PageSourceSearch

https://miamiyachtclub.com/about-us

html miamiyachtclub.com collected 2026-10-04 19:07:07 UTC 1,171,515 bytes, 25,056 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-3c161f3928-20261004t180747";
12        const reload_key = 'reloaded_for_missing_asset_at';
13        //a failure this soon after a reload means the reload didn't help, later ones are a new mismatch
14        const repeat_window_ms = 30 * 1000;
15        let has_reported_repeat = false;
16        let is_leaving_page = false;
17
18        //resolves once the event is sent, so a reload right after can't drop it
19        function report_missing_asset(url, type) {
20            //offline the event can't send, so waiting on it would only delay the reload
21            if (typeof Sentry === 'undefined' || navigator.onLine === false) {
22                return Promise.resolve();
23            }
24            const asset_path = new URL(url, window.location.href).pathname;
25            // so we can track if a certain asset is problematic
26            const missing_asset = asset_path.replace('/public/dist/', '').replace(/\.[0-9a-f]{12}(\.\w+)$/, '$1');
27            const metric = {
28                attributes: {
29                    type: type,
30                    missing_asset: missing_asset,
31                    page_version: page_version,
32                },
33            };
34            Sentry.metrics.count('missing_asset', 1, metric);
35            //we already reloaded and it didn't recover the page
36            if (type === 'repeat') {
37                Sentry.captureMessage('Missing asset after reload', {
38                    level: 'error',
39                    fingerprint: ['missing-asset-after-reload'],
40                    tags: {
41                        missing_asset: missing_asset,
42                        page_version: page_version,
43                    },
44                    extra: {
45                        missing_asset_url: asset_path,
46                    },
47                });
48            }
49            return Sentry.flush(2000);
50        }
51
52        //only failed script and stylesheet loads of hashed files count
53        function return_missing_asset_url(event) {
54            const element = event.target;
55            if (!element || (element.tagName !== 'SCRIPT' && element.tagName !== 'LINK')) {
56                return null;
57            }
58            const url = element.src || element.href || '';
59            if (!url.includes('/public/dist/')) {
60                return null;
61            }
62            return url;
63        }
64
65        //without storage we can't tell a first failure from a repeat, so we don't reload at all
66        function remember_reload() {
67            try {
68                window.sessionStorage.setItem(reload_key, String(Date.now()));
69                return true;
70            } catch(error) {
71                return false;
72            }
73        }
74
75        function has_reloaded_recently() {
76            try {
77                const reloaded_at = Number(window.sessionStorage.getItem(reload_key));
78                return Date.now() - reloaded_at < repeat_window_ms;
79            } catch(error) {
80                return false;
81            }
82        }
83
84        //errors from loads the browser abandons on navigation aren't missing files
85        window.addEventListener('pagehide', function(){
86            is_leaving_page = true;
87        });
88
89        //html from one deploy can reach an instance of the next, which doesn't have this page's hashed files
90        // .. in which case we reload to force them onto the new instance
91        window.addEventListener('error', function(event){
92            if (window.is_reloading_for_missing_asset || is_leaving_page) {
93                return;
94            }
95            const url = return_missing_asset_url(event);
96            if (!url) {
97                return;
98            }
99            //at most one reload per window so we don't loop
100            if (!has_reloaded_recently()) {
101                if (remember_reload()) {
102                    window.is_reloading_for_missing_asset = true;
103                    report_missing_asset(url, 'reload').finally(() => {
104                        window.location.reload();
105                    });
106                }
107            } else if (!has_reported_repeat) {
108                has_reported_repeat = true;
109                report_missing_asset(url, 'repeat');
110            }
111        }, true);
112    })();
113</script>
113
114    
115    
115<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
115
116    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
117    
117<script src="/public/assets/js/jquery-ui.min.js"></script>
117
118    <link href="/public/assets/css/jquery-ui.min.css" rel="stylesheet">
119    
119<script type="text/javascript" src="/parse-sdk-js"></script>
119
120    
120<script type="text/javascript" src="/public/dist/sc-common.628889f40a86.js"></script>
120
121    <!-- start vue -->
122    
123        <!-- prod minified vue js -->
124        
124<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.4.21/vue.global.prod.min.js"></script>
124
125    
126    
126<script src="/public/dist/catch-all/vue.99a908762ece.js"></script>
126
127    <!-- end vue -->
128    <!-- library for image orientation -->
129    
129<script src="/public/assets/js/load-image-all.min.js"></script>
129
130    <!-- moment.js -->
131    
131<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js"></script>
131
132    
132<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment-with-locales.min.js"></script>
132
133    
133<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.45/moment-timezone-with-data.min.js"></script>
133
134    <!-- library for charts -->
135    
135<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.js"></script>
135
136    <link rel="stylesheet" href="/public/assets/css/material-icons.css">
137    <!-- <link rel="stylesheet" href="https://kit.fontawesome.com/a59cae6e7b.css" crossorigin="anonymous"> -->
138    <link rel="stylesheet" href="/public/assets/css/font-awesome.css">
139    <!-- more js -->
140    
140<script src="/public/dist/js/cookies.184eea104904.js"></script>
140
141    
142
143    
144        <!-- web builder -->
145        
146<link href="https://fonts.googleapis.com/css2?family=Boogaloo&family=Bree+Serif&family=Carlito:ital,wght@0,400;0,700;1,400;1,700&family=Cinzel:wght@400;900&family=Cormorant+Garamond&family=Halant:wght@400;500;600;700&family=Inknut+Antiqua:wght@400;700&family=JetBrains+Mono:wght@400;500&family=Karla&family=Krona+One&family=Lato&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:wght@400;500&family=Montserrat:wght@400;500;600&family=Mrs+Saint+Delafield&family=Mukta:wght@400;700;800&family=Nunito+Sans&family=Open+Sans:ital,wght@0,300;0,800&family=Poller+One&family=PT+Sans:wght@400;700&family=Raleway:wght@400;500;700;800&family=Roboto:wght@400;500&family=Rubik:wght@400;500;700&family=Special+Gothic&family=Spectral+SC:wght@400;500;600;700&family=Spinnaker&family=Vollkorn&family=Zalando+Sans+Expanded:wght@400;500;700&display=swap" rel="stylesheet">
147
148        <!-- Juice browserified for inline css -->
149        
149<script src="/public/dist/browserified/juice-browserified.4ae70b219fc7.js"></script>
149
150        <!-- start calendar stuff (tui-calendar) -->
151        <link rel="stylesheet" type="text/css" href="https://uicdn.toast.com/tui-calendar/v1.15.0/tui-calendar.css" />
152        <link rel="stylesheet" type="text/css" href="https://uicdn.toast.com/tui.date-picker/latest/tui-date-picker.css" />
153        <link rel="stylesheet" type="text/css" href="https://uicdn.toast.com/tui.time-picker/latest/tui-time-picker.css" />
154        
154<script src="https://uicdn.toast.com/tui.code-snippet/v1.5.2/tui-code-snippet.min.js"></script>
154
155        
155<script src="https://uicdn.toast.com/tui-calendar/v1.15.0/tui-calendar.js"></script>
155
156        <!-- end calendar stuff -->
157        
157<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
157
158        
158<script src="/public/dist/js/browserify_src/bundle_browserify.078d255a7e2b.js"></script>
158
159        
159<script src="/public/dist/catch-all/common-web-builder.6885f73389b0.js"></script>
159
160        <!-- Cropper.js -->
161        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.css">
162        
162<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.js"></script>
162
163        <!-- unlayer (newer email builder, to replace tinymce) -->
164        
164<script src="https://editor.unlayer.com/embed.js"></script>
164
165        <!-- CodeMirror code editor (for custom-code component) -->
166        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
167        
167<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
167
168        
168<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/xml/xml.min.js"></script>
168
169        
169<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
169
170        
170<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/css/css.min.js"></script>
170
171        
171<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
171
172        
172<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/closebrackets.min.js"></script>
172
173        
173<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/closetag.min.js"></script>
173
174        
174<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/matchbrackets.min.js"></script>
174
175        <!-- end CodeMirror -->
176        
177    
178
179    
180    
181    <!-- weglot translations -->
182    
183         
184    
185
186    
187
188    
189    
190    <!-- userway.org accessibility snippet -->
191    
192         
193    
194
195
196    
197        
197<script src="/public/dist/catch-all/hosted-sites.7bbc5790960f.js"></script>
197
198        <link href="/public/dist/css/hosted.4b026a3f2a07.css" rel="stylesheet">
199        
199<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
199
200        
201        
201<script>
202            window.__cs_web_builder_default_animation = "";
203        </script>
203
204        <!-- custom styles -->
205        <style>
206
207        
208
209	
210
211	
212
213	
214	
215    
216
217	/* set base fonts */
218	
219	
220	
221	
222	
223	
224
225	/* set base colors */
226	
227	
228	
229    
230
231
232	/* now write the styles */
233
234	/* NOTE: the wb-content class needs to prepend all the styles so they are ONLY applied to hosted website content, not other content on the page - eg. for the website builder, it shouldn't impact the builder menu on the left */
235
236	.wb-content .cs-header p, 
237	.wb-content .cs-header div, 
238	.cs-footer-sublink-label,
239	.cs-header-sublink-label{
240		font-family: Lato !important;  
241	}
242
243	.wb-content h1{
244		font-size: 64px;
245		font-weight: normal;
246		font-family: Cormorant Garamond;
247	}
248
249    .custom-font-wrap h1{
250		font-family: Cormorant Garamond;
251	}
252
253	.wb-content h2 {
254		font-size: 42px;
255		font-weight: normal;
256		font-family: Cormorant Garamond;
257	}
258
259    .custom-font-wrap h2{
260		font-family: Cormorant Garamond;
261	}
262
263	.wb-content h3{
264		font-size: 28px;
265		font-weight: normal;
266		font-family: Cormorant Garamond;
267	}
268
269	.wb-content select{
270		font-size: 28px;
271		font-weight: normal;
272		font-family: Cormorant Garamond;
273	}
274
275    .custom-font-wrap h3{
276		font-family: Cormorant Garamond;
277	}
278
279	.wb-content p, 
280    .wb-content div{
281		font-size: 16px;
282		font-weight: normal;
283		font-family: Lato;
284	}
285
286    .custom-font-wrap p, 
287    .custom-font-wrap div{
288		font-family: Lato;
289	}
290
291	.wb-content button:not(.exclude-from-custom-button-styles),
292    .custom-font-wrap button:not(.exclude-from-custom-button-styles){
293		font-family: Cormorant Garamond;
294        font-weight: bold;
295        font-size: 14px;
296        text-transform: uppercase;
297        border-radius: 3px;
298        color: #fff;
299        background-color: #1b2556;
300        border: none !important;
301        transition: filter 0.15s ease-in-out;
302	}
303
304    .wb-content button:not(.exclude-from-custom-button-styles):hover,
305    .custom-font-wrap button:not(.exclude-from-custom-button-styles):hover{
306        filter: brightness(1.2);
307        background-color: #1b2556;
308        border: none !important;
309    }
310
311	/*-- tab bars --*/
312
313	.cs-tab-bar-option.active, 
314	.cs-tab-bar-option:hover
315	{
316	    color: #fff !important;
317	    background-color: #236651;
318	}
319
320    /* -- custom code for millbrook --*/
321    
322    /*-- end custom code for millbrook --*/
323
324	/*-- color schemes --*/
325
326	.wb-content .color_scheme_white .background, 
327	.wb-content .color_scheme_white.background
328	{
329		background-color: #fff;
330		color: #181b3e;
331	}
332
333	.wb-content .color_scheme_light .background, 
334	.wb-content .color_scheme_light.background
335	{
336		background-color: #eaefef;
337		color: #181b3e;
338	}
339
340	.wb-content .color_scheme_dark .background, 
341	.wb-content .color_scheme_dark.background
342	{
343		background-color: #181b3e;
344		color: #fff;
345	}
346
347	.wb-content .color_scheme_black .background, 
348	.wb-content .color_scheme_black.background
349	{
350		background-color: #000;
351		color: #fff;
352	}
353
354	.wb-content .color_scheme_accent_light .background, 
355	.wb-content .color_scheme_accent_light.background
356	{
357		background-color: #fff;
358		color: #236651;
359	}
360
361	.wb-content .color_scheme_accent_dark .background, 
362	.wb-content .color_scheme_accent_dark.background
363	{
364		background-color: #236651;
365		color: #fff;
366	}
367
368	/*----- color schemes for header ------ */
369
370	/* Keep gradient channels tied to the same custom palette as the solid header. */
371	.cs-header.color_scheme_light{ --cs-header-background-rgb: 234, 239, 239 !important; }
372	.cs-header.color_scheme_dark{ --cs-header-background-rgb: 24, 27, 62 !important; }
373	.cs-header.color_scheme_accent_dark{ --cs-header-background-rgb: 35, 102, 81 !important; }
374	.cs-header.cs-header-background-color-white{ --cs-header-background-rgb: 255, 255, 255 !important; }
375	.cs-header.cs-header-background-color-light{ --cs-header-background-rgb: 234, 239, 239 !important; }
376	.cs-header.cs-header-background-color-dark{ --cs-header-background-rgb: 24, 27, 62 !important; }
377	.cs-header.cs-header-background-color-black{ --cs-header-background-rgb: 0, 0, 0 !important; }
378	.cs-header.cs-header-background-color-accent_light{ --cs-header-background-rgb: 255, 255, 255 !important; }
379	.cs-header.cs-header-background-color-accent_dark{ --cs-header-background-rgb: 35, 102, 81 !important; }
380
381	.color_scheme_white .cs-header-link-wrap > p, 
382	.color_scheme_white .cs-burgee-wrap > p{
383	  color: #181b3e !important;
384	}
385
386	.color_scheme_white .cs-header-link-wrap::after{
387	  background: #181b3e;
388	}
389
390	.color_scheme_light .cs-header-background{
391	  background-color: #eaefef;
392	}
393
394	.color_scheme_light .cs-header-link-wrap > p, 
395	.color_scheme_light .cs-burgee-wrap > p{
396	  color: #181b3e !important;
397	}
398
399	.color_scheme_light .cs-header-link-wrap::after{
400	  background: #181b3e;
401	}
402
403	.color_scheme_dark .cs-header-background{
404	  background-color: #181b3e;
405	} 
406
407	.color_scheme_dark .cs-header-link-wrap > p, 
408	.color_scheme_dark .cs-burgee-wrap > p{
409	  color: #fff;
410	}
411
412	.color_scheme_dark .cs-header-link-wrap::after{
413	  background: #fff;
414	}
415
416	.color_scheme_black .cs-header-background{
417	  background-color: #000;
418	}
419
420	.color_scheme_black .cs-header-link-wrap > p, 
421	.color_scheme_black .cs-burgee-wrap > p{
422	  color: #fff;
423	}
424
425	.color_scheme_black .cs-header-link-wrap::after{
426	  background: #fff;
427	}
428
429	.color_scheme_accent_light .cs-header-background{
430	  background-color: #fff;
431	}
432
433	.color_scheme_accent_light .cs-header-link-wrap > p, 
434	.color_scheme_accent_light .cs-burgee-wrap > p{
435	  color: #236651;
436	}
437
438	.color_scheme_accent_light .cs-header-link-wrap::after{
439	  background: #236651;
440	}
441
442	.color_scheme_accent_dark .cs-header-background{
443	  background-color: #236651;
444	}
445
446	.color_scheme_accent_dark .cs-header-link-wrap > p, 
447	.color_scheme_accent_dark .cs-burgee-wrap > p{
448	  color: #fff;
449	}
450
451	.color_scheme_accent_dark .cs-header-link-wrap::after{
452	  background: #fff;
453	}
454
455	/* Animated sub-menu text follows the same accent color as the nav links. */
456	.cs-header-submenu-style_animated_buttons.color_scheme_accent_light .cs-header-sublink-label,
457	.cs-header-submenu-style_animated_flat.color_scheme_accent_light .cs-header-sublink-label{
458	  color: #236651;
459	}
460
461	.cs-header-submenu-style_animated_buttons.color_scheme_accent_light .cs-header-sublink-label:hover,
462	.cs-header-submenu-style_animated_flat.color_scheme_accent_light .cs-header-sublink-label:hover{
463	  color: #236651;
464	  opacity: .7;
465	}
466
467	/* Edge-fade / color-token overlays follow club palette. Do not set
468	   background-color on bare .cs-image-overlay — classic opacity-only
469	   layers must keep the hardcoded #141a21 fill from hosted.css. */
470	.cs-image-overlay.cs-overlay-color-light,
471	.cs-header-section-gradient.cs-overlay-color-light{
472	  --cs-overlay-rgb: 234, 239, 239;
473	}
474	.cs-image-overlay.cs-overlay-color-dark,
475	.cs-header-section-gradient.cs-overlay-color-dark{
476	  --cs-overlay-rgb: 24, 27, 62;
477	}
478	.cs-image-overlay.cs-overlay-color-top-light{ --cs-overlay-rgb-top: 234, 239, 239; }
479	.cs-image-overlay.cs-overlay-color-top-dark{ --cs-overlay-rgb-top: 24, 27, 62; }
480	.cs-image-overlay.cs-overlay-color-bottom-light{ --cs-overlay-rgb-bottom: 234, 239, 239; }
481	.cs-image-overlay.cs-overlay-color-bottom-dark{ --cs-overlay-rgb-bottom: 24, 27, 62; }
482	.cs-image-overlay.cs-overlay-top,
483	.cs-image-overlay.cs-overlay-bottom,
484	.cs-image-overlay.cs-overlay-both,
485	.cs-image-overlay.cs-overlay-gradient_top,
486	.cs-image-overlay.cs-overlay-gradient_bottom{
487	  background-color: transparent;
488	}
489	.cs-fade-color-top-light{
490	  --cs-fade-color-top: #eaefef;
491	}
492	.cs-fade-color-top-dark{
493	  --cs-fade-color-top: #181b3e;
494	}
495	.cs-fade-color-bottom-light{
496	  --cs-fade-color-bottom: #eaefef;
497	}
498	.cs-fade-color-bottom-dark{
499	  --cs-fade-color-bottom: #181b3e;
500	}
501
502	/* Overlap fade-through color follows club palette when set. */
503	.cs-overlap-through-light,
504	:not(.event-page-callout):has(+ .cs-overlap-through-light),
505	.componentPreview:has(+ .componentPreview .cs-overlap-through-light),
506	.wb-diff-section:has(+ .wb-diff-section .cs-overlap-through-light){
507	  --cs-overlap-through-rgb: 234, 239, 239;
508	}
509	.cs-overlap-through-dark,
510	:not(.event-page-callout):has(+ .cs-overlap-through-dark),
511	.componentPreview:has(+ .componentPreview .cs-overlap-through-dark),
512	.wb-diff-section:has(+ .wb-diff-section .cs-overlap-through-dark){
513	  --cs-overlap-through-rgb: 24, 27, 62;
514	}
515
516
517</style>        <!-- favicon -->
518        
519            <!-- try to fall back to the burgee url, but google might not love this if it's not sized properly, so we really want favicon_url set -->
520            <link rel="shortcut icon" href="https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306">
521            <link rel="icon" sizes="16x16 32x32 64x64" href="https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306">
522        
523        <!-- touch icon (mobile web) -->
524        
525        <!-- web app title (mobile safari) -->
526        
527            <meta name="apple-mobile-web-app-title" content="Miami Yacht Club">
528        
529        <!-- app_manifest (android) -->
530        
531    
532
533    
534
535    
536    
537        <title>About Us - Miami Yacht Club</title>
538    
539    
540        <link rel="canonical" href="https://miamiyachtclub.com/about-us">
541    
542    <!-- fb open graph tags -->
543    <meta property="fb:app_id" content="314788812688609" />
544    
545        <meta property="og:image" content="https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306">
546    
547    
548
549    <!-- Google Analytics for clubs -->
550     
551    <!-- END google analytics for clubs -->
552
553    <!-- FB pixel for clubs -->
554     
555    <!-- FB pixel for clubs -->
556
557    
558
559    <!-- vue date picker -->
560    <!-- see: https://vue3datepicker.com/ -->
561    
561<script src="/public/assets/js/vue-date-picker-8.5.1.js"></script>
561
562    <link rel="stylesheet" href="/public/assets/css/vue-date-picker.css">
563
564    <!-- Member portal splash video: warm the CDN connection and start fetching the splash
565         video during initial HTML parse, before Vue mounts the <video> element. This avoids
566         the multi-second gap (especially in the native app webview) where only the poster
567         image shows while the JS bundle loads, the club loads, and Vue renders. -->
568    
569
570</head>    
571    <body 
572        class='wb-content hosted-page' 
573        data-club-id='ee2X1rcgnh' 
574        data-club-name='Miami Yacht Club' 
575        data-timezone='America/New_York'
576    >
577        
578
579    <div class="globalMessageInsert flexNoWrap easeFast hideForPrinting">
580        <div class="global-message-icon-loading">
581            <div class="global-message-icon-spinner"></div>
582        </div>
583        <p>
584            <i class="fa-solid fa-square-check global-message-icon-good"></i>
585            <i class="fa-solid fa-square-xmark global-message-icon-bad"></i>
586            <span></span>
587        </p>
588    </div>
589
590
591        <!-- start standard content -->
592        <div id="v-content" class="hidden relative leftText relative easeFast paddingTopHeader">
593            <!-- header -->
594            
595            <custom-page-header
596                v-if="! is_react_native_member_app"
597            ></custom-page-header>
598            
599            <!-- page components -->
600            
601            
602                
603                
604                    
605                        <!-- check the config -->
606
607
608
609<!-- clean them first, to weed out null values -->
610
611
612    
613
614    
615
616    
617
618    
619
620    
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656 
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683<!-- now generate markup -->
684
685    <hero-banner-component
686        :client_id="'Qcf3YhEMSq4_1769033465'"
687        :formatting="'{&#34;section_height&#34;:&#34;50&#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;40&#34;}'"
688        :class_string="' color_scheme_dark orientation-left number_of_columns-3 cs-first-page-section caption_style-show_on_hover'"
689        :style_string="'min-height:50vh;align-items:center;'"
690        :element_style_string="'max-width:70%;'"
691        :component_type="'hero-banner'"
692        :elements_string="'%5B%7B%22type%22%3A%22h1-center%22%2C%22content%22%3A%22%3Ch1%20style%3D%5C%22text-align%3Acenter%5C%22%3EMiami%20Yacht%20Club%3C%2Fh1%3E%22%2C%22client_id%22%3A%22UGtbyzzZun74BIiX%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22h3-center%22%2C%22content%22%3A%22%3Ch3%20style%3D%5C%22text-align%3Acenter%5C%22%3ELocated%20in%20the%20Heart%20of%20Miami%3C%2Fh3%3E%3Cdiv%3E%3Cbr%20%2F%3E%3C%2Fdiv%3E%22%2C%22client_id%22%3A%22or1ox6Mn1qHu7Uk5%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22button%22%2C%22content%22%3A%22%3Cbutton%20class%3D%5C%22cs-button%5C%22%20style%3D%5C%22margin-left%3Aauto%3Bmargin-right%3Aauto%3Btext-align%3Acenter%3Bbackground-color%3Argb%2528255%2C%20255%2C%20255%2529%5C%22%3E%3Ca%20href%3D%5C%22%2Fmembership%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-KTR8Veb4Khz_1769033538%5C%22%3E%3Cspan%20class%3D%5C%22cs-font-size-18%20cs-7dWsHMFuwmp_1769033528_1769033528419%5C%22%20style%3D%5C%22background-color%3Argb%2528255%2C%20255%2C%20255%2529%3Bcolor%3Argb%25280%2C%200%2C%200%2529%5C%22%3E%3Cb%3EMembership%3C%2Fb%3E%3C%2Fspan%3E%3C%2Fa%3E%3C%2Fbutton%3E%22%2C%22client_id%22%3A%22eqt8BdmOsojwCfhY%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'"
693        :images_string="'[&#34;https://d282wvk2qi4wzk.cloudfront.net/cLvHvcTIWIRf_Qcf3YhEMSq4_1769033465_0_image_1769033473740&#34;]'"
694    ></hero-banner-component>
695
696                        
697                    
698                
699                    
700                        <!-- check the config -->
701
702
703
704<!-- clean them first, to weed out null values -->
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741 
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768<!-- now generate markup -->
769
770    <hero-banner-component
771        :client_id="'BDwzdDmFICP_1769033762'"
772        :formatting="'{&#34;section_height&#34;:&#34;1&#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;}'"
773        :class_string="' color_scheme_light orientation-left number_of_columns-3 caption_style-show_on_hover'"
774        :style_string="'min-height:1vh;align-items:center;'"
775        :element_style_string="'max-width:70%;'"
776        :component_type="'hero-banner'"
777        :elements_string="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch2%20style%3D%5C%22text-align%3Acenter%5C%22%3EMiami%20Yacht%20Club%3C%2Fh2%3E%3Cdiv%3E%3Cbr%20%2F%3E%3C%2Fdiv%3E%22%2C%22client_id%22%3A%22HVCfXQ66LuYa5sea%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22text-left%22%2C%22content%22%3A%22%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3EFor%20over%2080%20years%20we%20have%20been%20actively%20working%20with%20our%20community%20to%20foster%20an%20appreciation%20in%20the%20beautiful%20waters%20of%20Miami.%20We%20teach%20children%2C%20teenagers%20and%20adults%20to%20sail%20through%20our%20501%2528c%2529%25283%2529%20not-for-profit%2C%20the%20%3Ca%20href%3D%5C%22mycyouthsailing.com%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-5nlN5cwGaco_1769034139%5C%22%3EMiami%20Yacht%20Club%20Youth%20Sailing%20Foundation%3C%2Fa%3E.%20These%20classes%20are%20open%20to%20everyone%20%E2%80%93%20not%20just%20members%20of%20the%20Club.%20We%20provide%20meeting%20space%20to%20many%20local%2C%20national%20and%20international%20organizations%20including%20the%20Overtown%20Youth%20Center%2C%20Veterans%20Oceans%20Adventures%2C%20Lotus%20House%2C%20Amigos%20for%20Kids%2C%20Rotary%20Club%20and%20
777others.%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3EWe%20also%20host%20the%20City%20of%20Miami%20Marine%20Patrol%20and%20Underwater%20Recovery%20Unit.%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3EOur%20sailors%20have%20represented%20Miami%20Yacht%20Club%20all%20over%20the%20world%3A%20from%20youth%20events%20to%20the%20Olympics.%20Through%20the%20efforts%20of%20our%20internationally%20recognized%20coaches%2C%20dedicated%20sailors%20and%20MYC%20families%2C%20we%20are%20truly%20the%20%3Ca%20href%3D%5C%22%2Fracing%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-8djb42KDn8L_1769034170%5C%22%3E%E2%80%9CHome%20of%20Sailing%20Champions%2521%E2%80%9D%3C%2Fa%3E%3C%2Fp%3E%22%2C%22client_id%22%3A%22XfNxZPXeJ1Pt04Ci%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'"
778        :images_string="'[]'"
779    ></hero-banner-component>
780
781                        
782                    
783                
784                    
785                        <!-- check the config -->
786
787
788
789<!-- clean them first, to weed out null values -->
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826 
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853<!-- now generate markup -->
854	
855    <div class="cs-text background flexWrap relative  color_scheme_white orientation-left number_of_columns-2 caption_style-show_on_hover" style="min-height:1vh;align-items:center;">
856        
857            <div class="element-wrapper flexNoWrap flexWrapOnMobile" class="'element-wrapper_' + text" style="max-width:100%;">
858                
859                    <text-component
860                        :number_of_columns="'2'"	
861                        :column_prop="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch2%20style%3D%5C%22text-align%3Acenter%5C%22%3EMYC%20Quicklinks%3C%2Fh2%3E%22%2C%22client_id%22%3A%22OZMuHCuHEBzkfKYt%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EOffice%20Hours%20of%20Operation%3C%2Fh3%3E%22%2C%22client_id%22%3A%221yQ6kYc66ZyneE9I%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3E%3Cb%3EOffice%20Hours%3C%2Fb%3E%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3ETues%20%26amp%3B%20Thurs%3C%2Fp%3E%3Cp%3E11%3A00am%20%E2%80%93%204%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EWed%20%26amp%3B%20Fri%3C%2Fp%3E%3Cp%3E12%3A00pm%20%E2%80%93%206%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3ESat%20%26amp%3B%20Sun%3C%2Fp%3E%3Cp%3E11%3A00am%20%E2%80%93%204%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EClosed%20Monday%3C%2Fp%3E%22%2C%22client_id%22%3A%22jRgUbR0zaFeDnAaO%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EDining%20%26amp%3B%20Events%3C%2Fh3%3E%22%2C%22client_id%22%3A%22YuEuIeE2iVcVB4Y5%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3E%3Cb%3ERestaurant%20Hours%3C%2Fb%3E%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EWed%3C%2Fp%3E%3Cp%3E4%3A00pm%20%E2%80%93%2010%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EThus%20%26amp%3B%20Sun%3C%2Fp%3E%3Cp%3E11%3A00am%20%E2%80%93%2010%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EFri%20%26amp%3B%20Sat%3C%2Fp%3E%3Cp%3E11%3A00am%20%E2%80%93%2011%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EClosed%20Monday%20%26amp%3B%20Tuesday%3C%2Fp%3E%22%2C%22client_id%22%3A%22yZNlTeetXsEhH1Jy%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EMembership%3C%2Fh3%3E%22%2C%22client_id%22%3A%22sNd5kCyEhgQll9xG%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cdiv%20class%3D%5C%22elementor-toggle-item%5C%22%20style%3D%5C%22box-sizing%3Aborder-box%3Bcolor%3Argb%252851%2C%2051%2C%2051%2529%3Bfont-family%3A-apple-system%2C%20%26quot%3Bsystem-ui%26quot%3B%2C%20%26quot%3BSegoe%20UI%26quot%3B%2C%20Roboto%2C%20%26quot%3BHelvetica%20Neue%26quot%3B%2C%20Arial%2C%20%26quot%3BNoto%20Sans%26quot%3B%2C%20sans-serif%2C%20%26quot%3BApple%20Color%20Emoji%26quot%3B%2C%20%26quot%3BSegoe%20UI%20Emoji%26quot%3B%2C%20%26quot%3BSegoe%20UI%20Symbol%26quot%3B%2C%20%26quot%3BNoto%20Color%20Emoji%26quot%3B%3Bbackground-color%3Argb%2528255%2C%20255%2C%20255%2529%5C%22%3E%3Cdiv%20id%3D%5C%22elementor-tab-content-6903%5C%22%20class%3D%5C%22elementor-tab-content%20elementor-clearfix%20elementor-active%5C%22%20data-tab%3D%5C%223%5C%22%20style%3D%5C%22box-sizing%3Aborder-box%3Bborder-bottom%3A1px%20solid%20rgb%252828%2C%2036%2C%2075%2529%3Bpadding%3A15px%3Bcolor%3Argb%252828%2C%2036%2C%2075%2529%3Bfont-family%3ARoboto%2C%20sans-serif%3Bfont-size%3A18px%5C%22%3E%3Cp%20style%3D%5C%22box-sizing%3Aborder-box%3Bmargin-block%3A0px%200.9rem%5C%22%3EMiami%20Yacht%20Club%2C%20welcomes%20new%20members%20looking%20to%20be%20part%20of%20our%20sailing%20tradition.%20If%20you%20would%20like%20more%20information%2C%20please%C2%A0%3Ca%20href%3D%5C%22mailto%3Aoffice%40miamiyachtclub.com%5C%22%20style%3D%5C%22box-sizing%3Aborder-box%3Bbackground-color%3Atransparent%3Bcolor%3Argb%2528204%2C%2051%2C%20102%2529%3Bbox-sha
861dow%3Anone%5C%22%3Eemail%20us.%3C%2Fa%3E%3C%2Fp%3E%3C%2Fdiv%3E%3C%2Fdiv%3E%22%2C%22client_id%22%3A%22CHqT6jjVQDagzYkT%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3E%C2%A0Contact%20Us%3C%2Fh3%3E%22%2C%22client_id%22%3A%220kIs0sGLjXljtfp9%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3E%3Cb%3EMiami%20Yacht%20Club%3C%2Fb%3E%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EServing%20Miami%20since%201927%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3E%3C%2Fp%3E%3Cp%3E%3C%2Fp%3E%3Ca%20href%3D%5C%22tel%3A305-377-9877%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-OpqGTxcFPPr_1769034366%5C%22%3E%3Cp%3E305-377-9877%3C%2Fp%3E%3Cp%3E%3C%2Fp%3E%3Cp%3E%3C%2Fp%3E%3C%2Fa%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3E%3Ca%20href%3D%5C%22mailto%3Aoffice%40miamiyachtclub.com%5C%22%20class%3D%5C%22cs-link-zD18UBY29EC_1769034374%5C%22%3Eoffice%40miamiyachtclub.com%3C%2Fa%3E%3C%2Fp%3E%22%2C%22client_id%22%3A%222yzhyaE25oTIMSRc%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'"
862                        :column_index="'0'"
863                        :section_element_animation="''"
864                    ></text-component>	
865                
866                    <text-component
867                        :number_of_columns="'2'"	
868                        :column_prop="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch2%20style%3D%5C%22text-align%3Acenter%5C%22%3EVisitors%20Information%3C%2Fh2%3E%22%2C%22client_id%22%3A%22QUsiz8wuG86rYDbn%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EVisitors%20are%20Always%20Welcome%3C%2Fh3%3E%22%2C%22client_id%22%3A%22hSoO70CmznvxUX2m%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3EAs%20a%20visitor%20you%20are%20welcome%20to%20enjoy%20our%20restaurant.%20We%20have%20lunch%20and%20dinner%20specials%20daily%20with%20the%20exception%20of%20Monday%20when%20the%20restaurant%20and%20bar%20are%20closed.%20Visitors%20are%20also%20welcome%20to%20inquire%20about%20hosting%20a%20party%2C%20banquet%20or%20meeting%20at%20the%20club.%20MYC%20burgees%2C%20shirts%2C%20hats%2C%20jackets%20and%20more%20can%20be%20purchased%20in%20our%20ship%E2%80%99s%20store.%3C%2Fp%3E%22%2C%22client_id%22%3A%22sHPwrt8XapdKkXtK%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3ERacers%20are%20Welcome%20at%20MYC%3C%2Fh3%3E%22%2C%22client_id%22%3A%22MRw5WsIY3vZLKH7o%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3ERegat%3Ca%20href%3D%5C%22racing%5C%22%20class%3D%5C%22cs-link-o63aCm3gBoe_1788365771%5C%22%3E%3C%2Fa%3Eta%20participants%20are%20warmly%20welcomed%20during%20our%20hosted%20events.%20Regatta%20participants%20should%20check%20with%20the%20office%20or%20regatta%20chair%20about%20support%20boat%20storage%2C%20amenities%20or%20if%20extended%20stays%20need%20to%20be%20arranged.%20Daily%20rates%20do%20not%20apply%20to%20regatta%20participants%20during%20hosted%20event.%3C%2Fp%3E%22%2C%22client_id%22%3A%22gYbFTXFMGyctx83r%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EVisiting%20Yachts%3C%2Fh3%3E%22%2C%22client_id%22%3A%22E3LxMoaJ8NNJ7DIF%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3EVisiting%20yachts%20should%20contact%20the%20main%20office%20for%20availability%20of%20space%20for%20their%20vessel.%20Charges%20for%20visiting%20yachts%20desiring%20to%20land%20and%20use%20the%20facilities%20of%20the%20Club%20vary%20based%20on%20club%20reciprocity%20with%20the%20visiting%20captain%E2%80%99s%20home%20yacht%20club.%3C%2Fp%3E%22%2C%22client_id%22%3A%2259kTefIyomabwkQA%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3E%C2%A0Visitor%20Amenities%3C%2Fh3%3E%22%2C%22client_id%22%3A%22Q6TPt5UsMA7u3O1S%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3EVisiting%20yachts%20desiring%20to%20lay%20alongside%20the%20dock%20
868and%20utilize%20the%20electricity%2C%20fresh%20water%2C%20make%20minor%20repairs%2C%20etc.%2C%20may%20do%20so%20for%20a%20charge%20and%20based%20on%20availability.%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3ELike%20our%20members%2C%20all%20visitors%20are%20expected%20to%20follow%20the%20rules%20of%20the%20Club.%3C%2Fp%3E%22%2C%22client_id%22%3A%22gIjjxiwsIxif5yey%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'"
869                        :column_index="'1'"
870                        :section_element_animation="''"
871                    ></text-component>	
872                
873                    <text-component
874                        :number_of_columns="'2'"	
875                        :column_prop="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch2%20style%3D%5C%22text-align%3Acenter%5C%22%3EClick%20to%20edit%3C%2Fh2%3E%22%2C%22client_id%22%3A%22h8JGDLiPxh8BIhoy%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22h3-center%22%2C%22content%22%3A%22%3Ch3%20style%3D%5C%22text-align%3Acenter%5C%22%3EClick%20to%20edit%3C%2Fh3%3E%22%2C%22client_id%22%3A%22bz0fwE3vaBVofo65%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'"
876                        :column_index="'2'"
877                        :section_element_animation="''"
878                    ></text-component>	
879                
880            </div>
881        
882    </div>
883
884                        
885                    
886                
887            
888            <!-- footer -->
889            
890                
891
892<!-- check the config -->
893
894    
895<div class="cs-footer relative color_scheme_white background" style="" >
896    <div class="">
897        <div class="cs-footer-flex-wrap-row">
898            <div class="cs-footer-logo-wrap background">
899                
900                    
901                    
902                    <div class="cs-burgee-wrap relative" style="height:190px;width:300px;">
903                        <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306)"></div>
904                        <a class="absoluteA" href="/"></a>
905                    </div>
906                
907            </div>
908            <div>
909                <div class="flexWrapLeftAlign">
910                    <div class="cs-footer-link-wrap flexGrowOne noPaddingBottom">
911                        
912                            
913                            
914                            
915                                
916                                    <!-- component -->
917                                    <div class="footer-link-section flexGrowOne">
918                                        
919                                        
920                                            <p class="footer-sublink-section-header smallMarginTop">Navigation</p>
921                                        
922                                        <!-- handle sub-components -->
923                                        
924                                        
925                                            <div class="cs-footer-sublink-outer easeFast">
926                                                
927                                                    
928                                                    
929                                                    
930                                                        
931                                                            <div class="cs-footer-sublink-wrap relative">
932                                                                
933                                                                    
934                                                                    
935                                                                        <p class="cs-footer-sublink-label">
936                                                                            NOAA Nautical Charts
937                                                                            <a class="absoluteA" href="https://nauticalcharts.noaa.gov/enconline/enconline.html"></a>
938                                                                        </p>
939                                                                    
940                                                                
941                                                            </div>
942                                                        
943                                                    
944                                                
945                                                    
946                                                    
947                                                    
948                                                        
949                                                            <div class="cs-footer-sublink-wrap relative">
950                                                                
951                                                                    
952                                                                    
953                                                                        <p class="cs-footer-sublink-label">
954                                                                            Adult Sailing Courses
955                                                                            <a class="absoluteA" href="https://charismasailing.org/locations/miami-yacht-club"></a>
956                                                                        </p>
957                                                                    
958                                                                
959                                                            </div>
960                                                        
961                                                    
962                                                
963                                                    
964                                                    
965                                                    
966                                                        
967                                                            <div class="cs-footer-sublink-wrap relative">
968                                                                
969                                                                    
970                                                                    
971                                                                        <p class="cs-footer-sublink-label">
972                                                                            Racing
973                                                                            <a class="absoluteA" href="/racing"></a>
974                                                                        </p>
975                                                                    
976                                                                
977                                                            </div>
978                                                        
979                                                    
980                                                
981                                                    
982                                                    
983                                                    
984                                                        
985                                                            <div class="cs-footer-sublink-wrap relative">
986                                                                
987                                                                    
988                                                                    
989                                                                        <p class="cs-footer-sublink-label">
990                                                                            MYC Outreach
991                                                                            <a class="absoluteA" href="/myc-outreach"></a>
992                                                                        </p>
993                                                                    
994                                                                
995                                                            </div>
996                                                        
997                                                    
998                                                
999                                                    
1000                                                    
1001                                                    
1002                                                        
1003                                                            <div class="cs-footer-sublink-wrap relative">
1004                                                                
1005                                                                    
1006                                                                    
1007                                                                        <p class="cs-footer-sublink-label">
1008                                                                            Event Calendar
1009                                                                            <a class="absoluteA" href="/events-calendar"></a>
1010                                                                        </p>
1011                                                                    
1012                                                                
1013                                                            </div>
1014                                                        
1015                                                    
1016                                                
1017                                                    
1018                                                    
1019                                                    
1020                                                        
1021                                                            <div class="cs-footer-sublink-wrap relative">
1022                                                                
1023                                                                    
1024                                                                    
1025                                                                        <p class="cs-footer-sublink-label">
1026                                                                            About Us
1027                                                                            <a class="absoluteA" href="/about-us"></a>
1028                                                                        </p>
1029                                                                    
1030                                                                
1031                                                            </div>
1032                                                        
1033                                                    
1034                                                
1035                                                    
1036                                                    
1037                                                    
1038                                                        
1039                                                            <div class="cs-footer-sublink-wrap relative">
1040                                                                
1041                                                                    
1042                                                                    
1043                                                                        <p class="cs-footer-sublink-label">
1044                                                                            Membership
1045                                                                            <a class="absoluteA" href="/membership"></a>
1046                                                                        </p>
1047                                                                    
1048                                                                
1049                                                            </div>
1050                                                        
1051                                                    
1052                                                
1053                                            </div>
1054                                        
1055                                    </div>
1056                                
1057                            
1058                        
1059                            
1060                            
1061                            
1062                                
1063                                    <!-- component -->
1064                                    <div class="footer-link-section flexGrowOne">
1065                                        
1066                                        
1067                                            <p class="footer-sublink-section-header smallMarginTop">Contact Us</p>
1068                                        
1069                                        <!-- handle sub-components -->
1070                                        
1071                                        
1072                                            <div class="cs-footer-sublink-outer easeFast">
1073                                                
1074                                                    
1075                                                    
1076                                                    
1077                                                        
1078                                                            <div class="cs-footer-sublink-wrap relative">
1079                                                                
1080                                                                    
1081                                                                    
1082                                                                        <p class="cs-footer-sublink-label">
1083                                                                            305-377-9877
1084                                                                            <a class="absoluteA" href="tel:305-377-9877"></a>
1085                                                                        </p>
1086                                                                    
1087                                                                
1088                                                            </div>
1089                                                        
1090                                                    
1091                                                
1092                                                    
1093                                                    
1094                                                    
1095                                                        
1096                                                            <div class="cs-footer-sublink-wrap relative">
1097                                                                
1098                                                                    
1099                                                                    
1100                                                                        <p class="cs-footer-sublink-label">
1101                                                                            [email protected]
1102                                                                            <a class="absoluteA" href="mailto: [email protected]"></a>
1103                                                                        </p>
1104                                                                    
1105                                                                
1106                                                            </div>
1107                                                        
1108                                                    
1109                                                
1110                                                    
1111                                                    
1112                                                    
1113                                                        
1114                                                            <div class="cs-footer-sublink-wrap relative">
1115                                                                
1116                                                                    
1117                                                                    
1118                                                  
1118                      <p class="cs-footer-sublink-label">
1119                                                                            1001 MacArthur Causeway, Miami, FL 33132
1120                                                                            <a class="absoluteA" href="https://www.google.com/maps/place/Miami+Yacht+Club/@25.7859326,-80.1731247,15z/data=!4m6!3m5!1s0x88d9b416c387f001:0x6423ef1fdc4db60e!8m2!3d25.7859326!4d-80.1731247!16s%2Fg%2F1tfrsqr0"></a>
1121                                                                        </p>
1122                                                                    
1123                                                                
1124                                                            </div>
1125                                                        
1126                                                    
1127                                                
1128                                                    
1129                                                    
1130                                                    
1131                                                        
1132                                                            <div class="cs-footer-sublink-wrap relative">
1133                                                                
1134                                                                    
1135                                                                    
1136                                                                        <p class="cs-footer-sublink-label">
1137                                                                            Membership Application
1138                                                                            <a class="absoluteA" href="/apply"></a>
1139                                                                        </p>
1140                                                                    
1141                                                                
1142                                                            </div>
1143                                                        
1144                                                    
1145                                                
1146                                            </div>
1147                                        
1148                                    </div>
1149                                
1150                            
1151                        
1152                        <!-- social icons -->
1153                        
1154                            <div class="footer-link-section flexGrowOne">
1155                                <p class="footer-sublink-section-header smallMarginTop">Social</p>
1156                                <div class="cs-footer-sublink-outer easeFast">
1157                                    <div class="flexWrapLeftAlign">
1158                                        
1159                                            
1160                                                <a href="https://www.instagram.com/miamiyachtclub1927/" target="_blank" style="display: flex; cursor: pointer; text-decoration: none; border: none !important;">
1161                                                    <i class="fa-brands fa-instagram" style="font-size:30px; color: #16163f; margin: 10px"></i>
1162                                                </a>
1163                                            
1164                                        
1165                                            
1166                                        
1167                                            
1168                                        
1169                                            
1170                                        
1171                                            
1172                                        
1173                                            
1174                                        
1175                                            
1176                                        
1177                                            
1178                                                <a href="https://www.facebook.com/MiamiYachtClub/" target="_blank" style="display: flex; cursor: pointer; text-decoration: none; border: none !important;">
1179                                                    <i class="fa-brands fa-facebook-f" style="font-size:30px; color: #16163f; margin: 10px"></i>
1180                                                </a>
1181                                            
1182                                        
1183                                            
1184                                        
1185                                            
1186                                        
1187                                            
1188                                        
1189                                            
1190                                                <a href="https://www.linkedin.com/company/miami-yacht-club/about/" target="_blank" style="display: flex; cursor: pointer; text-decoration: none; border: none !important;">
1191                                                    <i class="fa-brands fa-linkedin-in" style="font-size:30px; color: #16163f; margin: 10px"></i>
1192                                                </a>
1193                                            
1194                                        
1195                                            
1196                                        
1197                                            
1198                                        
1199                                            
1200                                        
1201                                            
1202                                        
1203                                    </div>
1204                                </div>
1205                            </div>
1206                        
1207                    </div>
1208                </div> 
1209                <div class="cs-footer-link-wrap noPaddingTop">
1210                    <div class="powered_by_wrap relative">
1211                        <div class="flexNoWrap align-items-center">
1212                            <p class="powered_by"><i class="fa-solid fa-shield-check" style="padding-right:3px;"></i> Powered by</p>
1213                            <p class="modern pointer powered_by_text relative" style="font-weight:bold;">Clubspot <a class="absoluteA" href="https://theclubspot.com"></a></p>
1214                        </div>
1215                        
1216                        <p style="display:flex;font-family:roboto !important;" class="cs-footer-sublink-label roboto copyright_text noPointer">© 2026 Miami Yacht Club, All rights reserved.</p>
1217                    </div> 
1218                </div>
1219            </div>
1220        </div>
1221    </div>
1222</div>            
1223
1224            <!-- react native tab bar -->
1225            <native-tab-bar-component></native-tab-bar-component>
1226
1227        </div>
1228        <div id="v-overlays" class="hidden">
1229            <!-- common -->
1230            <template v-if="overlays.show_push_notification">
1231                <show-push-notification></show-push-notification>
1232            </template>
1233            <template v-if="overlays.overflow_overlay">
1234                <overflow-overlay></overflow-overlay>
1235            </template>
1236
1237            <template v-if="overlays.view_all_events">
1238                <view-all-events-overlay></view-all-events-overlay>
1239            </template>
1240
1241            <template v-if="overlays.view_photo_carousel">
1242                <view-photo-carousel></view-photo-carousel>
1243            </template>
1244             <template v-if="overlays.show_event_details_in_member_cal">
1245                <show-event-details-in-member-cal-overlay></show-event-details-in-member-cal-overlay>
1246            </template>
1247
1248        </div>
1249        <div id="overlay_membership-directory-popup" class="standardOverlay scrollable hugePaddingBottom">
1250    <div class="standardOverlayCard centeredBlock eightyThenNinety overflowHidden relative">
1251        <div class="xButton" onclick="fadeOut('#overlay_membership-directory-popup');"></div>
1252        <div class="profile-background overflowHidden">
1253            <div class="profile-background-inner hundop" style="height:100%;"></div>
1254        </div>
1255        <div class="profile-picture color_scheme_accent_dark background capitalize" onclick="openDirectoryProfileImage(this)"></div>
1256        <!-- enlarged image overlay -->
1257        <div id="directory-image-lightbox" class="directory-image-lightbox" onclick="closeDirectoryProfileImage()">
1258            <div class="directory-image-lightbox-inner">
1259                <img class="directory-image-lightbox-img" src="" alt="Profile photo">
1260            </div>
1261        </div>
1262        <div class="paddingAllAround smallMarginBottom">
1263
1264            <p class="insert insert_name product-card-label"></p>
1265
1266            <div class="section-wrap tinyMarginTop tinyMarginBottom">
1267                <p class="flexGrowOne inline-value" data-db-key="street"></p>
1268                <p class="flexGrowOne inline-value" data-db-key="city"></p>
1269                <p class="flexGrowOne inline-value" data-db-key="state"></p>
1270                <p class="flexGrowOne inline-value" data-db-key="zip"></p>
1271            </div>
1272
1273            <div class="section-wrap tinyMarginTop tinyMarginBottom">
1274                <h4 class="smallBlockLetters leftText smallMarginTop">Contact</h4>
1275                <p class="flexGrowOne inline-value tinyPaddingTop" data-db-key="email"></p>
1276                <p class="flexGrowOne inline-value tinyPaddingTop" data-db-key="mobile"></p>
1277            </div>
1278
1279            <div class="section-wrap tinyMarginTop tinyMarginBottom">
1280                <h4 class="smallBlockLetters leftText smallMarginTop">Secondary address</h4>
1281                <p class="flexGrowOne inline-value dark tinyPaddingTop" data-db-key="street_secondary"></p>
1282                <p class="flexGrowOne inline-value dark" data-db-key="city_secondary"></p>
1283                <p class="flexGrowOne inline-value dark" data-db-key="state_secondary"></p>
1284                <p class="flexGrowOne inline-value dark" data-db-key="zip_secondary"></p>
1285            </div>
1286
1287            <div class="section-wrap section-wrap_custom_fields tinyMarginTop tinyMarginBottom">
1288                
1289            </div>
1290
1291            <!-- boat fields -->
1292            <div class="section-wrap tinyMarginTop tinyMarginBottom">
1293                <p class="flexGrowOne inline-value" data-db-key="boatName"></p>
1294                <p class="flexGrowOne inline-value" data-db-key="sailNumber" data-prepend="Sail #:"></p>
1295                <p class="flexGrowOne inline-value" data-db-key="hullNumber" data-prepend="Hull #:"></p>
1296                <p class="flexGrowOne inline-value" data-db-key="class" data-prepend="Class: "></p>
1297                <p class="flexGrowOne inline-value" data-db-key="registration" data-prepend="Registration #:"></p>
1298            </div>
1299
1300            <div class="section-wrap section-wrap_documents hidden tinyMarginTop tinyMarginBottom">
1301                <h4 class="smallBlockLetters leftText smallMarginTop">Documents</h4>
1302                <!-- documents inserted here -->
1303
1304            </div>
1305
1306            <div class="section-wrap section-wrap_family tinyMarginTop tinyMarginBottom">
1307                <h4 class="smallBlockLetters leftText smallMarginTop version_member">Family</h4>
1308                <h4 class="smallBlockLetters leftText smallMarginTop version_boat hidden">Related Members</h4>
1309                <!-- family members inserted here -->
1310
1311            </div>
1312
1313            <div class="section-wrap section-wrap_boats hidden tinyMarginTop tinyMarginBottom">
1314                <h4 class="smallBlockLetters leftText smallMarginTop version_member">Boats</h4>
1315                <h4 class="smallBlockLetters leftText smallMarginTop version_boat hidden">Related boats</h4>
1316                <!-- boats inserted here -->
1317
1318            </div>
1319
1320        </div>
1321    </div>
1322</div>        <div id="overlay_event-description-popup" class="standardOverlay scrollable hugePaddingBottom" style="display:none;opacity:0;">
1323    <div class="standardOverlayCard centeredBlock eightyThenNinety" style="max-width:850px;">
1324        <h3></h3>
1325        <div class="standardCardBody noPadding fff">
1326            <div class="centeredBlock eightyThenNinety bigPaddingBottom marginTop">
1327                <p class="inline-label smallerMarginBottom">Date</p>
1328                <p class="metric marginBottom leftText tinyMarginTop metric_start_date" style="margin-bottom:20px;"></p>
1329                <p class="inline-label smallerMarginBottom">Time</p>
1330                <p class="metric marginBottom leftText tinyMarginTop metric_start_date_time" style="margin-bottom:20px;"></p>
1331                <p class="inline-label smallerMarginBottom">Event Details</p>
1332                <div class="marginBottom leftText tinyMarginTop metric_description" style="line-height:1.5;"></div>
1333            </div>
1334        </div>
1335        <div class="standardCardFooter flexNoWrap">
1336            <div class="flexGrowOne"></div>
1337            <button class="cardFooterButton easeFast" onclick="fadeOut('#overlay_event-description-popup');">Done</button>
1338        </div>
1339    </div>
1340</div>
1341    </body>
1342    
1342<script>
1343
1344        //add the timezone to the global store
1345        store.timezone = $('body').data('timezone');
1346
1347        //add to global store
1348        store.eventBus = ref({
1349            listeners: {},
1350            $on(event, callback) {
1351                if (!this.listeners[event]) {
1352                this.listeners[event] = [];
1353                }
1354                this.listeners[event].push(callback);
1355            },
1356            $emit(event, ...args) {
1357                if (this.listeners[event]) {
1358                for (const callback of this.listeners[event]) {
1359                    callback(...args);
1360                }
1361                }
1362            },
1363            $off(event, callback) {
1364                if (!this.listeners[event]) {
1365                    return;
1366                }
1367                if (!callback) {
1368                    delete this.listeners[event];
1369                    return;
1370                }
1371                this.listeners[event] = this.listeners[event].filter(listener => listener !== callback);
1372            },
1373        });
1374
1375        //create the app
1376        var app_content = createApp({
1377            data() {
1378                return store
1379            },
1380            mounted() {
1381                console.log("mounted vue app: main content");
1382                $('#v-content').removeClass('hidden');
1383                var clubId = $('body').data('clubId');
1384                if(clubId) {
1385                    var query = new Parse.Query('clubs');
1386                    query.include('web_settings');
1387                    return query.get(clubId).then((foundClub) => {
1388                        store.clubObject = foundClub;
1389                    })
1390                }
1391            },
1392            methods: {
1393               
1394            },
1395            computed: {
1396                is_react_native_member_app(){
1397                    return store?.is_react_native_member_app || false;
1398                }
1399            }
1400        });
1401
1402        //create the app
1403        var app_overlays = createApp({
1404            data() {
1405                return store
1406            },
1407            mounted() {
1408                console.log("mounted vue app: overlays");
1409                $('#v-overlays').removeClass('hidden');
1410            }
1411        });
1412
1413    </script>
1413
1414
1415    <!-- define/register vue components  -->
1416    
1416<script type="text/html" id="template-custom-page-header">
1417    
1418
1419    <!-- globals -->
1420    
1421    
1422
1423    <!-- check the config -->
1424    
1425    
1426    
1427    
1428    
1429    
1430    
1431
1432    <!-- color scheme -->
1433    
1434    
1435    
1436    
1437
1438    <!-- opacity -->
1439    
1440    
1441
1442    <!-- font-size -->
1443    
1444    
1445    
1446
1447    <!-- font-weight -->
1448    
1449    
1450    
1451
1452    <!-- header style (eg stacked vs inline vs centered) -->
1453    
1454    
1455    
1456
1457    <!-- header position (eg relative vs absolute) -->
1458    
1459    
1460    
1461
1462    <!-- sub-menu style (eg simple vs animated slide-down) -->
1463    
1464
1465    
1466
1467    
1468    
1469
1470    <!-- header background image, eg. for inverness yc -->
1471    
1472
1473    <!-- burgee url -->
1474    
1475
1476    <!-- ok, start header markup -->
1477    <div
1478        ref="header"
1479        class="cs-header easeFast absolute-header color_scheme_dark cs-font-size-16 header-800 header-style_stacked header-position_absolute"
1480        style=""
1481        
1482        
1483    >
1484        
1485            <div class="cs-header-background background" style="opacity: 40%;"></div>
1486        
1487        <div class="innerHeader align-items-middle flexNoWrap">
1488            <div class="hamburgerWrap marketingMenu">
1489                <div style="position: fixed; height: 1px; width: 1px; opacity: .01;"></div>
1490                <div class="hamburger hideOnDesktop">
1491                    
1492                        <div id="headerLineTop" class="ease"></div>
1493                        <div id="headerLineMiddle" class="ease"></div>
1494                        <div id="headerLineBottom" class="ease"></div>
1495                    
1496                </div>
1497                
1498                    <p class="burger-menu-text hideOnDesktop hideOnMobile">Menu</p>
1499                
1500            </div>
1501
1502            
1503                
1504                
1505                
1506                    <div class="cs-burgee-wrap hideOnMobile" style="">
1507                        <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306);background-size:contain;"></div>
1508                        <a href="/" class="absoluteA"></a>
1509                    </div>
1510                
1511                <div class="cs-burgee-wrap-mobile">
1512                    <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306);background-size:contain;"></div>
1513                    <a :href="burgee_link" class="absoluteA"></a>
1514                </div>
1515            
1516
1517            
1518            <div class="cs-desktop-menu-wrap flexGrowOne flexWrapRightAlign hideOnMobile  header-style_stacked">
1519                
1520                
1521                    
1522                    
1523                    
1524                    
1525                    <div class="cs-header-link-wrap  header-style_stacked">
1526                                <p class="button_link_p" style="">Become a Member</p>
1527                                <!-- link -->
1528                                
1529                                
1530                                    <a class="absoluteA" href="/membership"></a>
1531                                
1532                                <!-- handle sub-components -->
1533                                
1534                                
1535                    </div>
1536                
1537                    
1538                    
1539                    
1540                    
1541                    <div class="cs-header-link-wrap  header-style_stacked">
1542                                <p class="button_link_p" style="">About Us</p>
1543                                <!-- link -->
1544                                
1545                                
1546                                    <a class="absoluteA" href="/about-us"></a>
1547                                
1548                                <!-- handle sub-components -->
1549                                
1550                                
1551                                    <div class="cs-header-sublink-outer easeFast">
1552                                        
1553                                            
1554                                            
1555                                            
1556                                                
1557                                                
1558                                                    <div class="cs-header-sublink-wrap relative" style="--sm-index:0;">
1559                                                        <p class="cs-header-sublink-label">Outreach</p>
1560                                                        
1561                                                        
1562                                                        
1563                                                            <a class="absoluteA" href="/myc-outreach"></a>
1564                                                        
1565                                                    </div>
1566                                                
1567                                            
1568                                        
1569                                    </div>
1570                                
1571                    </div>
1572                
1573                    
1574                    
1575                    
1576                    
1577                    <div class="cs-header-link-wrap  header-style_stacked">
1578                                <p class="button_link_p" style="">Dining</p>
1579                                <!-- link -->
1580                                
1581                                
1582                                    <a class="absoluteA" href="/myc-dining"></a>
1583                                
1584                                <!-- handle sub-components -->
1585                                
1586                                
1587                    </div>
1588                
1589                    
1590                    
1591                    
1592                    
1593                    <div class="cs-header-link-wrap  header-style_stacked">
1594                                <p class="button_link_p" style="">Racing</p>
1595                                <!-- link -->
1596                                
1597                                
1598                                    <a class="absoluteA" href="/racing"></a>
1599                                
1600                                <!-- handle sub-components -->
1601                                
1602                                
1603                                    <div class="cs-header-sublink-outer easeFast">
1604                                        
1605                                            
1606                                            
1607                                            
1608                                                
1609                                                
1610                                                    <div class="cs-header-sublink-wrap relative" style="--sm-index:0;">
1611                                                        <p class="cs-header-sublink-label">On the Water</p>
1612                                                        
1613                                                        
1614                                                        
1615                                                            <a class="absoluteA" href="/on-the-water"></a>
1616                                                        
1617                                                    </div>
1618                                                
1619                                            
1620                                        
1621                                    </div>
1622                                
1623                    </div>
1624                
1625                    
1626                    
1627                    
1628                    
1629                    <div class="cs-header-link-wrap  header-style_stacked">
1630                                <p class="button_link_p" style="">Learn to Sail</p>
1631                                <!-- link -->
1632                                
1633                                
1634                                <!-- handle sub-components -->
1635                                
1636                                
1637                                    <div class="cs-header-sublink-outer easeFast">
1638                                        
1639                                            
1640                                            
1641                                            
1642                                                
1643                                                
1644                                            
1645                                        
1646                                            
1647                                            
1648                                            
1649                                                
1650                                                
1651                                                    <div class="cs-header-sublink-wrap relative" style="--sm-index:1;">
1652                                                        <p class="cs-header-sublink-label">MYC Youth Foundation</p>
1653                                                        
1654                                                        
1655                                                        
1656                                                            <a class="absoluteA" href="https://www.mycyouthsailing.org/"></a>
1657                                                        
1658                                                    </div>
1659                                                
1660                                            
1661                                        
1662                                    </div>
1663                                
1664                    </div>
1665                
1666                    
1667                    
1668                    
1669                    
1670                    <div class="cs-header-link-wrap  link-style-button header-style_stacked">
1671                                <p class="button_link_p" style="background-color:#000780 !important; color:#fa885c !important;">Member Login</p>
1672                                <!-- link -->
1673                                
1674                                
1675                                    <a class="absoluteA" href="/account"></a>
1676                                
1677                                <!-- handle sub-components -->
1678                                
1679                                
1680                    </div>
1681                
1682                
1683                
1684            </div>
1685        </div>
1686    </div>
1687
1688    <!-- now the mobile menu -->
1689    <div class="mobileMenuOverlay hideOnDesktop ease"></div>
1690    <div class="mobileMenu hideOnDesktop easeFast scrollable">
1691        <div class="innerMenu">
1692            <div class="mobile-link-wrap">
1693
1694
1695                
1696                    <div class="cs-burgee-wrap" style="width:175px;height:60px;float:none;margin-top:-40px;margin-bottom:15px;">
1697                        <div class="cs-burgee" style="background-position:left;background-image:url(https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306);background-size:contain;margin-bottom:15px;"></div>
1698                        <a :href="burgee_link" class="absoluteA"></a>
1699                    </div>
1700                
1701
1702
1703                <div class="mobile-sublink-outer easeFast">
1704
1705                    
1706                        <div class="header-sublink-wrap relative">
1707                            <p class="header-sublink-label">My Account</p>
1708                            <p class="header-sublink-explain">Click below to view your account</p>
1709                            <button class="wb-button-primary easeFast boxShadowBottom smallMarginTop">
1710                                View my account
1711                                <a class="absoluteA" href="/account"></a>
1712                            </button>
1713                        </div>
1714                    
1715                    
1716                        
1717                        
1718                        
1719                            
1720                            
1721                                <!-- component -->
1722                                
1723                                
1724                                    <p class="header-sublink-section-header smallMarginTop">
1725                                        Become a Member
1726                                        <a class="absoluteA" href="/membership"></a>
1727                                    </p>
1728                                
1729                                <!-- handle sub-components -->
1730                                
1731                                
1732                            
1733                        
1734                    
1735                        
1736                        
1737                        
1738                            
1739                            
1740                                <!-- component -->
1741                                
1742                                
1743                                    <p class="header-sublink-section-header smallMarginTop">
1744                                        About Us
1745                                        <a class="absoluteA" href="/about-us"></a>
1746                                    </p>
1747                                
1748                                <!-- handle sub-components -->
1749                                
1750                                
1751                                    
1752                                        
1753                                        
1754                                        
1755                                            
1756                                            
1757                                                <div class="cs-header-sublink-wrap relative">
1758                                                    
1759                                                    
1760                                                        <p class="cs-header-sublink-label">
1761                                                            Outreach
1762                                                            <a class="absoluteA" href="/myc-outreach"></a>
1763                                                        </p>
1764                                                    
1765                                                </div>
1766                                            
1767                                        
1768                                    
1769                                
1770                            
1771                        
1772                    
1773                        
1774                        
1775                        
1776                            
1777                            
1778                                <!-- component -->
1779                                
1780                                
1781                                    <p class="header-sublink-section-header smallMarginTop">
1782                                        Dining
1783                                        <a class="absoluteA" href="/myc-dining"></a>
1784                                    </p>
1785                                
1786                                <!-- handle sub-components -->
1787                                
1788                                
1789                            
1790                        
1791                    
1792                        
1793                        
1794                        
1795                            
1796                            
1797                                <!-- component -->
1798                                
1799                                
1800                                    <p class="header-sublink-section-header smallMarginTop">
1801                                        Racing
1802                                        <a class="absoluteA" href="/racing"></a>
1803                                    </p>
1804                                
1805                                <!-- handle sub-components -->
1806                                
1807                                
1808                                    
1809                                        
1810                                        
1811                                        
1812                                            
1813                                            
1814                                                <div class="cs-header-sublink-wrap relative">
1815                                                    
1816                                                    
1817                                                        <p class="cs-header-sublink-label">
1818                                               
1818             On the Water
1819                                                            <a class="absoluteA" href="/on-the-water"></a>
1820                                                        </p>
1821                                                    
1822                                                </div>
1823                                            
1824                                        
1825                                    
1826                                
1827                            
1828                        
1829                    
1830                        
1831                        
1832                        
1833                            
1834                            
1835                                <!-- component -->
1836                                
1837                                
1838                                    <p class="header-sublink-section-header smallMarginTop">Learn to Sail</p>
1839                                
1840                                <!-- handle sub-components -->
1841                                
1842                                
1843                                    
1844                                        
1845                                        
1846                                        
1847                                            
1848                                            
1849                                        
1850                                    
1851                                        
1852                                        
1853                                        
1854                                            
1855                                            
1856                                                <div class="cs-header-sublink-wrap relative">
1857                                                    
1858                                                    
1859                                                        <p class="cs-header-sublink-label">
1860                                                            MYC Youth Foundation
1861                                                            <a class="absoluteA" href="https://www.mycyouthsailing.org/"></a>
1862                                                        </p>
1863                                                    
1864                                                </div>
1865                                            
1866                                        
1867                                    
1868                                
1869                            
1870                        
1871                    
1872                        
1873                        
1874                        
1875                            
1876                            
1877                        
1878                    
1879                        
1880                        
1881                        
1882                            
1883                            
1884                                <!-- component -->
1885                                
1886                                
1887                                    <p class="header-sublink-section-header smallMarginTop">
1888                                        Member Login
1889                                        <a class="absoluteA" href="/account"></a>
1890                                    </p>
1891                                
1892                                <!-- handle sub-components -->
1893                                
1894                                
1895                            
1896                        
1897                    
1898                </div>
1899            </div>
1900        </div>
1901    </div>
1902    
1903</script>
1903
1904
1905
1906
1907<script>
1908
1909    //define the component
1910    app_content.component("custom-page-header", {
1911        template: "#template-custom-page-header",
1912        data() {
1913            return {}
1914        }, 
1915        props: {},
1916        mounted(){
1917            this.sync_submenu_edge_alignment();
1918            window.addEventListener("resize", this.schedule_submenu_edge_alignment, { passive: true });
1919            if( !this.$refs.header ){
1920                return;
1921            }
1922            // Belt-and-suspenders: re-check right before a panel opens so a
1923            // stale measurement (from before nav content settled) can't leave
1924            // it hanging off-screen.
1925            this.$refs.header.addEventListener("mouseover", this.handle_submenu_hover_check);
1926            this.$refs.header.addEventListener("focusin", this.handle_submenu_hover_check);
1927            let sticky_header = this.$refs.header.classList.contains("cs-header-behavior_always_visible");
1928            if( !sticky_header ){
1929                return;
1930            }
1931            window.addEventListener("scroll", this.handle_header_scroll, { passive: true });
1932            this.$nextTick(() => {
1933                window.requestAnimationFrame(() => {
1934                    this.handle_header_scroll();
1935                });
1936            });
1937        },
1938        beforeUnmount(){
1939            window.removeEventListener("scroll", this.handle_header_scroll);
1940            window.removeEventListener("resize", this.schedule_submenu_edge_alignment);
1941            window.clearTimeout(this._submenu_edge_alignment_timeout);
1942            if( this.$refs.header ){
1943                this.$refs.header.removeEventListener("mouseover", this.handle_submenu_hover_check);
1944                this.$refs.header.removeEventListener("focusin", this.handle_submenu_hover_check);
1945            }
1946        },
1947        methods: {
1948            // Left-aligned animated sub-menus can run past the right edge of the
1949            // viewport for nav items near the end of the menu - flip those to
1950            // hang from the right instead so they stay on screen.
1951            schedule_submenu_edge_alignment(){
1952                window.clearTimeout(this._submenu_edge_alignment_timeout);
1953                this._submenu_edge_alignment_timeout = window.setTimeout(() => {
1954                    this.sync_submenu_edge_alignment();
1955                }, 150);
1956            },
1957            handle_submenu_hover_check(event){
1958                if( event.target.closest && event.target.closest(".cs-header-link-wrap") ){
1959                    this.sync_submenu_edge_alignment();
1960                }
1961            },
1962            sync_submenu_edge_alignment(){
1963                if( !this.$refs.header ){
1964                    return;
1965                }
1966                let is_animated = this.$refs.header.classList.contains("cs-header-submenu-style_animated_buttons")
1967                    || this.$refs.header.classList.contains("cs-header-submenu-style_animated_flat");
1968                if( !is_animated ){
1969                    return;
1970                }
1971                let panels = this.$refs.header.querySelectorAll(".cs-header-sublink-outer");
1972                let viewport_width = window.innerWidth || document.documentElement.clientWidth || 0;
1973                panels.forEach((panel) => {
1974                    panel.classList.remove("cs-header-sublink-outer_align-right");
1975                    if( panel.getBoundingClientRect().right > viewport_width - 8 ){
1976                        panel.classList.add("cs-header-sublink-outer_align-right");
1977                    }
1978                });
1979            },
1980            handle_header_scroll(){
1981                if( !this.$refs.header ){
1982                    return;
1983                }
1984                let scroll_y = window.scrollY || window.pageYOffset || 0;
1985                let header_rect = this.$refs.header.getBoundingClientRect();
1986                let first_component = document.querySelector("#v-content .cs-first-page-section")
1987                    || document.querySelector("#v-content .cs-custom-code-wrap.first-page-section");
1988                let first_component_rect = first_component
1989                    ? first_component.getBoundingClientRect()
1990                    : null;
1991                let viewport_height = window.innerHeight || document.documentElement.clientHeight || 0;
1992                let transition_mode = this.$refs.header.getAttribute("data-header-transition-mode")
1993                    || 'phased';
1994                let was_past_handoff = !!this._header_past_handoff;
1995                let past_full_handoff = !!(
1996                    first_component_rect
1997                    && first_component_rect.height > 0
1998                    && first_component_rect.bottom <= (
1999                        header_rect.bottom + ( was_past_handoff ? 8 : -8 )
2000                    )
2001                ) || (
2002                    viewport_height > 0
2003                    && scroll_y >= viewport_height + ( was_past_handoff ? -8 : 8 )
2004                );
2005                let immediate_threshold = Math.max(1, Math.round(header_rect.height / 2));
2006                let header_past_handoff = transition_mode === 'immediate'
2007                    ? scroll_y >= immediate_threshold
2008                    : scroll_y > 1 && past_full_handoff;
2009                this._header_past_handoff = header_past_handoff;
2010                let early_offset = Math.max(8, Math.round(header_rect.height * 0.2));
2011                let early_default_gradient = scroll_y >= early_offset
2012                    && !header_past_handoff
2013                    && transition_mode === 'phased'
2014                    && this.$refs.header.classList.contains("cs-header-background-on-scroll");
2015                this.apply_header_background_for_scroll(header_past_handoff);
2016                if( early_default_gradient ){
2017                    this.sync_header_background_rgb();
2018                }
2019                this.$refs.header.classList.remove("cs-header-following-section-gradient");
2020                this.$refs.header.classList.toggle(
2021                    "cs-header-early-default-gradient",
2022                    early_default_gradient
2023                );
2024            },
2025            apply_header_background_for_scroll(is_scrolled){
2026                if( !this.$refs.header ){
2027                    return;
2028                }
2029                let top_raw = this.$refs.header.getAttribute("data-header-bg-top");
2030                let scrolled_raw = this.$refs.header.getAttribute("data-header-bg-scrolled");
2031                if( !top_raw ){
2032                    return;
2033                }
2034                let top = {};
2035                let scrolled = {};
2036                try {
2037                    top = JSON.parse(top_raw) || {};
2038                    scrolled = JSON.parse(scrolled_raw || top_raw) || top;
2039                } catch (error) {
2040                    return;
2041                }
2042                let active = is_scrolled ? scrolled : top;
2043                let previous_text_color = this.$refs.header.className.match(/color_scheme_[^\s]+/);
2044                if( previous_text_color ){
2045                    this.$refs.header.classList.remove(previous_text_color[0]);
2046                }
2047                this.$refs.header.classList.add(
2048                    "color_scheme_" + ( active.color || 'white' )
2049                );
2050                let previous_mode = this.$refs.header.className.match(/cs-header-background-mode-[^\s]+/);
2051                if( previous_mode ){
2052                    this.$refs.header.classList.remove(previous_mode[0]);
2053                }
2054                let previous_blur = this.$refs.header.className.match(/cs-header-blur-(light|medium|strong)/);
2055                if( previous_blur ){
2056                    this.$refs.header.classList.remove(previous_blur[0]);
2057                }
2058                let previous_color = this.$refs.header.className.match(/cs-header-background-color-[^\s]+/);
2059                if( previous_color ){
2060                    this.$refs.header.classList.remove(previous_color[0]);
2061                }
2062                this.$refs.header.classList.add("cs-header-background-mode-" + ( active.mode || 'none' ));
2063                this.$refs.header.classList.add(
2064                    "cs-header-background-color-" + ( active.color || 'white' )
2065                );
2066                if( active.mode === 'blur_tint' ){
2067                    this.$refs.header.classList.add("cs-header-blur-" + ( active.blur_amount || 'medium' ));
2068                }
2069                if( active.opacity !== undefined ){
2070                    this.$refs.header.style.setProperty(
2071                        '--cs-header-background-opacity',
2072                        String(active.opacity / 100)
2073                    );
2074                }
2075                this.$refs.header.classList.toggle(
2076                    "cs-header-top-visible",
2077                    !is_scrolled && active.mode !== 'none'
2078                );
2079                this.$refs.header.classList.toggle(
2080                    "cs-header-blur-active",
2081                    is_scrolled && active.mode !== 'none'
2082                );
2083                let header_visible = !( is_scrolled && active.mode === 'none' );
2084                this.$refs.header.style.opacity = header_visible ? '' : '0';
2085                this.$refs.header.style.visibility = header_visible ? '' : 'hidden';
2086                this.$refs.header.style.pointerEvents = header_visible ? '' : 'none';
2087            },
2088            // Sample the real sticky fill (incl. club custom CSS) so the interim
2089            // gradient never falls back to overlay navy / a mismatched default.
2090            sync_header_background_rgb(){
2091                if( !this.$refs.header ){
2092                    return;
2093                }
2094                let scrolled_raw = this.$refs.header.getAttribute("data-header-bg-scrolled");
2095                if( scrolled_raw ){
2096                    try {
2097                        let scrolled = JSON.parse(scrolled_raw) || {};
2098                        let previous_color = this.$refs.header.className.match(/cs-header-background-color-[^\s]+/);
2099                        if( previous_color ){
2100                            this.$refs.header.classList.remove(previous_color[0]);
2101                        }
2102                        this.$refs.header.classList.add(
2103                            "cs-header-background-color-" + ( scrolled.color || 'white' )
2104                        );
2105                        if( scrolled.opacity !== undefined ){
2106                            this.$refs.header.style.setProperty(
2107                                '--cs-header-background-opacity',
2108                                String(scrolled.opacity / 100)
2109                            );
2110                        }
2111                    } catch (error) {
2112                        return;
2113                    }
2114                }
2115                let bg = this.$refs.header.querySelector('.cs-header-background');
2116                if( !bg ){
2117                    return;
2118                }
2119                let color = window.getComputedStyle(bg).backgroundColor || '';
2120                let match = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?/.exec(color);
2121                if( !match ){
2122                    return;
2123                }
2124                let alpha = match[4] === undefined ? 1 : Number(match[4]);
2125                if( !( alpha > 0 ) ){
2126                    return;
2127                }
2128                this.$refs.header.style.setProperty(
2129                    '--cs-header-background-rgb',
2130                    match[1] + ', ' + match[2] + ', ' + match[3]
2131                );
2132            },
2133            sync_section_gradient_height(section_height_px){
2134                if( !this.$refs.section_gradient || !this.$refs.header ){
2135                    return;
2136                }
2137                let transfer_raw = this.$refs.header.getAttribute("data-header-top-fade-transfer");
2138                if( !transfer_raw ){
2139                    return;
2140                }
2141                let transfer = null;
2142                try {
2143                    transfer = JSON.parse(transfer_raw);
2144                } catch (error) {
2145                    return;
2146                }
2147                if( !transfer || transfer.kind === 'legacy_fade' ){
2148                    return;
2149                }
2150                if( !section_height_px || section_height_px <= 0 ){
2151                    return;
2152                }
2153                this.$refs.section_gradient.style.height =
2154                    ( section_height_px * ( transfer.height_percent / 100 ) ) + "px";
2155            },
2156        },
2157        computed: {
2158            burgee_link(){
2159                //starting june 18 2026 we don't show this custom page header component in the member app at all..
2160                /*
2161                if( this.is_react_native_member_app ){
2162                    return "/account"
2163                }
2164                */
2165                return "/"
2166            },
2167            current_user(){
2168                return Parse.User.current()
2169            },
2170        },
2171    });
2172
2173
2174</script>
2174    
2174<script type="text/html" id="template-image-grid">
2175    <template v-if="!image_grid_layout || image_grid_layout !== 'carousel'">
2176        <div 
2177            class="image-grid-wrap centeredBlock"
2178            :class="'image-grid-wrap_' + client_id + ' images_per_row_' + images_per_row + ' ' + class_string"
2179        >
2180            <!-- draggable start for image-grid-image -->
2181            <div 
2182                v-if="!show_first_row_skeleton && images?.length > 0" 
2183                class="image-grid-insert flexWrapLeftAlign" 
2184                :class=" is_admin ? 'draggable-container_image_grid_' + component.id : ''  "
2185            >
2186                <template v-for="(image, index) in images" :key="image?.id">
2187                    <div 
2188                        v-if="image && image.url" 
2189                        class="image-grid-image relative" 
2190                        :class="'image-grid-image_' + image?.id"
2191                        :id="image?.id"
2192                    >
2193                        <!-- video -->
2194                        <div 
2195                            v-if="image.is_video" 
2196                            class="image-grid-image-inner" 
2197                            :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }"
2198                            style="align-items:center;display:flex;" 
2199                            :style="{'border-radius': image_grid_border_radius + 'px' }"
2200                        >
2201                            <video class="cs-background-video" autoplay playsinline loop muted :src="image.url" style="display:block;"></video>
2202                            <div v-if="is_admin" class="image-grid-drag-handle-wrap draggable-handle">
2203                                <i class="fa-regular fa-grip-dots image-grid-drag-handle"></i>
2204                            </div>
2205                        </div>
2206                        <!-- image -->
2207                        <div 
2208                            v-else
2209                            class="image-grid-image-inner" 
2210                            :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }"
2211                            :style="{'background-image': 'url(' + image.url + ')', 'border-radius': image_grid_border_radius + 'px' }"
2212                        >
2213                            <div v-if="is_admin" class="image-grid-drag-handle-wrap draggable-handle">
2214                                <i class="fa-regular fa-grip-dots image-grid-drag-handle"></i>
2215                            </div>
2216                        </div>
2217                        <!-- overlay -->
2218                        <div v-if="is_admin" class="image-grid-image-overlay flexWrap easeFast" :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }" @click.stop="change_image(image)">
2219                            <div class="image-toolbar-wrapper flexNoWrap centeredBlock" @click.stop>
2220                                <div 
2221                                    class="image-toolbar-option easeFast" 
2222                                    data-option="change" 
2223                                    style="border-right:1px solid #fff;" 
2224                                    @click.stop="change_image(image)"
2225                                >Change</div>
2226                                <div 
2227                                    class="image-toolbar-option easeFast" 
2228                                    data-option="update" 
2229                                    style="border-right:1px solid #fff;" 
2230                                    @click.stop="prepThenDisplayImageSettings(image)"
2231                                >Update</div>
2232                                <div 
2233                                    class="image-toolbar-option easeFast" 
2234                                    data-option="remove" 
2235                                    @click.stop="tapped_remove_image(image)"
2236                                ><i class="fa-regular fa-trash" style="font-size:14px;"></i></div>
2237                            </div>
2238                            <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }">
2239                                <h2 
2240                                    v-if="image.caption"
2241                                    class="image-grid-image-caption"
2242                                    :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }"
2243                                >{{ image.caption }}</h2>
2244                                <p
2245                                    v-if="image.subtext"
2246                                    class="image-grid-image-caption marginTopFour"
2247                                    :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }"
2248                                >{{ image.subtext }}</p>
2249                                <p
2250                                    v-if="image.link_label"
2251                                    class="image-grid-image-caption marginTopFour"
2252                                    style="text-decoration: underline;"
2253                                    :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }"
2254                                >{{ image.link_label }}</p>
2255                            </div>
2256                        </div> 
2257                        <div v-if="!is_admin" class="image-grid-image-overlay flexWrap easeFast" :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }">
2258                            <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }">
2259                                <h2 
2260                                    v-if="image.caption"
2261                                    class="image-grid-image-caption"
2262                                    :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }"
2263                                >{{ image.caption }}</h2>
2264                                <p
2265                                    v-if="image.subtext"
2266                                    class="image-grid-image-caption marginTopFour"
2267                                    :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }"
2268                                >{{ image.subtext }}</p>
2269                                <p
2270                                    v-if="image.link_label"
2271                                    class="image-grid-image-caption marginTopFour"
2272                                    style="text-decoration: underline;"
2273                                    :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }"
2274                                >{{ image.link_label }}</p>
2275                                <a
2276                                    v-if="return_link_href(image.link_to)"
2277                                    class="absoluteA"
2278                                    :href="return_link_href(image.link_to)"
2279                                    rel="noopener noreferrer"
2280                                ></a>
2281                            </div>
2282                        </div> 
2283                    </div>
2284                    <div
2285                        v-else-if="is_admin && image && image.placeholder"
2286                        class="image-grid-image relative cs-image-slot-placeholder-host"
2287                        :class="'image-grid-image_' + image?.id"
2288                        :id="image?.id"
2289                        @click.stop="fill_image_placeholder(image)"
2290                    >
2291                        <div class="image-grid-image-inner cs-image-slot-placeholder" :style="{ 'border-radius': image_grid_border_radius + 'px' }">
2292                            <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span>
2293                            <span class="cs-image-slot-placeholder-label">Add {{ image.label || 'image' }}</span>
2294                        </div>
2295                    </div>
2296                </template>
2297                <!-- upload image **for admin -->
2298                <template v-if="is_admin">
2299                    <div class="image-grid-image image-grid-image_upload non-draggable">
2300                        <div class="image-grid-image-inner">
2301                            <div class="uploadImageZone relative pointer">
2302                                <input 
2303                                    @change="imageUploadChange($event)"
2304                                    class="image-grid-upload-input absoluteInput" 
2305                                    type="file" accept="image/*,video/*"
2306                                >
2307                                <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p>
2308                                <div class="uploadImageOverlay">
2309                                    <div class="uploadImageSpinner centeredBlock"></div>
2310                                </div>
2311                            </div>
2312                        </div>
2313                    </div>
2314                </template>
2315            </div>
2316            <!-- draggable end for image-grid-image -->
2317            <div v-if="!show_first_row_skeleton && images?.length === 0 && is_admin" class="image-grid-insert flexWrapLeftAlign">
2318                <div class="image-grid-image image-grid-image_upload">
2319                    <div class="image-grid-image-inner">
2320                        <div class="uploadImageZone relative pointer">
2321                            <input 
2322                                @change="imageUploadChange($event)"
2323                                class="image-grid-upload-input absoluteInput" 
2324                                type="file" accept="image/*,video/*"
2325                            >
2326                            <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p>
2327                            <div class="uploadImageOverlay">
2328                                <div class="uploadImageSpinner centeredBlock"></div>
2329                            </div>
2330                        </div>
2331                    </div>
2332                </div>
2333            </div>
2334            <div v-if="show_first_row_skeleton" class="image-grid-insert flexWrapLeftAlign">
2335                <div
2336                    v-for="n in skeleton_tile_count"
2337                    :key="'image-grid-skeleton-' + n"
2338                    class="image-grid-image relative"
2339                >
2340                    <div
2341                        class="image-grid-image-inner image-grid-skeleton-tile"
2342                        :style="{ 'border-radius': image_grid_border_radius + 'px' }"
2343                    ></div>
2344                </div>
2345            </div>
2346        </div>
2347    </template>
2348    <template v-else>
2349
2350        <div style="box-sizing: border-box; padding: 20px;" class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id">
2351            <!-- <div 
2352                class="flexNoWrap align-items-center event_list_carousel_default"
2353                style="padding: 0px 0px 15px 5px;"
2354            >
2355                <p>PLACEHOLDER</p>
2356            </div> -->
2357            <div 
2358                class="image-grid-wrap centeredBlock"
2359                :class="'image-grid-wrap_' + client_id + class_string"
2360                style="padding: 20px;"
2361            >
2362                <div 
2363                    class="tableInsert event-list-card-wrapper"
2364                    style="overflow: scroll !important; scrollbar-width: 5px !important; flex-wrap: nowrap;"
2365                    :style="{ 'gap': formatting_obj.card_spacing ? Number(formatting_obj.card_spacing) + 'px' : '25px' }"
2366                >
2367                    <template v-if="show_first_row_skeleton">
2368                        <div
2369                            v-for="n in skeleton_tile_count"
2370                            :key="'image-grid-carousel-skeleton-' + n"
2371                            class="event-list-card noPadding noMargin"
2372                            :class="{
2373                                'small-card-image-grid': formatting_obj.card_size == 'small',
2374                                'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium',
2375                                'large-card-image-grid': formatting_obj.card_size == 'large',
2376                            }"
2377                            :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }"
2378                        >
2379                            <div class="image-grid-image relative">
2380                                <div
2381                                    class="image-grid-event-image image-grid-skeleton-tile"
2382                                    :style="{'border-radius': image_grid_border_radius + 'px' }"
2383                                ></div>
2384                            </div>
2385                            <div
2386                                v-if="formatting_obj.caption_align === 'under'"
2387                                class="image-grid-card-content background"
2388                                :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }"
2389                            >
2390                                <div class="image-grid-skeleton-caption image-grid-skeleton-tile"></div>
2391                            </div>
2392                        </div>
2393                    </template>
2394                    <template
2395                        v-else-if="images && images.length > 0"
2396                        v-for="(image, index) in images"
2397                        :key="image?.id"
2398                    >
2399                        <div
2400                            v-if="image && image.url" 
2401                            class="event-list-card noPadding noMargin" 
2402                            :class="{
2403                                'small-card-image-grid': formatting_obj.card_size == 'small',
2404                                'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium',
2405                                'large-card-image-grid': formatting_obj.card_size == 'large',
2406                                'box-shadow-light': formatting_obj.box_shadow == 'light',
2407                                'box-shadow-dark': formatting_obj.box_shadow == 'dar
2407k',
2408                                'pointer': !is_admin,
2409                            }"
2410                            :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }"
2411                            @click.stop="clicked_carousel_image(image, $event)"
2412                        >
2413                            <div 
2414                                v-if="image && image.url" 
2415                                class="image-grid-image relative" 
2416                                :class="'image-grid-image_' + image?.id"
2417                                :id="image?.id"
2418                            >
2419                                <!-- video -->
2420                                <div 
2421                                    v-if="image.is_video" 
2422                                    class="image-grid-event-image"
2423                                    :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }"
2424                                    style="align-items:center;display:flex;" 
2425                                    :style="{'border-radius': image_grid_border_radius + 'px' }"
2426                                >
2427                                    <video class="cs-background-video" autoplay playsinline loop muted :src="image.url" style="display:block;"></video>
2428                                </div>
2429                                <!-- image -->
2430                                <div 
2431                                    v-else
2432                                    class="image-grid-event-image"
2433                                    :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }"
2434                                    :style="{'background-image': 'url(' + image.url + ')', 'border-radius': image_grid_border_radius + 'px' }"
2435                                ></div>
2436                                <!-- overlay -->
2437                                <div 
2438                                    @click.stop="change_image(image)"
2439                                    v-if="is_admin" 
2440                                    class="image-grid-image-overlay flexWrap easeFast" 
2441                                    style="left:0px;right:0px;bottom:0px;top:0px"
2442                                    :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }"
2443                                >
2444                                    <div class="image-toolbar-wrapper flexNoWrap centeredBlock" @click.stop>
2445                                        <div 
2446                                            class="image-toolbar-option easeFast" 
2447                                            data-option="change" 
2448                                            style="border-right:1px solid #fff;" 
2449                                            @click.stop="change_image(image)"
2450                                        >Change</div>
2451                                        <div 
2452                                            class="image-toolbar-option easeFast" 
2453                                            data-option="update" 
2454                                            style="border-right:1px solid #fff;" 
2455                                            @click.stop="prepThenDisplayImageSettings(image)"
2456                                        >Update</div>
2457                                        <div 
2458                                            class="image-toolbar-option easeFast" 
2459                                            data-option="remove" 
2460                                            @click.stop="tapped_remove_image(image)"
2461                                        ><i class="fa-regular fa-trash" style="font-size:14px;"></i></div>
2462                                    </div>
2463                                    <div v-if="formatting_obj.caption_align !== 'under'" class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }">
2464                                        <h2 
2465                                            v-if="image.caption"
2466                                            class="image-grid-image-caption"
2467                                            :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }"
2468                                        >{{ image.caption }}</h2>
2469                                        <p
2470                                            v-if="image.subtext"
2471                                            class="image-grid-image-caption marginTopFour"
2472                                            :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }"
2473                                        >{{ image.subtext }}</p>
2474                                        <p
2475                                            v-if="image.link_label"
2476                                            class="image-grid-image-caption marginTopFour"
2477                                            style="text-decoration: underline;"
2478                                            :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }"
2479                                        >{{ image.link_label }}</p>
2480                                    </div>
2481                                </div> 
2482                                <div v-if="!is_admin" class="image-grid-image-overlay image-grid-carousel-live-overlay flexWrap easeFast pointer" style="left:0px;right:0px;bottom:0px;top:0px" :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }" @click.stop="clicked_carousel_image(image, $event)">
2483                                    <div v-if="formatting_obj.caption_align !== 'under'" class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }">
2484                                        <h2 
2485                                            v-if="image.caption"
2486                                            class="image-grid-image-caption"
2487                                            :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }"
2488                                        >{{ image.caption }}</h2>
2489                                        <p
2490                                            v-if="image.subtext"
2491                                            class="image-grid-image-caption marginTopFour"
2492                                            :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }"
2493                                        >{{ image.subtext }}</p>
2494                                        <p
2495                                            v-if="image.link_label"
2496                                            class="image-grid-image-caption marginTopFour"
2497                                            style="text-decoration: underline;"
2498                                            :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }"
2499                                        >{{ image.link_label }}</p>
2500                                    </div>
2501                                    <a
2502                                        v-if="return_link_href(image.link_to)"
2503                                        class="absoluteA"
2504                                        :href="return_link_href(image.link_to)"
2505                                        rel="noopener noreferrer"
2506                                    ></a>
2507                                </div> 
2508                            </div>
2509                            <div 
2510                                v-if="formatting_obj.caption_align === 'under'"
2511                                class="image-grid-card-content background"
2512                                :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }"
2513                            >
2514                                <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }">
2515                                    <h2 
2516                                        v-if="image.caption"
2517                                        class="image-grid-image-caption visible"
2518                                        :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }"
2519                                    >{{ image.caption }}</h2>
2520                                    <p
2521                                        v-if="image.subtext"
2522                                        class="image-grid-image-caption visible marginTopFour"
2523                                        :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }"
2524                                    >{{ image.subtext }}</p>
2525                                    <p
2526                                        v-if="image.link_label"
2527                                        class="image-grid-image-caption visible marginTopFour"
2528                                        style="text-decoration: underline;"
2529                                        :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }"
2530                                    >
2531                                        <a
2532                                            v-if="return_link_href(image.link_to)"
2533                                            :href="return_link_href(image.link_to)"
2534                                            rel="noopener noreferrer"
2535                                        >{{ image.link_label }}</a>
2536                                        <template v-else>{{ image.link_label }}</template>
2537                                    </p>
2538                                </div>
2539                            </div>
2540                        </div>
2541                        <div
2542                            v-else-if="is_admin && image && image.placeholder"
2543                            class="event-list-card noPadding noMargin cs-image-slot-placeholder-host pointer"
2544                            :class="{
2545                                'small-card-image-grid': formatting_obj.card_size == 'small',
2546                                'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium',
2547                                'large-card-image-grid': formatting_obj.card_size == 'large',
2548                            }"
2549                            :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }"
2550                            @click.stop="fill_image_placeholder(image)"
2551                        >
2552                            <div class="image-grid-image relative">
2553                                <div class="image-grid-event-image cs-image-slot-placeholder">
2554                                    <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span>
2555                                    <span class="cs-image-slot-placeholder-label">Add {{ image.label || 'image' }}</span>
2556                                </div>
2557                            </div>
2558                        </div>
2559                    </template>
2560                    <!-- upload image **for admin -->
2561                    <template v-if="is_admin">
2562                        <div 
2563                            class="event-list-card pointer" 
2564                            :class="{
2565                                'small-card-image-grid': formatting_obj.card_size == 'small',
2566                                'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium',
2567                                'large-card-image-grid': formatting_obj.card_size == 'large',
2568                            }"
2569                        >
2570                            <div class="image-grid-image relative upload-image-grid-image-zone">
2571                                <div class="image-grid-event-image"></div>
2572                                <input 
2573                                    @change="imageUploadChange($event)"
2574                                    class="image-grid-upload-input absoluteInput" 
2575                                    type="file" accept="image/*,video/*"
2576                                >
2577                                <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p>
2578                                <!-- <div class="uploadImageOverlay">
2579                                    <div class="uploadImageSpinner centeredBlock"></div>
2580                                </div> -->
2581                            </div>
2582                            <div 
2583                                v-if="formatting_obj.caption_align === 'under'"
2584                                class="image-grid-card-content"
2585                                :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }"
2586                            >
2587                                <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }">
2588                                    <!-- PLACEHOLDER -->
2589                                </div>
2590                            </div>
2591                        </div>
2592                    </template>
2593                </div>
2594            </div>
2595        </div>
2596    </template>
2597</script>
2597
2598
2599<style>
2600    .cs-image-slot-placeholder-host {
2601        cursor: pointer;
2602    }
2603    .cs-image-slot-placeholder {
2604        display: flex;
2605        flex-direction: column;
2606        align-items: center;
2607        justify-content: center;
2608        gap: 8px;
2609        width: 100%;
2610        height: 100%;
2611        min-height: 140px;
2612        background: #f1f4f8;
2613        border: 1.5px dashed #b7c1d1;
2614        color: #5f6f8d;
2615        text-align: center;
2616        box-sizing: border-box;
2617    }
2618    .cs-image-slot-placeholder-icon {
2619        font-size: 22px;
2620    }
2621    .cs-image-slot-placeholder-label {
2622        font-family: roboto !important;
2623        font-size: 14px;
2624        font-weight: 600;
2625    }
2626    .image-grid-image.dragging{
2627        overflow: hidden;
2628        border-radius: 10px;
2629    }
2630    .image-grid-image.dragging .image-grid-image-inner {
2631        transition: all 0.35s ease-in-out;
2632        border: 1.2px solid #fff;
2633        border-radius: 10px;
2634        z-index: 1000;
2635        transform: scale(1.1);
2636        cursor: grab;
2637        cursor: move;
2638    }
2639    .image-grid-image.dragging .image-grid-drag-handle-wrap {
2640        display: none;
2641        position: relative;
2642    }
2643    .event-list-card {
2644        overflow: hidden;
2645        background: white;
2646        font-family: 'Inter', sans-serif;
2647        display: flex;
2648        flex-direction: column;
2649        flex: 0 0 auto;
2650        transition: transform 0.1s ease;
2651    }
2652    .event-list-card.small-card-image-grid {
2653        width: 190px;
2654    }
2655    .event-list-card.medium-card-image-grid {
2656        width: 230px;
2657    }
2658    .event-list-card.large-card-image-grid {
2659        width: 300px;
2660    }
2661    .event-list-card.box-shadow-light {
2662        box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2);
2663    }
2664    .event-list-card.box-shadow-dark {
2665        box-shadow: 6px 6px 16px rgba(0, 0, 0, 0.2);
2666    }
2667    .event-list-card .image-grid-image {
2668        width: 100%;
2669        flex-basis: auto;
2670        aspect-ratio: 1 / 1;
2671    }
2672    .event-list-card .image-grid-image:before {
2673        display: none;
2674    }
2675    .event-list-card .image-grid-event-image {
2676        display: flex;
2677        text-align: center;
2678        align-items: center;
2679        width: 100%;
2680        height: 100%;
2681        background-size: cover;
2682        background-repeat: no-repeat;
2683        background-position: center;
2684        color: #fff;
2685        position: absolute;
2686        pointer-events: none;
2687    }
2688    .event-list-card .image-grid-event-image * {
2689        pointer-events: none;
2690    }
2691    .event-list-card .image-grid-image-overlay {
2692        z-index: 1;
2693    }
2694    .event-list-card .image-grid-carousel-live-overlay {
2695        pointer-events: auto;
2696        cursor: pointer;
2697    }
2698    .image-grid-card-content {
2699        height: 100px;
2700        padding: 12px 5px;
2701    }
2702    .image-grid-skeleton-tile {
2703        position: relative;
2704        background-color: #e8edf1;
2705        overflow: hidden;
2706        margin-right: 0;
2707    }
2708    .image-grid-skeleton-tile::after {
2709        background: linear-gradient(40deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0));
2710        display: block;
2711        content: "";
2712        position: absolute;
2713        width: 100%;
2714        height: 100%;
2715        transform: translateX(-100%);
2716        top: 0;
2717        left: 0;
2718        animation: coming_soon 0.75s infinite;
2719    }
2720    .image-grid-skeleton-caption {
2721        height: 16px;
2722        width: 70%;
2723        border-radius: 4px;
2724        margin: 8px auto 0;
2725    }
2726    .image-grid-fade-media {
2727        opacity: 0;
2728        transition: opacity 0.4s ease;
2729    }
2730    .image-grid-fade-media.is-loaded {
2731        opacity: 1;
2732    }
2733    .upload-image-grid-image-zone{
2734        color: #323d63;
2735        text-align: center;
2736        border-radius: 2px;
2737        background-color: #f1f5f9;
2738        position: relative;
2739        display: flex;
2740        align-items: center;
2741    }
2742</style>
2743
2744<script>
2745
2746    app_content.component("image-grid-component", {
2747        template: "#template-image-grid",
2748        data() {
2749            return {
2750                global: store,
2751                loading: false,
2752                first_row_ready: false,
2753                loaded_image_ids: {},
2754            };
2755        },
2756        props: {
2757            client_id: String,
2758            class_string: String,
2759            image_grid_layout: String,
2760            images_per_row: [String, Number],
2761            caption_size: String,
2762            subtext_size: String,
2763            link_label_size: String,
2764            text_position: String,
2765            caption_font_weight: String,
2766            image_grid_opacity: String,
2767            images_string: String,
2768            image_grid_border_radius: String,
2769            // carousel
2770            formatting: String,
2771            // admin
2772            is_admin: {
2773                type: Boolean,
2774                default: false,
2775            },
2776            data_source_type: String,
2777            images_arr: Array,
2778            component: Object,
2779            last_update: Number,
2780        },
2781        mounted() {
2782            this.preload_first_row();
2783            this.$nextTick(() => {
2784                if (this.is_admin && this.images_arr){
2785                    this.makeElementsSortable_image_grid();
2786                }
2787            })
2788        },
2789        methods: {
2790            preload_first_row(){
2791                if( this.is_admin ){
2792                    this.first_row_ready = true;
2793                    return;
2794                }
2795                let list = this.images || [];
2796                let count = this.first_row_slot_count;
2797                let slice = [];
2798                for( let i = 0; i < list.length && slice.length < count; i++ ){
2799                    let entry = list[i];
2800                    if( entry && entry.url && !entry.placeholder && !entry.is_video ){
2801                        slice.push(entry);
2802                    }
2803                }
2804                if( slice.length === 0 ){
2805                    this.first_row_ready = true;
2806                    this.preload_remaining_images();
2807                    return;
2808                }
2809                let remaining = slice.length;
2810                let finish = () => {
2811                    this.first_row_ready = true;
2812                    this.preload_remaining_images();
2813                };
2814                setTimeout(finish, 2500);
2815                slice.forEach((entry) => {
2816                    this.preload_image_entry(entry, () => {
2817                        remaining -= 1;
2818                        if( remaining <= 0 ){
2819                            finish();
2820                        }
2821                    });
2822                });
2823            },
2824            preload_remaining_images(){
2825                let list = this.images || [];
2826                list.forEach((entry) => {
2827                    this.preload_image_entry(entry);
2828                });
2829            },
2830            preload_image_entry( entry, on_done ){
2831                if( !entry || !entry.id ){
2832                    if( on_done ){
2833                        on_done();
2834                    }
2835                    return;
2836                }
2837                if( entry.is_video || !entry.url || entry.placeholder ){
2838                    this.mark_image_loaded(entry.id);
2839                    if( on_done ){
2840                        on_done();
2841                    }
2842                    return;
2843                }
2844                if( this.loaded_image_ids[entry.id] ){
2845                    if( on_done ){
2846                        on_done();
2847                    }
2848                    return;
2849                }
2850                let img = new Image();
2851                img.onload = () => {
2852                    this.mark_image_loaded(entry.id);
2853                    if( on_done ){
2854                        on_done();
2855                    }
2856                };
2857                img.onerror = () => {
2858                    this.mark_image_loaded(entry.id);
2859                    if( on_done ){
2860                        on_done();
2861                    }
2862                };
2863                img.src = entry.url;
2864            },
2865            mark_image_loaded( id ){
2866                if( !id ){
2867                    return;
2868                }
2869                let next = Object.assign({}, this.loaded_image_ids);
2870                next[id] = true;
2871                this.loaded_image_ids = next;
2872            },
2873            image_has_loaded( image ){
2874                if( this.is_admin ){
2875                    return true;
2876                }
2877                if( !image || !image.id ){
2878                    return false;
2879                }
2880                if( image.is_video ){
2881                    return true;
2882                }
2883                return !!this.loaded_image_ids[image.id];
2884            },
2885            makeElementsSortable_image_grid(){
2886                //now create the new instance
2887                const containerSelector = `.draggable-container_image_grid_${this.component.id}`;
2888                const containers = document.querySelectorAll(containerSelector);
2889                if (containers?.length === 0) {
2890                    return false;
2891                }
2892                const sortable = new Draggable.Sortable(containers, {
2893                    draggable: '.image-grid-image:not(.non-draggable)',
2894                    handle: '.draggable-handle',
2895                });
2896                //now register listeners
2897                sortable.on('sortable:start', event => {
2898                    var target = event.data.dragEvent.source;
2899                    target.classList.add('dragging');
2900                });
2901                sortable.on('sortable:stop', event => {
2902                    var target = event.data.dragEvent.source;
2903                    target.classList.remove('dragging');
2904                    //now handle ui updates inside a timeout due to a draggable library issue (https://github.com/Shopify/draggable/issues/92)
2905                    setTimeout(() => {  
2906                        this.handle_sortable_stop( event );
2907                    }, 0)
2908                });
2909            },
2910            handle_sortable_stop( event ){
2911                let targetId = event.data.dragEvent.source.id;
2912                let dropElement = document.getElementById(targetId);
2913                var reordered_ids = Array.from(event.newContainer.children).map(event => event.id);
2914                reordered_ids.pop(); //pop off last value which is an empty string placeholder for image upload
2915                var reordered_images = [];
2916                reordered_ids.forEach(image_id => {
2917                    //find the image 
2918                    var this_image = null;
2919                    this.images.forEach(image => {
2920                        if( image.id == image_id ){
2921                            this_image = image
2922                        }
2923                    });
2924                    if( this_image ){
2925                        reordered_images.push( this_image );
2926                    }
2927                });
2928                //save changes
2929                save_changes_for_component( this.component, 'images', reordered_images );
2930            },
2931            returnRGBAStringForImageGridImageOverlayOpacity( opacity ){
2932                if( ! opacity && opacity !== 0 ){
2933                    opacity = 40;
2934                }
2935                return "rgba(8,8,19,"+ opacity / 100 +")";
2936            },
2937            clicked_carousel_image( image, event ){
2938                if( this.is_admin ){
2939                    return;
2940                }
2941                if( event && event.target && event.target.closest && event.target.closest('a') ){
2942                    return;
2943                }
2944                // An image with a link navigates instead of opening the lightbox.
2945                let href = this.return_link_href( image && image.link_to );
2946                if( href ){
2947                    window.location.href = href;
2948                    return;
2949                }
2950                this.open_carousel_lightbox( image );
2951            },
2952            // link_to is saved raw from the image settings input, so:
2953            // - a bare domain needs a protocol or it resolves as a relative path
2954            // - anything that isn't a usable link returns '' so callers fall back
2955            //   to the lightbox instead of navigating nowhere
2956            return_link_href( link_to ){
2957                if( ! link_to ){
2958                    return '';
2959                }
2960                let trimmed = String( link_to ).trim();
2961                if( /^(https?:\/\/|mailto:|tel:)\S/i.test( trimmed ) ){
2962                    return trimmed;
2963                }
2964                if( /^\//.test( trimmed ) || /^#\S/.test( trimmed ) ){
2965                    return trimmed;
2966                }
2967                if( /^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)+([\/?#]\S*)?$/i.test( trimmed ) ){
2968                    return 'https://' + trimmed;
2969                }
2970                return '';
2971            },
2972            lightbox_file_name( entry ){
2973                let url = (entry && entry.url) || '';
2974                let from_url = url.split('?')[0].split('/').pop();
2975                if( entry && entry.is_video ){
2976                    if( from_url && from_url.indexOf('.') !== -1 ){
2977                        return from_url;
2978                    }
2979                    return 'video.mp4';
2980                }
2981                return from_url || 'photo.jpg';
2982            },
2983            open_carousel_lightbox( image ){
2984                if( !store.components || !store.components.view_photo_carousel ){
2985                    return;
2986                }
2987                let photos = (this.images || []).filter((entry) => {
2988                    return entry && entry.url && !entry.placeholder;
2989                });
2990                if( photos.length === 0 ){
2991                    return;
2992                }
2993                let current_index = photos.findIndex((entry) => {
2994                    return image && entry.id === image.id;
2995                });
2996                if( current_index < 0 ){
2997                    current_index = 0;
2998                }
2999                store.components.view_photo_carousel.json_photos = photos.map((entry) => {
3000                    return {
3001                        id: entry.id,
3002                        url: entry.url,
3003                        caption: entry.caption,
3004                        file_name: this.lightbox_file_name(entry),
3005                    };
3006                });
3007                store.components.view_photo_carousel.current_index = current_index;
3008                store.components.view_photo_carousel.json_config = {
3009                    context: 'web_builder',
3010                    icon_only_actions: true,
3011                    allow_download: false,
3012                    allow_share: true,
3013                    show_caption: true,
3014                    caption_size: this.caption_size || 16,
3015                    caption_font_weight: this.caption_font_weight || 400,
3016                    blur_background: false,
3017                };
3018                store.overlays.view_photo_carousel = true;
3019            },
3020            confirmed_remove_image( data ){
3021                var image = data.image;
3022                var component = data.component;
3023                //find existing images
3024                var images = component.get('config_latest').images || [];
3025                //loop through to remove this one
3026                var new_images = [];
3027                for (var i = 0; i <= images?.length - 1; i++) {
3028                    if( images[i].id !== image.id ){
3029                        new_images.push( images[i] );
3030                    }
3031                }
3032                //now save and update the preview
3033                save_changes_for_component( component, 'images', new_images );
3034                store.overlays.are_you_sure = false;
3035            },
3036            // Agent-placed empty slot. Open the shared image-slot picker; on a
3037            // pick, write the chosen/imported URL into this slot and clear the
3038            // placeholder flag, so it renders like any uploaded tile.
3039            fill_image_placeholder( image ){
3040                if( typeof webbuilder_open_image_slot_picker !== 'function' ){
3041                    if( typeof displayMessage === 'function' ){
3042                        displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000);
3043                    }
3044                    return;
3045                }
3046                let label = (image && image.label) ? image.label : 'image';
3047                let slot_id = image && image.id;
3048                webbuilder_open_image_slot_picker({
3049                    label: label,
3050                    mode: 'placeholder',
3051                    on_pick: (url) => {
3052                        if( !url || !slot_id ){
3053                            return;
3054                        }
3055                        let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || [];
3056                        let next = current.map((entry) => {
3057                            if( !entry || entry.id !== slot_id ){
3058                                return entry;
3059                            }
3060                            let updated = Object.assign({}, entry, { url: url, is_video: false });
3061                            delete updated.placeholder;
3062                            return updated;
3063                        });
3064                        save_changes_for_component( this.component, 'images', next );
3065                    },
3066                    on_keep_empty: () => {
3067                        if( !slot_id ){
3068                            return;
3069                        }
3070                        let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || [];
3071                        let next = current.filter((entry) => entry && entry.id !== slot_id);
3072                        save_changes_for_component( this.component, 'images', next );
3073                    },
3074                });
3075            },
3076            // Filled tile "Change" action — swap the image via the shared picker
3077            // (mirrors custom-code's attached-slot menu: pick/paste/copy-external/
3078            // remove). Caption/link editing stays on the existing Update toolbar.
3079            change_image( image ){
3080                if( typeof webbuilder_open_image_slot_picker !== 'function' ){
3081                    if( typeof displayMessage === 'function' ){
3082                        displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000);
3083                    }
3084                    return;
3085                }
3086                let slot_id = image && image.id;
3087                if( !slot_id ){
3088                    return;
3089                }
3090                webbuilder_open_image_slot_picker({
3091                    label: 'image',
3092                    mode: 'attached',
3093                    current_url: (image && image.url) || '',
3094                    on_pick: (url) => {
3095                        if( !url ){
3096                            return;
3097                        }
3098                        let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || [];
3099                        let next = current.map((entry) => {
3100                            if( !entry || entry.id !== slot_id ){
3101                                return entry;
3102                            }
3103                            return Object.assign({}, entry, { url: url, is_video: false });
3104                        });
3105                        save_changes_for_component( this.component, 'images', next );
3106                    },
3107                    on_remove: () => {
3108                        this.confirmed_remove_image({ image: image, component: this.component });
3109                    },
3110                });
3111            },
3112            tapped_remove_image( image ){
3113                //ask for confirmation
3114                var params = {};
3115                params["fn"] = this.confirmed_remove_image;                
3116                params["title"] = "Remove image";
3117                params["subtext"] = "Are you sure you want to permanently remove this tile from the image grid?";
3118                params["button_label"] = "Remove";
3119                params["data"] = {
3120                    "image": image,
3121                    "component": this.component
3122                };
3123                are_you_sure( params );
3124                return
3125            },
3126            prepThenDisplayImageSettings(imageObject){
3127                store.components.image_settings_overlay.componentObject = this.component;
3128                store.components.image_settings_overlay.imageObject = imageObject;
3129                store.components.image_settings_overlay.settings = {
3130                    link_input: imageObject.link_to  || '',
3131                    caption_input: imageObject.caption || '',
3132                    subtext_input: imageObject.subtext || '',
3133                    link_label_input: imageObject.link_label || '',
3134                }
3135                store.components.image_settings_overlay.image_grid_props = {
3136                    client_id: this.client_id,
3137                    caption_size: this.caption_size,
3138                    caption_font_weight: this.caption_font_weight,
3139                };
3140                this.global.overlays.image_settings_overlay = !this.global.overlays.image_settings_overlay;
3141                this.$nextTick(() => {
3142                    this.updatePositionForImageSettings( imageObject );
3143                });
3144            },
3145            updatePositionForImageSettings(imageObject){
3146                var image_object = imageObject;
3147                if( ! image_object ){
3148                    return
3149                }
3150                var element = $(".image-grid-image_" + image_object.id);
3151                if( ! element ){
3152                    return
3153                }
3154                var offset = element.offset();
3155                var top = offset.top;
3156                var popupHeight = $('.image-settings').outerHeight(true) || 0;
3157                var above_top = top - ( popupHeight + 10 );
3158                var viewportTop = $(window).scrollTop();
3159                var correct_top = above_top >= viewportTop + 10 ? above_top : top + 45;
3160                store.components.image_settings_overlay.position = {
3161                    top: `${correct_top}px`,
3162                    left: `${offset.left}px`
3163                };
3164            },
3165            imageUploadChange($event){
3166                var file = event.target.files[0];
3167                var componentObject = this.component;
3168                if(file && componentObject){
3169                    //update the ui
3170                    $(this).parents('.uploadImageZone').addClass('loading');
3171                    //set attributes
3172                    var type = 'image_grid';
3173                    var timestamp = Date.now();
3174                    //check the file type
3175                    var is_video = this.returnIsVideo( file['name'] ) || false;
3176                    if( is_video ){
3177                        //ok, it's a video
3178                        //.. handle the video
3179                        var file_name = componentObject.get('client_id')+"_grid_video_"+timestamp;
3180                        return uploadFileToAWSV2( file, file_name ).then(( url )=>{
3181                            this.handleUploadedImageGridImage( url, componentObject, true );
3182                        });
3183                    }else{
3184                        //assume it's an image!
3185                        var file_name = componentObject.get('client_id')+"_grid_image_"+timestamp;
3186                        //handle the image
3187                        loadCleanImage(file, this.handleCleanImage_image_grid, type, file_name, componentObject.get('client_id') );
3188                    }
3189                }
3190                //reset input value to detect change if same file is uploaded
3191                event.target.value = '';
3192            },
3193            returnIsVideo(filename) {
3194                var ext = this.getExtension(filename);
3195                switch (ext.toLowerCase()) {
3196                    case 'm4v':
3197                    case 'avi':
3198                    case 'mpg':
3199                    case 'mp4':
3200                    case 'mp3':
3201                    case 'mov':
3202                    // etc
3203                    return true;
3204                }
3205                return false;
3206            },
3207            getExtension(filename) {
3208                var parts = filename.split('.');
3209                return parts[parts?.length - 1];
3210            },
3211            completed_crop(params){
3212                const { fileType, is_video, file_name, blob, componentObject } = params;
3213                //upload it
3214                return uploadFileToAWSV2( blob, file_name ).then(( url )=>{
3215                    this.handleUploadedImageGridImage( url, componentObject, is_video );
3216                });
3217            },
3218            handleCleanImage_image_grid( canvas, type, file_name, componentID, image_index, is_video, fileType ){
3219                $('body').data('can', canvas);
3220                let componentObject = this.component;
3221                if(!fileType) {
3222                    fileType = 'image/jpeg'
3223                }
3224                var url = canvas.toDataURL(fileType);
3225                var params = {};
3226                params["url"] = url;
3227                params["fn"] = this.completed_crop;
3228                params["componentID"] = componentID;
3229                params["fileType"] = fileType;
3230                params["is_video"] = is_video || false;
3231                params["file_name"] = file_name;
3232                params["componentObject"] = componentObject;
3233                launch_image_cropper( params );
3234            },
3235            handleUploadedImageGridImage(url, componentObject, is_video){
3236                if( componentObject ){
3237                    //it's still in the dom
3238                    //.. update it
3239                    var images = componentObject.get('config_latest')?.images || [];
3240                    images.push({
3241                        "id": generateClientID(),
3242                        "url": url, 
3243                        "is_video": is_video
3244                    });
3245                    save_changes_for_component( this.component, 'images', images );
3246                    //store.eventBus.$emit('saveChangesForComponent', this.component, option, value)
3247                    //message
3248                    if( is_video ){
3249                        displayMessage("Video uploaded. It may take a moment for your video to appear.", false, 5000);
3250                    }else{
3251                        displayMessage("Image uploaded.", false, 3000);
3252                    }
3253                }
3254            },
3255        },
3256        computed: {
3257            images(){
3258                if (!this.is_admin && this.images_string){
3259                    try {
3260                        const parsedObject = JSON.parse(this.images_string);
3261                        // Drop unfilled agent placeholders on the public site —
3262                        // they are an admin-only "Add image" affordance.
3263                        return Array.isArray(parsedObject)
3264                            ? parsedObject.filter((entry) => entry && entry.url && !entry.placeholder)
3265                            : parsedObject;
3266                    } catch (error) {
3267                        console.error('Error parsing JSON: ', error)
3268                    }
3269                } else if (this.is_admin && this.images_arr){
3270                    return this.images_arr || [];
3271                }
3272            },
3273            formatting_obj(){
3274                if (this.formatting){
3275                    return JSON.parse(this.formatting);
3276                }
3277                return {};
3278            },
3279            first_row_slot_count(){
3280                if( this.image_grid_layout === 'carousel' ){
3281                    let size = this.formatting_obj.card_size;
3282                    if( size === 'small' ){
3283                        return 5;
3284                    }
3285                    if( size === 'large' ){
3286                        return 3;
3287                    }
3288                    return 4;
3289                }
3290                let per_row = Number(this.images_per_row);
3291                if( !per_row || per_row < 1 ){
3292                    return 3;
3293                }
3294                return per_row;
3295            },
3296            skeleton_tile_count(){
3297                let images = this.images;
3298                let row = this.first_row_slot_count;
3299                if( images && images.length > 0 && images.length < row ){
3300                    return images.length;
3301                }
3302                return row;
3303            },
3304            show_first_row_skeleton(){
3305                if( this.is_admin ){
3306                    return false;
3307                }
3308                return !this.first_row_ready;
3309            },
3310        },
3311    })
3312
3313</script>
3313    
3313<script type="text/html" id="template-photo-gallery">
3314    <div class="photo-gallery-wrap centeredBlock centeredText">
3315        <div v-if="loading" class="photo-gallery">
3316            <div
3317                v-for="n in skeleton_tile_count"
3318                :key="'photo-gallery-skeleton-' + n"
3319                class="photo-container"
3320                :style="calculate_container_dimensions()"
3321            >
3322                <div class="photo-cover photo-gallery-skeleton-tile"></div>
3323            </div>
3324        </div>
3325        <!-- hosted site -->
3326        <div v-if="!loading && !is_admin">
3327            <div 
3328                v-if="photo_album_id && json_photos"
3329                class="photo-gallery"
3330            >
3331                <div 
3332                    v-for="(photo, index) in json_photos" 
3333                    class="photo-container" 
3334                    :id="photo.id"
3335                    @click="clicked_gallery_photo( index )"
3336                    :style="calculate_container_dimensions()"
3337                >
3338                    <video v-if="photo && returnIsVideo(photo.file_name)" class="video-cover paddingTop" autoplay muted loop playsinline>
3339                        <source :src="photo.url" type="video/mp4">
3340                    </video>
3341                    <div 
3342                        v-else-if="photo && photo.url" 
3343                        class="photo-cover"
3344                        :class="{ 'photo-cover-fade': !is_admin, 'is-loaded': photo_has_loaded(photo) }"
3345                        :style="'background-image:url('+encodeURI(photo.url)+')'"
3346                    ></div>
3347                    <!-- overlay -->
3348                    <div class="photo-overlay"></div>
3349                </div>
3350            </div>
3351        </div>
3352        <!-- admin -->
3353        <div v-if="!loading && is_admin">
3354            <div
3355                v-if="!photo_album_id"
3356                @click="link_photo_album()"
3357                class="noObjectsMSG centeredBlock centeredText"
3358                style="padding: 5em;"
3359            >
3360                <p style="cursor: pointer; font-family: 'Roboto', 'Arial' !important;"><i class="fa-light fa-images smallMarginRight"></i>Click to link a photo album</p>
3361            </div>
3362            <div 
3363                v-else-if="photo_album_id && json_photos"
3364                class="photo-gallery"
3365            >
3366                <div 
3367                    v-for="(photo, index) in json_photos" 
3368                    class="photo-container" 
3369                    :id="photo.id"
3370                    @click="clicked_gallery_photo( index )"
3371                    :style="calculate_container_dimensions()"
3372                >
3373                    <video v-if="photo && returnIsVideo(photo.file_name)" class="video-cover paddingTop" autoplay muted loop playsinline>
3374                        <source :src="photo.url" type="video/mp4">
3375                    </video>
3376                    <div 
3377                        v-else-if="photo && photo.url" 
3378                        class="photo-cover"
3379                        :class="{ 'photo-cover-fade': !is_admin, 'is-loaded': photo_has_loaded(photo) }"
3380                        :style="'background-image:url('+encodeURI(photo.url)+')'"
3381                    ></div>
3382                    <!-- overlay -->
3383                    <div class="photo-overlay"></div>
3384                </div>
3385            </div>
3386        </div>
3387    </div>
3388</script>
3388
3389
3390<style>
3391    .photo-gallery-wrap{
3392        height: fit-content;
3393        width: 100%;
3394    }
3395    .photo-gallery{
3396        display: flex;
3397        flex-wrap: wrap;
3398        align-items: center;
3399        justify-content: flex-start; /* center for mobile */
3400    }
3401    .photo-container {
3402        position: relative;
3403        box-sizing: border-box;
3404        margin: 0.3em;
3405        text-align: center;
3406    }
3407    .photo-container .video-cover {
3408        width: 100%;
3409        object-fit: cover;
3410    }
3411    .photo-container > .photo-cover {
3412        height: 100%;
3413        width: inherit;
3414        display: block;
3415        overflow: hidden;
3416        margin: 0 auto;
3417        background-size: cover;
3418        background-position: center;
3419        background-repeat: no-repeat;
3420        opacity: 1;
3421    }
3422    .photo-container > .photo-cover.photo-cover-fade {
3423        opacity: 0;
3424        transition: opacity 0.4s ease;
3425    }
3426    .photo-container > .photo-cover.photo-cover-fade.is-loaded {
3427        opacity: 1;
3428    }
3429    .photo-overlay{
3430        position: absolute;
3431        display: flex;
3432        align-items: center;
3433        justify-content: center;
3434        top: 0;
3435        left: 0;
3436        height: 100%;
3437        width: 100%;
3438        opacity: 0;
3439        background-color: rgba(8,8,19,0.3);
3440        transition: opacity 0.2s ease-out;
3441    }
3442    .photo-container:hover .photo-overlay {
3443        cursor: pointer;
3444        opacity: 0.8 !important;
3445    }
3446    .photo-gallery-skeleton-tile {
3447        position: relative;
3448        background-color: #e8edf1;
3449        overflow: hidden;
3450        margin-right: 0;
3451        height: 100%;
3452        width: 100%;
3453        border-radius: 2px;
3454    }
3455    .photo-gallery-skeleton-tile::after {
3456        background: linear-gradient(40deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0));
3457        display: block;
3458        content: "";
3459        position: absolute;
3460        width: 100%;
3461        height: 100%;
3462        transform: translateX(-100%);
3463        top: 0;
3464        left: 0;
3465        animation: coming_soon 0.75s infinite;
3466    }
3467</style>
3468
3469<script>
3470
3471    app_content.component("photo-gallery-component", {
3472        template: "#template-photo-gallery",
3473        data() {
3474            return {
3475                global: store,
3476                loading: true,
3477                json_photos: [],
3478                loaded_photo_ids: {},
3479            };
3480        },
3481        props: {
3482            client_id: String,
3483            is_admin: Boolean,
3484            component: Object,
3485            last_update: Number,
3486            photo_album: [String, Object],
3487            show_caption: Boolean,
3488            caption_size: Number,
3489            caption_font_weight: Number,
3490            photos_per_row: Number,
3491        },
3492        mounted() {
3493            if( !this.photo_album_id ){
3494                this.loading = false;
3495                return;
3496            }
3497            this.load_photo_albums( this.client_id );
3498        },
3499        methods: {
3500            calculate_container_dimensions(){
3501                const percentage = 100 / this.photos_per_row;
3502                const isMobile = window.innerWidth <= 600;
3503                if ( isMobile ){
3504                    return {
3505                        flex: `1 1 calc(${percentage}% - 0.6em)`,
3506                        maxWidth: `calc(${percentage}% - 0.6em)`,
3507                        height: `calc(${100}px * (${percentage} / 100) * 2.5)`,
3508                    };
3509                }
3510                return {
3511                    flex: `1 1 calc(${percentage}% - 0.6em)`,
3512                    maxWidth: `calc(${percentage}% - 0.6em)`,
3513                    height: `calc(${500}px * (${percentage} / 100) * 2.5)`,
3514                };
3515            },
3516            load_photo_albums( client_id, overrides = {} ){
3517                this.loading = true;
3518                var promise = Parse.Promise.as();
3519                var thisPromise = promise.then(() => {
3520                    var params = {};
3521                    params["client_id"] = client_id;
3522                    params["photo_album_id"] = this.photo_album_id;
3523                    params["overrides"] = overrides;
3524                    params["request_url"] = window.location.href;
3525                    return Parse.Cloud.run('retrieve_data_for_component', params);
3526                }).then(retrieved_photos => {
3527                    this.json_photos = retrieved_photos;
3528                    this.preload_gallery_photos(retrieved_photos);
3529                    return Parse.Promise.as();
3530                }).then(() => {
3531                    this.loading = false;
3532                }, error => {
3533                    checkParseError(error);
3534                    this.loading = false;
3535                });
3536                return thisPromise;
3537            },
3538            clicked_gallery_photo( index ){
3539                store.components.view_photo_carousel.json_photos = this.json_photos;
3540                store.components.view_photo_carousel.current_index = index;
3541                store.components.view_photo_carousel.json_config = {
3542                    context: 'web_builder',
3543                    icon_only_actions: true,
3544                    allow_download: false,
3545                    allow_share: true,
3546                    show_caption: this.show_caption || false,
3547                    caption_size: this.caption_size || 16,
3548                    caption_font_weight: this.caption_font_weight || 400,
3549                    blur_background: false,
3550                };
3551                store.overlays.view_photo_carousel = true;
3552            },
3553            link_photo_album(){
3554                store.components.link_photo_album.component = this.component;
3555                store.components.link_photo_album.last_update = this.last_update;
3556                store.overlays.link_photo_album = true;
3557            },
3558            returnIsVideo(filename) {
3559                var ext = this.getExtension(filename);
3560                switch (ext.toLowerCase()) {
3561                    case 'm4v':
3562                    case 'avi':
3563                    case 'mpg':
3564                    case 'mp4':
3565                    case 'mp3':
3566                    case 'mov':
3567                    // etc
3568                    return true;
3569                }
3570                return false;
3571            },
3572            getExtension(filename) {
3573                var parts = filename.split('.');
3574                return parts[parts.length - 1];
3575            },
3576            preload_gallery_photos( photos ){
3577                if( !photos || !photos.length ){
3578                    return;
3579                }
3580                photos.forEach((photo) => {
3581                    if( !photo || !photo.id ){
3582                        return;
3583                    }
3584                    if( this.returnIsVideo(photo.file_name) || !photo.url ){
3585                        this.mark_photo_loaded(photo.id);
3586                        return;
3587                    }
3588                    let img = new Image();
3589                    img.onload = () => {
3590                        this.mark_photo_loaded(photo.id);
3591                    };
3592                    img.onerror = () => {
3593                        this.mark_photo_loaded(photo.id);
3594                    };
3595                    img.src = photo.url;
3596                });
3597            },
3598            mark_photo_loaded( id ){
3599                if( !id ){
3600                    return;
3601                }
3602                let next = Object.assign({}, this.loaded_photo_ids);
3603                next[id] = true;
3604                this.loaded_photo_ids = next;
3605            },
3606            photo_has_loaded( photo ){
3607                if( this.is_admin ){
3608                    return true;
3609                }
3610                if( !photo || !photo.id ){
3611                    return false;
3612                }
3613                if( this.returnIsVideo(photo.file_name) ){
3614                    return true;
3615                }
3616                return !!this.loaded_photo_ids[photo.id];
3617            },
3618        },
3619        computed: {
3620            photo_album_obj(){
3621                if (this.photo_album && typeof this.photo_album === 'string'){
3622                    return JSON.parse(unescape(decodeURIComponent(this.photo_album)));
3623                } else if (this.photo_album){
3624                    return this.photo_album;
3625                } 
3626                return;
3627            },
3628            photo_album_id(){
3629                let album = this.photo_album_obj;
3630                if( !album ){
3631                    return null;
3632                }
3633                return album.id || null;
3634            },
3635            skeleton_tile_count(){
3636                let per_row = Number(this.photos_per_row);
3637                if( !per_row || per_row < 1 ){
3638                    return 4;
3639                }
3640                return per_row;
3641            },
3642            photo_album_name(){
3643                return this.photo_album_obj.name || null;
3644            },
3645        },
3646        watch: {
3647            last_update(newVal, oldVal){
3648                if (newVal !== oldVal){
3649                    this.load_photo_albums( this.client_id );
3650                }
3651            }
3652        }
3653    })
3654
3655</script>
3655    
3655<script type="text/html" id="template-membership-calendar">
3656     <!-- calendar component -->
3657    <div v-if="current_date"class="lightBorderTop hundop">
3658        <calendar-booking-component
3659            ref="bookingCalendar"
3660            :is_admin="false"
3661            :rerender_key="rerender_key"
3662            :calendar_type="'web_component'"
3663            :default_calendar_tags="formatted_event_types"
3664            :selected_tags="selected_tags"
3665            :events="filtered_events"
3666            :view_preference="view_preference"
3667            :current_date="current_date"
3668            :calendar_tags="calendar_tags"
3669            :timezone="timezone"
3670            :height="'initial'"
3671            :filter_logic="filter_logic"
3672            :loading_parent="loading"
3673            :ui_tag_ids_default="ui_tag_ids_default"
3674            :ui_tag_ids_custom="ui_tag_ids_custom"
3675            :disable_filtering="!allow_user_filtering"
3676            @change_date="change_date"
3677            @change_view_preference="change_view_preference"
3678            @clicked_default_tag="clicked_default_tag"
3679            @clicked_calendar_tag="clicked_calendar_tag"
3680            @clicked_event="clicked_event"
3681            @clicked_show_more_events="clicked_show_more_events"
3682            @zoom_in_to_date="zoom_in_to_date"
3683            @add_event="clicked_day_cell"
3684            @isolate_default_tag="isolate_default_tag"
3685            @select_all_default_tags="select_all_default_tags"
3686            @clear_all_default_tags="clear_all_default_tags"
3687            @select_visible_default_tags="select_visible_default_tags"
3688            @clear_visible_default_tags="clear_visible_default_tags"
3689            @isolate_calendar_tag="isolate_calendar_tag"
3690            @select_all_custom_tags="select_all_custom_tags"
3691            @clear_all_custom_tags="clear_all_custom_tags"
3692            @select_visible_custom_tags="select_visible_custom_tags"
3693            @clear_visible_custom_tags="clear_visible_custom_tags"
3694            @unselect_all_custom_tags="unselect_all_custom_tags"
3695        />
3696    </div>
3697</script>
3697
3698
3699<script>    
3700    /* ----- STORE ----- */
3701    store.components['membership_cal'] = {
3702        clubId: null,
3703        config_obj: {},
3704        // new 
3705        rerender_key: 0,
3706        current_date: null,
3707        view_preference: "Month",
3708        events: [],
3709        loading: {
3710            calendar: false, 
3711            calendar_tags: false,
3712            ui_refresh: false,
3713        }, 
3714        ui_tag_ids_default: {}, //used to track which tags are selected in the UI for default tags
3715        ui_tag_ids_custom: {},
3716        selected_tags: {
3717            default: [],
3718            custom: [],
3719        },
3720        calendar_tags: [], 
3721    };
3722    /* ----- COMPONENT ----- */
3723    let membership_calendar_config = {
3724        template: '#template-membership-calendar',
3725        data() {
3726            return store.components['membership_cal'];
3727        },
3728        props: {
3729            config: String,
3730            client_id: String,
3731            class_string: String,
3732            style_string: String,
3733            //admin
3734            component: Object,
3735            last_update: Number,
3736            tz: String,
3737        },
3738        mounted() {
3739            if (this.config){
3740                try {
3741                    let raw_config = this.config;
3742                    try {
3743                        raw_config = decodeURIComponent(raw_config);
3744                    } catch (decode_error) {
3745                        // Already plain JSON from older markup.
3746                    }
3747                    this.config_obj = JSON.parse(raw_config);
3748                } catch (parse_error) {
3749                    this.config_obj = {};
3750                }
3751            }
3752            this.clubId = $('body').data('club-id'); 
3753            // this.load_membership_cal(this.client_id);
3754            // Use component prop to determine if admin flow
3755            this.is_admin = !!this.component;
3756            // this.checkThenHandleEventTagsForCalendar(this.client_id);
3757            // //eventBus for overflow-overlay
3758            // store.eventBus.$on('clickSchedule_membership_cal', (event) => {
3759            //     this.clickSchedule(event)
3760            // })
3761
3762            //******* NEW CALENDAR BELOW *******//
3763            if(!this.current_date){
3764                this.current_date = moment.tz( this.timezone );
3765            }
3766            this.load_calendar();
3767            this.set_tags();
3768            // this.handle_filter_options();
3769            this.load_event_tags();
3770            this.get_timezone();
3771        },
3772        methods: {
3773            get_timezone(){
3774                var promise = Parse.Promise.as().then(() => {
3775                    var query = new Parse.Query("clubs");
3776                    query.select("timezone");
3777                    return query.get(this.clubId);
3778                }).then((clubObj) => {
3779                    if(clubObj){
3780                        store.clubObject = clubObj;
3781                    }
3782                    return Parse.Promise.as();
3783                }).catch((error) => {
3784                    checkParseError(error);
3785                    displayMessage("An error occurred while loading the club timezone: " + error.message, true, 5000);
3786                });
3787            },
3788            load_calendar(){
3789                this.loading.calendar = true;
3790                var promise = Parse.Promise.as();
3791                var events = [];
3792                var camps = [];
3793                var regattas = [];
3794                var group_classes = [];
3795                var recurring_events = [];
3796                var thisPromise = promise.then(() => {
3797                    var promises = [];
3798                    //if admin configured filter_tag_ids, we need to load all event types
3799                    //so we can match events that have those tags regardless of type
3800                    var should_load_all_types = this.filter_tag_ids && this.filter_tag_ids.length > 0;
3801                    //social events
3802                    var primary_promise = promise.then(()=>{
3803                        //see if we want these event types
3804                        if( ! this.event_types.includes('socials') && ! should_load_all_types ){
3805                            //not retrieving social events
3806                            return Promise.resolve({
3807                                formatted_events: [],
3808                                recurring_events: [],
3809                            });
3810                        }
3811                        var params = {};
3812                        var { startTimestamp, endTimestamp } = this.getCalendarRange();
3813                        params["club_id"] = $('body').data('club-id');
3814                        params["start_timestamp"] = startTimestamp;
3815                        params["end_timestamp"] = endTimestamp;
3816                        params["flow"] = "member_portal";
3817                        params["is_admin"] = false;
3818                        return Parse.Cloud.run('retrieve_social_events_for_calendar_v2', params);
3819                    }).then((retrievedEvents) => {
3820                        events = retrievedEvents.formatted_events || [];
3821                        recurring_events = retrievedEvents.recurring_events || [];
3822                        return Parse.Promise.as();
3823                    });
3824                    promises.push(primary_promise);
3825                    // load camps
3826                    var camps_promise = promise.then(()=>{
3827                        //see if we want these event types
3828                        if( ! this.event_types.includes('camps') && ! should_load_all_types ){
3829                            //not retrieving camps
3830                            return Promise.resolve([]);
3831                        }
3832                        var params = {};
3833                        var { startTimestamp, endTimestamp } = this.getCalendarRange();
3834                        params["club_id"] = $('body').data('club-id');
3835                        params["start_timestamp"] = startTimestamp;
3836                        params["end_timestamp"] = endTimestamp;
3837                        params["is_admin"] = false;
3838                        return Parse.Cloud.run('retrieve_camps_for_calendar_v2', params);
3839                    }).then((retrievedCamps) => {
3840                        camps = retrievedCamps;
3841                        return Parse.Promise.as();
3842                    });
3843                    promises.push(camps_promise);
3844                    // load regattas
3845                    var regattas_promise = promise.then(()=>{
3846                        //see if we want these event types
3847                        if( this.hide_regattas ){
3848                            return Promise.resolve([]);
3849                        }
3850                        if( ! this.event_types.includes('regattas') && ! should_load_all_types ){
3851                            //not retrieving regattas
3852                            return Promise.resolve([]);
3853                        }
3854                        var params = {};
3855                        var { startTimestamp, endTimestamp } = this.getCalendarRange();
3856                        params["club_id"] = $('body').data('club-id');
3857                        params["start_timestamp"] = startTimestamp;
3858                        params["end_timestamp"] = endTimestamp;
3859                        params["flow"] = "member_portal";
3860                        return Parse.Cloud.run('retrieve_regattas_for_calendar_v2', params);
3861                    }).then((retrievedRegattas) => {
3862                        regattas = retrievedRegattas;
3863                        return Parse.Promise.as();
3864                    });
3865                    promises.push(regattas_promise);
3866                    // load group classes
3867                    var group_classes_promise = promise.then(()=>{
3868                        if( ! this.event_types.includes('group_classes') && ! should_load_all_types ){
3869                            return Promise.resolve([]);
3870                        }
3871                        var params = {};
3872                        var { startTimestamp, endTimestamp } = this.getCalendarRange();
3873                        params["club_id"] = $('body').data('club-id');
3874                        params["start_timestamp"] = startTimestamp;
3875                        params["end_timestamp"] = endTimestamp;
3876                        params["is_admin"] = false;
3877                        return Parse.Cloud.run('retrieve_group_classes_for_calendar_v2', params);
3878                    }).then((retrievedGroupClasses) => {
3879                        group_classes = retrievedGroupClasses || [];
3880                        return Parse.Promise.as();
3881                    });
3882                    promises.push(group_classes_promise);
3883                    //return them in parallel
3884                    return Promise.all(promises);
3885                }).then(() => {
3886                    this.events = [];
3887                    this.events = [ ...events, ...camps, ...regattas, ...group_classes, ...recurring_events ];
3888                    this.loading.calendar = false;
3889                }).catch((error) => {
3890                    checkParseError(error);
3891                    displayMessage("An error occurred while loading the calendar: " + error.message, true, 5000);
3892                    this.loading.calendar = false;
3893                    this.events = [];
3894                });
3895                return thisPromise;
3896            },
3897            load_event_tags(){
3898                this.loading.calendar_tags = true;
3899                var promise = Parse.Promise.as();
3900                var thisPromise = promise.then(() => {
3901                    var query = new Parse.Query("event_tags");
3902                    query.equalTo("clubObject", {
3903                        __type: "Pointer",
3904                        objectId: $('body').data('club-id'),
3905                        className: "clubs"
3906                    });
3907                    query.equalTo("archived", false);
3908                    query.notEqualTo("internal_only", true);
3909                    query.limit( 1000 );
3910                    query.ascending("name");
3911                    return query.find();
3912                }).then((retrievedTags) => {
3913                    //if admin restricted to specific tags, only expose those in the user filter UI
3914                    if( this.filter_tag_ids && this.filter_tag_ids.length > 0 ){
3915                        retrievedTags = retrievedTags.filter(tag => this.filter_tag_ids.indexOf(tag.id) !== -1);
3916                    }
3917                    this.calendar_tags = retrievedTags;
3918                    //now set all of them to selected
3919                    this.ui_tag_ids_custom = {};
3920                    this.calendar_tags.forEach((tag) => {
3921                        this.ui_tag_ids_custom[tag.id] = true;
3922                    });
3923                    this.selected_tags.custom = this.calendar_tags.map(t => t.id);
3924                    this.loading.calendar_tags = false;
3925                }).catch((error) => {
3926                    checkParseError(error);
3927                    displayMessage("An error occurred while loading calendar tags: " +error.message, true, 5000);
3928                    this.loading.calendar_tags = false;
3929                    this.calendar_tags = [];
3930                    this.loading.calendar = false;
3931                });
3932                return thisPromise;
3933            },
3934            set_tags(){
3935                //select all event types by default
3936                //options: "camp", "regatta", "social", "calendar_event"
3937                this.selected_tags.default = [];
3938                this.selected_tags.custom = []; 
3939                let selected = [];
3940                //check what event types are available
3941                let event_types = this.event_types || [];
3942                if( event_types.indexOf('camps') !== -1  ) {
3943                   selected.push('camp');
3944                }
3945                if( event_types.indexOf('regattas') !== -1 ) {
3946                    selected.push('regatta');
3947                }
3948                if( event_types.indexOf('socials') !== -1 ) {
3949                    selected.push('social');
3950                    selected.push('calendar_event');
3951                }
3952                if( event_types.indexOf('group_classes') !== -1 ) {
3953                    selected.push('group_class');
3954                }
3955                this.selected_tags.default = selected;
3956                //and update the ui tags
3957                this.ui_tag_ids_default = {};
3958                this.selected_tags.default.forEach((id) => {
3959                    this.ui_tag_ids_default[id] = true;
3960                });
3961            },
3962            getCalendarRange() {
3963                let startTimestamp, endTimestamp;
3964                if (this.view_preference === "Month") {
3965                    // Start and end of the previous and next months
3966                    startTimestamp = moment(this.current_date).startOf("month").subtract(1, "month").valueOf();
3967                    endTimestamp = moment(this.current_date).endOf("month").add(1, "month").valueOf();
3968                } else if (this.view_preference === "Week") {
3969                    // Start and end of the previous and next 2 weeks
3970                    startTimestamp = moment(this.current_date).startOf("isoWeek").subtract(2, "week").valueOf();
3971                    endTimestamp = moment(this.current_date).endOf("isoWeek").add(2, "week").valueOf();
3972                } else if (this.view_preference === "Day") {
3973                    // Start and end of the previous and next 2 weeks
3974                    //.. important to do a wide enough range so that multi-day events are included
3975                    startTimestamp = moment(this.current_date).startOf("isoWeek").subtract(2, "week").valueOf();
3976                    endTimestamp = moment(this.current_date).endOf("isoWeek").add(2, "week").valueOf();
3977                }
3978
3979                return { startTimestamp, endTimestamp };
3980            },
3981            change_date( new_date ){
3982                this.current_date = new_date;
3983                this.load_calendar();
3984            },
3985            change_view_preference( new_value ){
3986                this.view_preference = new_value;
3987                this.load_calendar();
3988            },
3989            clicked_day_cell(date_components){
3990                // if its month view, and we are on mobile (width < 720), go to day view
3991                if(this.view_preference === "Month" && window.innerWidth < 720){
3992                     //set the current date to the clicked date
3993                    var clicked_date = moment.tz({
3994                        year: date_components.year,
3995                        month: date_components.month,
3996                        date: date_components.date, 
3997                    }, this.timezone);
3998                    this.zoom_in_to_date( clicked_date );
3999                }
4000            },
4001            zoom_in_to_date( date ){
4002                this.change_view_preference( "Day" );
4003                this.current_date = date;
4004            },
4005            clicked_default_tag(tag){
4006                this.loading.ui_refresh = true;
4007                //update the ui
4008                if(this.ui_tag_ids_default[tag.id] === undefined){
4009                    //if the tag is not in the ui_tag_ids_default, add it
4010                    this.ui_tag_ids_default[tag.id] = true;
4011                }else{
4012                    this.ui_tag_ids_default[tag.id] = !this.ui_tag_ids_default[tag.id];
4013                }
4014                requestAnimationFrame(() => {
4015                    // second RAF helps ensure we’re past layout/paint on Chrome/Safari
4016                    requestAnimationFrame(() => {
4017                        const list = this.selected_tags.default;
4018                        const idx = list.indexOf(tag.id);
4019                        // create a new array to avoid deep reactive churn
4020                        this.selected_tags.default = idx !== -1
4021                            ? list.filter(t => t !== tag.id)
4022                            : [...list, tag.id];
4023                        this.loading.ui_refresh = false;
4024                    });
4025                });     
4026            },
4027            clicked_calendar_tag(tag){
4028                this.loading.ui_refresh = true;
4029                //update the ui
4030                if(this.ui_tag_ids_custom[tag.id] === undefined){
4031                    //if the tag is not in the ui_tag_ids_custom, add it
4032                    this.ui_tag_ids_custom[tag.id] = true;
4033                }else{
4034                    this.ui_tag_ids_custom[tag.id] = !this.ui_tag_ids_custom[tag.id];
4035                }
4036                requestAnimationFrame(() => {
4037                    // second RAF helps ensure we’re past layout/paint on Chrome/Safari
4038                    requestAnimationFrame(() => {
4039                        const list = this.selected_tags.custom;
4040                        const idx = list.indexOf(tag.id);
4041                        // create a new array to avoid deep reactive churn
4042                        this.selected_tags.custom = idx !== -1
4043                            ? list.filter(t => t !== tag.id)
4044                            : [...list, tag.id];
4045                        this.loading.ui_refresh = false;
4046                    });
4047                });   
4048            },
4049             isolate_default_tag(tag){
4050                //set the ui check boxes
4051                this.ui_tag_ids_default = {};
4052                this.ui_tag_ids_default[tag.id] = true;
4053                this.ui_tags_
4054                //set the selected tags to just this one
4055                this.selected_tags.default = [tag.id];   
4056            },
4057            select_all_default_tags(){
4058                //set the ui check boxes
4059                this.ui_tag_ids_default = {};
4060                var event_types = ["camp", "regatta", "social", "banquet_event", "calendar_event", "group_class"];
4061                if(store.clubObject.get('hide_regattas')){
4062                    event_types = event_types.filter(type => type !== 'regatta');
4063                }
4064                event_types.forEach((type) => {
4065                    this.ui_tag_ids_default[type] = true;
4066                });
4067                //set the selected tags to all default tags
4068                this.selected_tags.default = event_types;
4069            },
4070            clear_all_default_tags(){
4071                this.ui_tag_ids_default = {};
4072                this.selected_tags.default = [];
4073            },
4074            select_visible_default_tags(visibleIds){
4075                if (!visibleIds || !visibleIds.length) return;
4076                var current = this.selected_tags.default || [];
4077                var next = current.slice();
4078                visibleIds.forEach(function(id){ if (next.indexOf(id) === -1) next.push(id); });
4079                this.selected_tags.default = next;
4080                next.forEach(function(id){ this.ui_tag_ids_default[id] = true; }.bind(this));
4081            },
4082            clear_visible_default_tags(visibleIds){
4083                if (!visibleIds || !visibleIds.length) return;
4084                var set = {};
4085                visibleIds.forEach(function(id){ set[id] = true; });
4086                var current = this.selected_tags.default || [];
4087                this.selected_tags.default = current.filter(function(id){ return !set[id]; });
4088                this.ui_tag_ids_default = {};
4089                (this.selected_tags.default || []).forEach(function(id){ this.ui_tag_ids_default[id] = true; }.bind(this));
4090            },
4091            isolate_calendar_tag(tag){
4092                //set the ui check boxes
4093                this.ui_tag_ids_custom = {};
4094                this.ui_tag_ids_custom[tag.id] = true;
4095                //set the selected tags to just this one
4096                this.selected_tags.custom = [tag.id];         
4097            },
4098            select_all_custom_tags(){
4099                //set the ui check boxes
4100                this.ui_tag_ids_custom = {};
4101                this.calendar_tags.forEach((tag) => {
4102                    this.ui_tag_ids_custom[tag.id] = true;
4103                });
4104                //set the selected tags to all custom tags
4105                this.selected_tags.custom = this.calendar_tags.map((tag) => tag.id);
4106            },
4107            unselect_all_custom_tags(){
4108                //used for the "catch all" option
4109                //set the ui check boxes
4110                this.ui_tag_ids_custom = {};
4111                //set the selected tags to no custom tags
4112                this.selected_tags.custom = [];
4113            },
4114            clear_all_custom_tags(){
4115                this.ui_tag_ids_custom = {};
4116                this.selected_tags.custom = [];
4117            },
4118            select_visible_custom_tags(visibleIds){
4119                if (!visibleIds || !visibleIds.length) return;
4120                var tagIds = visibleIds.filter(function(id){ return id !== 'catch_all_custom'; });
4121                var current = this.selected_tags.custom || [];
4122                var next = current.slice();
4123                tagIds.forEach(function(id){ if (next.indexOf(id) === -1) next.push(id); });
4124                this.selected_tags.custom = next;
4125                next.forEach(function(id){ this.ui_tag_ids_custom[id] = true; }.bind(this));
4126            },
4127            clear_visible_custom_tags(visibleIds){
4128                if (!visibleIds || !visibleIds.length) return;
4129                var set = {};
4130                visibleIds.forEach(function(id){ set[id] = true; });
4131                var current = this.selected_tags.custom || [];
4132                this.selected_tags.custom = current.filter(function(id){ return !set[id]; });
4133                this.ui_tag_ids_custom = {};
4134                (this.selected_tags.custom || []).forEach(function(id){ this.ui_tag_ids_custom[id] = true; }.bind(this));
4135            },
4136            clicked_event(event){
4137                var is_recurring_event = event.is_recurring;
4138                // open detail view
4139                if(is_recurring_event) {
4140                    // if(event.external_event_url) {
4141                    //     //its external, just pop open the window
4142                    //     return window.open(event.external_event_url);
4143                    // }
4144                    // var params = {
4145                    //     event_json: event,
4146                    // };
4147                    show_event_details_in_member_cal({ event_json: event });
4148                } else if(event) {
4149                    if(event.regatta_subevent) { 
4150                        //go to microsite
4151                        var regatta_id = event.parent_regatta_id;
4152                        window.open(`/regatta/${ regatta_id }`);
4153                    } else if(event.type == 'regatta'){
4154                        if(event.external_regatta_url){
4155                            window.open(event.external_regatta_url);
4156                        } else {
4157                            window.open(`/regatta/${ event.id }`);
4158                        }
4159                    } else if(event.type == 'camp'){
4160                        //go to registration
4161                        window.open(`/register/camp/${ event.id }/class`);
4162                    } else if(event.type === 'group_class'){
4163                        var timestamp = event.ts;
4164                        var dateComponents = return_date_components_v2({
4165                            timezone: this.timezone,
4166                            date: new Date(timestamp)
4167                        });
4168                        var dateString = dateComponents.year + '-' + (dateComponents.month + 1) + '-' + dateComponents.date;
4169                        let event_json = {
4170                            ...event,
4171                            external_event_url: '/group-classes?date_string=' + dateString,
4172                        };
4173                        let params = {
4174                            event_json: event_json,
4175                        };
4176                        show_event_details_in_member_cal(params);
4177                    } else {
4178                        // if(event.external_event_url){
4179                        //     window.open(event.external_event_url);
4180                        // } else
4181                        if(event.type === 'social') {
4182                            //not calendar only  ... go to the event page 
4183                            window.open(`/event/${ event.id }`);
4184                        } else {
4185                            //calendar event
4186                            var params = {
4187                                event_json: event,
4188                            };
4189                            show_event_details_in_member_cal( params );
4190                        }
4191                    }
4192                }
4193            },
4194        },
4195        computed: {
4196            event_types(){
4197                if(this.is_admin) {
4198                    return this.component?.get('config_latest').event_types || [];
4199                } else {
4200                    return this.config_obj.event_types || [];
4201                }
4202            },
4203            filter_tag_ids(){
4204                if(this.is_admin) {
4205                    return this.component?.get('config_latest').filter_tag_ids || [];
4206                } else {
4207                    return this.config_obj.filter_tag_ids || [];
4208                }
4209            },
4210            allow_user_filtering(){
4211                //defaults to true for legacy components where the field is not set
4212                var raw;
4213                if(this.is_admin) {
4214                    raw = this.component?.get('config_latest').allow_user_filtering;
4215                } else {
4216                    raw = this.config_obj.allow_user_filtering;
4217                }
4218                return raw !== false;
4219            },
4220            filter_logic(){
4221                //when admin restricts the calendar to a set of event tags, an event
4222                //should be visible if it matches the type OR has a matching tag (per
4223                //admin filter rules), so we use OR logic on the user-facing filter
4224                //to avoid hiding events that admin intends to show.
4225                if( this.filter_tag_ids && this.filter_tag_ids.length > 0 ){
4226                    return 'or';
4227                }
4228                return 'and';
4229            },
4230            event_type_setting_for_event(){
4231                //map the actual event.type strings to the toggle keys used in event_types config
4232                return {
4233                    'camp': 'camps',
4234                    'regatta': 'regattas',
4235                    'social': 'socials',
4236                    'calendar_event': 'socials',
4237                    'group_class': 'group_classes',
4238                };
4239            },
4240            filtered_events(){
4241                var event_types = this.event_types || [];
4242                var filter_tag_ids = this.filter_tag_ids || [];
4243                //if neither types nor tags are configured, fall back to whatever was loaded
4244                if( event_types.length === 0 && filter_tag_ids.length === 0 ){
4245                    return this.events;
4246                }
4247                var type_map = this.event_type_setting_for_event;
4248                return this.events.filter(event => {
4249                    var setting_key = type_map[event.type];
4250                    var type_in_selected = setting_key && event_types.indexOf(setting_key) !== -1;
4251                    if( filter_tag_ids.length === 0 ){
4252                        //no tag filter configured: only show events whose type is selected
4253                        return type_in_selected;
4254                    }
4255                    //tags are configured: an event is shown if its type is selected
4256                    //(regardless of tags applied) OR it has any of the selected tags
4257                    if( type_in_selected ){
4258                        return true;
4259                    }
4260                    var event_tag_ids = event.event_tag_ids || [];
4261                    return event_tag_ids.some(id => filter_tag_ids.indexOf(id) !== -1);
4262                });
4263            },
4264            formatted_event_types(){
4265                this.rerender_key; //depend on this to force recompute when it changes
4266                var event_types = this.event_types || [];
4267                // Updated vibrant, distinct colors of similar intensity to #853fc6
4268                var options = [];
4269                var camp_option =  {
4270                    key: "camp",
4271                    label: "Camps",
4272                    id: "camp",
4273                    color: "#236651" 
4274                };
4275                var social_option = {
4276                    key: "social",
4277                    label: "Social events",
4278                    id: "social",
4279                    color: "#ffa69e"
4280                };
4281                var calendar_event_option = {
4282                    key: "calendar_event",
4283                    label: "Other",
4284                    id: "calendar_event",
4285                    color: "#e1e7ea"
4286                };
4287                if( event_types.includes('camps') ) {
4288                    options.push(camp_option);
4289                }
4290                //respect hide regattas on club object
4291                if( event_types.includes('regattas') && ! this.hide_regattas ) {
4292                    var regatta_option = {
4293                        key: "regatta",
4294                        label: "Regattas",
4295                        id: "regatta",
4296                        color: "#87d4f9"
4297                    };
4298                    options.push(regatta_option);
4299                }
4300                if( event_types.includes('socials') ) {
4301                    options.push(social_option);
4302                    options.push(calendar_event_option);
4303                }
4304                if( event_types.includes('group_classes') ) {
4305                    options.push({
4306                        key: "group_class",
4307                        label: "Group classes",
4308                        id: "group_class",
4309                        color: "#5c7c99"
4310                    });
4311                }
4312                return {
4313                    title: "Type",
4314                    options: options
4315                };
4316            },
4317            hide_regattas(){
4318                return store.clubObject && store.clubObject.get('hide_regattas');
4319            },
4320            timezone(){
4321                if(this.tz){
4322                    return this.tz;
4323                }
4324                return store.clubObject ? store.clubObject.get('timezone') : moment.tz.guess();
4325            }
4326
4327        },
4328        watch: {
4329            last_update(newVal, oldVal){
4330                if (newVal !== oldVal){
4331                    if (this.config){
4332                        try {
4333                            let raw_config = this.config;
4334                            try {
4335                                raw_config = decodeURIComponent(raw_config);
4336                            } catch (decode_error) {
4337                                // Already plain JSON from older markup.
4338                            }
4339                            this.config_obj = JSON.parse(raw_config);
4340                        } catch (parse_error) {
4341                            this.config_obj = {};
4342                        }
4343                    }
4344                    this.rerender_key = generateClientID(); //force update of event types included
4345                    this.clubId = $('body').data('club-id'); 
4346                    this.load_calendar();
4347                    //re-filter the user-facing tag list in case admin changed filter_tag_ids
4348                    this.load_event_tags();
4349                    this.is_admin = !!this.component;
4350                }
4351            }
4352        },
4353    }
4354    app_content.component('membership-calendar-component', membership_calendar_config);
4355    app_overlays.component('membership-calendar-component', membership_calendar_config);
4356</script>
4356    
4356<script type="text/html" id="template-membership-directory">
4357    <div class="cs-background-image relative" :class="class_string" :style="style_string">
4358        <template v-if="formatting && formatting.overlay_opacity">
4359            <div class="cs-image-overlay" :style="{ 'opacity': formatting.overlay_opacity.toString() }"></div>
4360        </template>
4361        <div class="element-wrapper" :class="'element-wrapper_' + component_type" :style="element_style_string">
4362            <div class="membership-directory-wrap centeredBlock centeredText" :data-client-id="client_id">
4363                <div class="ease flexNoWrap show_when_loaded">
4364                    <div class="inputWrap searchBarWrap flexGrowOne" style="min-width:100%;">
4365                        <input class="searchBar easeFast" placeholder="Search members..." v-model="search">
4366                    </div>
4367                    <div class="flexGrowOne"></div>
4368                </div> 
4369                <div v-if="!loading && member_tags?.length >= 1" class="scrollable posScrollbar lightBorderTop noPaddingBottom">
4370                    <div class="flexNoWrap insert_member_tags" style="padding:15px;">
4371                        <div :class="{ 'active' : selectedTag === 'all' }" class="cs-tab-bar-option cs-tab-bar-option_member_tags cs-tab-bar-option_member_tags_all mobile-order" data-option="all" @click="handleTagSelection( 'all' )">All members</div>
4372                        <template v-for="tag in member_tags" :key="tag.id">
4373                            <div :class="{ 'active' : selectedTag === tag.id }" class="cs-tab-bar-option cs-tab-bar-option_member_tags mobile-order" :data-option="tag.id" @click="handleTagSelection( tag.id )">{{ tag.get('name') }}</div>
4374                        </template>
4375                    </div>
4376                </div>
4377                <!-- <div class="show_when_loaded table_markup_insert"> -->
4378                <div class="lightBorderTop scrollable fff noPadding">
4379                <table v-if="!loading && filtered_members?.length >= 1 && configurable_columns.length >= 2" class="tableInsert hundop">
4380                    <tbody>
4381                        <tr class="tableHeaderRow" :class="{ 'ascending' : sortObject.order == 'ascending' }">
4382                            <template v-for="column in configurable_columns">
4383                                <th v-if="!Array.isArray(column.headerName) && !column.headerClasses.includes('sortable')" :class="column.headerClasses" :style="column.headerStyle">{{ column.headerName }}</th>
4384                                <th v-if="!Array.isArray(column.headerName) && column.headerClasses.includes('sortable')" 
4385                                    :style="column.headerStyle" 
4386                                    :data-db-key="column['data-db-key']"
4387                                    @click="clicked_sort(column['data-db-key'])"
4388                                    :class="[column.headerClasses, { 'sorted': sortObject.key === column['data-db-key'] }]"
4389                                    >
4390                                        {{ column.headerName }}
4391                                        <div class="sort-arrow"></div>
4392                                </th>
4393                                <template v-for="headerName in column.headerName">
4394                                    <th v-if="Array.isArray(column.headerName)" :class="column.headerClasses">{{ headerName }}</th>
4395                                </template>
4396                            </template>
4397                        </tr>
4398                        <!-- member rows injected here -->
4399                        <template v-for="member in filtered_members" :key="member.id">
4400                            <tr 
4401                                v-if="check_archive_status( member )"
4402                                class="tableRow pointer" 
4403                                :class="`member-row_${member.id || member.objectId}`"
4404                                @click="clicked_member_row( member )"
4405                            >
4406                                <!-- photo column -->
4407                                <td v-if="check_configurable_classes('column_photo')" class="lessPadding column_photo first-cell" style="padding-right:0px;">
4408                                    <div v-if="member && member.get('profPicURL')" class="smallIcon noMargin noBorder" :style="{ backgroundImage: 'url(' + member.get('profPicURL') + ')' }"></div>
4409                                    <div v-else 
4410                                        class="smallIcon noMargin noBorder flexWrap align-items-center" 
4411                                        style="padding-right:0px;
4412                                        background-color: #f1f5fa;
4413                                        text-align: center;
4414                                        font-size: 16px;"
4415                                    >
4416                                        <i class="fa-regular fa-user"></i>
4417                                    </div>
4418                                </td>
4419                                <!-- firstName column -->
4420                                <td v-if="check_configurable_classes('firstName')">
4421                                    <p v-if="(member && member.get('firstName')) && !member.get('nickname')" class="capitalize">{{ member.get('firstName') }}</p>
4422                                    <p v-if="(member && member.get('firstName')) && member.get('nickname')" class="capitalize">{{ member.get('firstName') }} ({{ member.get('nickname') }})</p>
4423                                    <p v-if="member && !member.get('firstName')" class="incomplete">None</p>
4424                                </td>
4425                                <!-- lastName column -->
4426                                <td v-if="check_configurable_classes('lastName')">
4427                                    <p v-if="(member && member.get('lastName'))" class="capitalize">{{ member.get('lastName') }}</p>
4428                                    <p v-else class="incomplete">None</p>
4429                                </td>
4430                                <!-- member number column -->
4431                                <td v-if="check_configurable_classes('column_member_number')" class="column_member_number">
4432                                    <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('memberNumber')" class="uppercase">{{ member.get('membershipObject').get('memberNumber') }}</p>
4433                                    <p v-else class="incomplete">None</p>
4434                                </td>
4435                                <!-- email column -->
4436                                <td v-if="check_configurable_classes('column_email')" class="column_email">
4437                                    <p v-if="member && member.get('email')"><a @click.stop :href="'mailto:' + member.get('email')">{{ member.get('email')}}</a></p>
4438                                    <p v-else class="incomplete">None</p>
4439                                </td>
4440                                <!-- mobile column -->
4441                                <td v-if="check_configurable_classes('column_mobile')" class="column_mobile">
4442                                    <p v-if="member && member.get('mobile')"><a @click.stop :href="'tel:' + member.get('mobile')">{{ member.get('mobile')}}</a></p>
4443                                    <p v-else class="incomplete">None</p>
4444                                </td>
4445                                <!-- home phone column -->
4446                                <td v-if="check_configurable_classes('column_phone_home')" class="column_phone_home">
4447                                    <p v-if="member && member.get('phone_home')"><a @click.stop :href="'tel:' + member.get('phone_home')">{{ member.get('phone_home')}}</a></p>
4448                                    <p v-else class="incomplete">None</p>
4449                                </td>
4450                                <!-- member tags column -->
4451                                <td v-if="check_configurable_classes('column_member_tags')" class="column_member_tags">
4452                                    <div class="flexWrapLeftAlign" v-html="return_member_tags( member ) || ''"></div>
4453                                </td>
4454                                <!-- ghin number column -->
4455                                <td v-if="check_configurable_classes('column_ghin_number')" class="column_ghin_number">
4456                                    <p v-if="member && member.get('ghin_number')">{{ member.get('ghin_number') }}</p>
4457                                    <p v-else class="incomplete">None</p>
4458                                </td>
4459                                <!-- address ( street, city, state, zip) columns -->
4460                                <template v-if="check_configurable_classes('column_address')">
4461                                    <td class="column_address">
4462                                        <p v-if="member && member.get('street')">{{ member.get('street') }}</p>
4463                                        <p v-else class="incomplete">None</p>
4464                                    </td>
4465                                    <td class="column_address">
4466                                        <p v-if="member && member.get('city')">{{ member.get('city') }}</p>
4467                                        <p v-else class="incomplete">None</p>
4468                                    </td>
4469                                    <td class="column_address">
4470                                        <p v-if="member && member.get('state')">{{ member.get('state') }}</p>
4471                                        <p v-else class="incomplete">None</p>
4472                                    </td>
4473                                    <td class="column_address">
4474                                        <p v-if="member && member.get('zip')">{{ member.get('zip') }}</p>
4475                                        <p v-else class="incomplete">None</p>
4476                                    </td>
4477                                </template>
4478                                <!-- secondary address ( street, city, state, zip ) columns -->
4479                                <template v-if="check_configurable_classes('column_address_secondary')">
4480                                    <td class="column_address_secondary">
4481                                        <p v-if="member && member.get('street_secondary')">{{ member.get('street_secondary') }}</p>
4482                                        <p v-else class="incomplete">None</p>
4483                                    </td>
4484                                    <td class="column_address_secondary">
4485                                        <p v-if="member && member.get('city_secondary')">{{ member.get('city_secondary') }}</p>
4486                                        <p v-else class="incomplete">None</p>
4487                                    </td>
4488                                    <td class="column_address_secondary">
4489                                        <p v-if="member && member.get('state_secondary')">{{ member.get('state_secondary') }}</p>
4490                                        <p v-else class="incomplete">None</p>
4491                                    </td>
4492                                    <td class="column_address_secondary">
4493                                        <p v-if="member && member.get('zip_secondary')">{{ member.get('zip_secondary') }}</p>
4494                                        <p v-else class="incomplete">None</p>
4495                                    </td>
4496                                </template>
4497                                <!-- start date column -->
4498                                <td v-if="check_configurable_classes('column_start_date')" class="column_start_date">
4499                                    <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('startDate')">{{ format_utc_date( member.get('membershipObject').get('startDate') ) }}</p>
4500                                    <p v-else class="incomplete">None</p>
4501                                </td>
4502                                <!-- end date column -->
4503                                <td v-if="check_configurable_classes('column_end_date')" class="column_end_date">
4504                                    <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('endDate')">{{ format_utc_date( member.get('membershipObject').get('endDate')) }}</p>
4505                                    <p v-else class="incomplete">None</p>
4506                                </td>
4507                                <!-- renewal column -->
4508                                <td v-if="check_configurable_classes('column_renewed')" class="column_renewed">
4509                                    <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('startDate')">{{ format_utc_date( member.get('membershipObject').get('renewed') || member.get('membershipObject').get('startDate') ) }}</p>
4510                                    <p v-else class="incomplete">None</p>
4511                                </td>
4512                                <!-- category column -->
4513                                <td v-if="check_configurable_classes('column_category')" class="column_category">
4514                                    <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('tierObject')">{{ member.get('membershipObject').get('tierObject').get('name') }}</p>
4515                                    <p v-else class="incomplete">None</p>
4516                                </td>
4517                                <!-- plan column -->
4518                                <td v-if="check_configurable_classes('column_plan')" class="column_plan">
4519                                    <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('planObject')">{{ member.get('membershipObject').get('planObject').get('name') }}</p>
4520                                    <p v-else class="incomplete">None</p>
4521                                </td>
4522                            </tr>
4523                        </template>
4524                    </tbody>
4525                </table>
4526                </div>
4527                <!-- </div> -->
4528                <div v-if="loading" class="cardBodySpinnerWrap relative active">
4529                    <div class="cardBodySpinner centeredBlock"></div>
4530                </div>
4531                <p v-if="!loading && filtered_members.length == 0" class="noObjectsMSG withPadding">{{ return_no_results_msg() }}</p>
4532            </div>
4533        </div>
4534    </div>
4535</script>
4535
4536
4537<script>
4538
4539    function prepThenDisplayMembershipDirectoryPopup( memberObject ){
4540        //clear stuff
4541        $('#overlay_membership-directory-popup .insert').text('');
4542        //name
4543        var name = '';
4544        //check type
4545        if( memberObject.className == "boats" ){
4546            //actually a boat - we re-use the same overlay
4547            $('#overlay_membership-directory-popup .version_boat').removeClass('hidden');
4548            $('#overlay_membership-directory-popup .version_member').addClass('hidden');
4549            name = returnNameStringForBoat( memberObject );
4550            //handle documents
4551            $('.section-wrap_documents').find('.inline-value').remove();
4552            // var documents = $('.membership-directory-wrap').data('documents');
4553            var documents = store.components.membership_directory.documents;
4554            if( documents ){
4555                for (var i = 0; i <= documents.length - 1; i++) {
4556                    if( documents[i].get('boatObject') ){
4557                        if( documents[i].get('boatObject').id == memberObject.objectId ){
4558                            var this_name = documents[i].get('name');
4559                            if( this_name  && this_name.length >= 1 ){
4560                                $('#overlay_membership-directory-popup .section-wrap_documents').append('<p class="flexGrowOne inline-value tinyPaddingTop"><a class="tappedDocument" data-document-id="'+ documents[i].id +'" target="_blank" href="'+ documents[i].get('URL') +'">'+ this_name +' ></a></p>');
4561                            }
4562                        }
4563                    }
4564                }
4565            }
4566        }else{
4567            //ok, it's a member
4568            $('#overlay_membership-directory-popup .version_boat').addClass('hidden');
4569            $('#overlay_membership-directory-popup .version_member').removeClass('hidden');
4570            //name
4571            if( memberObject.get('firstName') ){
4572                name += memberObject.get('firstName');
4573                if( memberObject.get('nickname') ){
4574                    name += ' ('+ memberObject.get('nickname') + ')';
4575                }
4576            }
4577            if( memberObject.get('lastName') ){
4578                if( name.length >= 1 ){
4579                    name += ' ';
4580                }
4581                name += memberObject.get('lastName');
4582            }
4583        }
4584        $('#overlay_membership-directory-popup .insert_name').text(name);
4585        //prof pic
4586        var profPicURL = memberObject.get('profPicURL') || memberObject.get('imageURL'); //the or is if the "memberObject" is a boat because you clicked on a boat and are seeing boat details
4587        if( profPicURL ){
4588            $('#overlay_membership-directory-popup .profile-picture').text("");
4589            $('#overlay_membership-directory-popup .profile-picture').css('background-image', 'url('+profPicURL+')' );
4590            $('#overlay_membership-directory-popup .profile-background-inner').css('background-image', 'url('+profPicURL+')' );
4591        }else{
4592            $('#overlay_membership-directory-popup .profile-picture').css('background-image', '' );
4593            $('#overlay_membership-directory-popup .profile-background-inner').css('background-image', '');
4594            if( name.length >= 1 ){
4595                var initial = name.substr(0,1).toLowerCase();
4596                $('#overlay_membership-directory-popup .profile-picture').text( initial );
4597            }
4598        }
4599        //plug in data
4600        $('#overlay_membership-directory-popup .inline-value').each(function(){
4601            var key = $(this).data('db-key');
4602            if( key ){
4603                var value = sanitize_string(memberObject.get(key));
4604                if( value ){
4605                    if( key.toLowerCase().indexOf('email') >= 0 ){
4606                        //it's an email
4607                        $(this).html( '<a href="mailto:'+ value +'">'+ value +'</a>' );
4608                        $(this).removeClass( 'hidden' );
4609                    }else if( key.toLowerCase().includes('mobile') || key.toLowerCase().includes('phone') ){
4610                        //it's a mobile number
4611                        $(this).html( '<a href="tel:'+ returnCleanMobileNumber( value ) +'">'+ formatMobileNumber( value ) +'</a>' );
4612                        $(this).removeClass( 'hidden' );
4613                    }else if( $(this).data('prepend') ){
4614                        $(this).text( $(this).data('prepend') + " " + value );
4615                        $(this).removeClass( 'hidden' );
4616                    }else{
4617                        //not an email, mobile number, or special boat field
4618                        $(this).text( value );
4619                        $(this).removeClass( 'hidden' );
4620                    }
4621                }else{
4622                    $(this).text( "" ).addClass('hidden');
4623                    $(this).data( 'date', null );
4624                }
4625            }
4626        });
4627        //custom fields
4628        $('.section-wrap_custom_fields').html('');
4629        // var custom_fields = $('.membership-directory-wrap').data('custom_fields');
4630        var custom_fields = store.components.membership_directory.custom_fields;
4631        if( custom_fields ){
4632            //plug them in
4633            for (var i = 0; i <= custom_fields.length - 1; i++) {
4634                var response = returnResponseForCustomField( memberObject, custom_fields[i].id );
4635                if( response ){
4636                    $('.section-wrap_custom_fields').append('<h4 class="smallBlockLetters leftText smallMarginTop">'+ custom_fields[i].get('name') +'</h4>');
4637                    $('.section-wrap_custom_fields').append('<p class="inline-value dark tinyPaddingTop">'+ response +'</p>');
4638                }
4639            }
4640        }
4641        //family
4642        $('.section-wrap_family').find('.inline-value').remove();
4643        var membershipObject = memberObject.get('membershipObject');
4644        var membersArray = membershipObject.get('membersArray');
4645        var family_members = [];
4646        if( membersArray ){
4647            for (var i = 0; i <= membersArray.length - 1; i++) {
4648                if( membersArray[i].id != memberObject.id ){
4649                    var this_member = returnObjectByID( membersArray[i].id, store.components.membership_directory.allMembers );
4650                    if( this_member ){
4651                        family_members.push( this_member );
4652                    }
4653                }
4654            }
4655        }
4656        for (var i = 0; i <= family_members.length - 1; i++) {
4657            var this_name = '';
4658            if( family_members[i].get('firstName') ){
4659                this_name = family_members[i].get('firstName');
4660                if( family_members[i].get('lastName') ){
4661                    this_name += ' ';
4662                    this_name += family_members[i].get('lastName');
4663                }
4664            }
4665            if( this_name.length >= 1 ){
4666                $('#overlay_membership-directory-popup .section-wrap_family').append('<p class="flexGrowOne inline-value tinyPaddingTop"><a class="tappedFamilyMember" data-member-id="'+ family_members[i].id +'">'+ this_name +' ></a></p>');
4667            }
4668        }
4669        //boats
4670        $('.section-wrap_boats').find('.inline-value').remove();
4671        var boats_map = store.components.membership_directory.boats_map;
4672        if( boats_map ){
4673            var these_boats = boats_map[ membershipObject.id ];
4674            if( these_boats ){
4675                for (var i = 0; i <= these_boats.length - 1; i++) {
4676                    var this_name = returnNameStringForBoat( these_boats[i] );
4677                    if( this_name.length >= 1 ){
4678                        $('#overlay_membership-directory-popup .section-wrap_boats').append('<p class="flexGrowOne inline-value tinyPaddingTop"><a class="tappedBoat" data-boat-id="'+ these_boats[i].id +'" data-membership-id="'+ membershipObject.id +'">'+ this_name +' ></a></p>');
4679                    }
4680                }
4681            }
4682        }
4683        //continue
4684        $('#overlay_membership-directory-popup .section-wrap').each(function(){
4685            if( $(this).find('.inline-value').not( $('.hidden') ).length >= 1 ){
4686                $(this).removeClass('hidden');
4687            }else{
4688                $(this).addClass('hidden');
4689            }
4690        });
4691        //fade it in
4692        fadeIn('#overlay_membership-directory-popup');
4693    }
4694
4695    function openDirectoryProfileImage( el ){
4696        var bgImage = $(el).css('background-image');
4697        if( !bgImage || bgImage === 'none' ) return;
4698        var url = bgImage.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
4699        if( !url ) return;
4700        $('#directory-image-lightbox .directory-image-lightbox-img').attr('src', url);
4701        $('#directory-image-lightbox').addClass('active');
4702    }
4703
4704    function closeDirectoryProfileImage(){
4705        $('#directory-image-lightbox').removeClass('active');
4706        $('#directory-image-lightbox .directory-image-lightbox-img').attr('src', '');
4707    }
4708
4709    function returnNameStringForBoat( boatObject ){
4710        var this_name = '';
4711        if( boatObject.get('boatName') ){
4712            this_name += boatObject.get('boatName');
4713        }
4714        if( boatObject.get('sailNumber') ){
4715            if( this_name.length >= 1 ){
4716                this_name += " - ";
4717            }
4718            this_name += boatObject.get('sailNumber');
4719        }
4720        if( boatObject.get('class') ){
4721            if( this_name.length >= 1 ){
4722                this_name += " - ";
4723            }
4724            this_name += boatObject.get('class');
4725        }
4726        if( boatObject.get('hullNumber') ){
4727            if( this_name.length >= 1 ){
4728                this_name += " - ";
4729            }
4730            this_name += boatObject.get('hullNumber');
4731        }
4732        return this_name;
4733    }
4734
4735    function returnResponseForCustomField( member, custom_field_id ){
4736        //find the response in custom_field_responses where .get('member') == ,ember and .get('custom_field').id matches custom_field_id
4737        var all_responses = store.components.membership_directory.custom_field_responses;
4738        if(!member || !custom_field_id){
4739            return null;
4740        }
4741        var matching_response = all_responses?.find(resp => {
4742            // Match member (Parse.Object, so check .id)
4743            const member_matches = resp.get('member')?.id === member.id;
4744
4745            // Match custom field (resp.get('custom_field') is also a Parse.Object)
4746            const field_matches = resp.get('custom_field')?.id === custom_field_id;
4747
4748            return member_matches && field_matches;
4749        });
4750        if(matching_response){
4751            if( matching_response.get('field_type') == 'text' || matching_response.get('field_type') == 'textarea' || matching_response.get('field_type') == 'number' ){
4752                return matching_response.get('response')
4753            }else if (matching_response.get('field_type') == 'select'){                           
4754                var selected_options = matching_response.get('selected_options');              
4755                var displayString = selected_options.map(opt => opt.option_name).join(', ');     
4756                return displayString;           
4757            }else if( matching_response.get('field_type') == 'radio' ){   
4758                return matching_response.get('selected_options').map(option => option.option_name)                       
4759            }else if( matching_response.get('field_type') == 'file_upload' ){
4760                return matching_response.get('field_url') || null
4761            }  
4762        }
4763
4764        return null;
4765    }
4766    
4767    /* ----- STORE ----- */
4768    store.components["membership_directory"] = {
4769        clubId: null,
4770        clubIds: ['8pB9LjQKoE', '2oWU1chg3P'], //for mantoloking and cambridge yc
4771        allMembers: [],
4772        member_tags: [],
4773        json: [],
4774        boats_map: [],
4775        custom_fields: [],
4776        custom_field_responses: [],
4777        documents: [], //for scira
4778        selectedTag: 'all',
4779        // configurable_columns: [],
4780        count: 0,
4781        search: '',
4782        loading: true,
4783        sortObject: {
4784            order: 'ascending',
4785            key: 'lastName'
4786        },
4787    };
4788
4789    /* ----- COMPONENT ----- */
4790    app_content.component("membership-directory-component", {
4791        template: "#template-membership-directory",
4792        data() {
4793            return store.components['membership_directory']
4794        },
4795        props: {
4796            formatting: Object,
4797            client_id: String,
4798            class_string: String,
4799            style_string: String,
4800            element_style_string: String,
4801            component_type: String,
4802            //admin
4803            last_update: Number,
4804        },
4805        mounted() {
4806            this.clubId = $('body').data('club-id'); 
4807            this.load_membership_directory();
4808            // this.fillColumnClasses();
4809        },
4810        methods: {
4811            sortByFirstName_ascending( a, b ){
4812                var aFirstName = '';
4813                if( a.get('firstName') ){
4814                    aFirstName += a.get('firstName').trim().toLowerCase();
4815                }
4816                var bFirstName = '';
4817                if( b.get('firstName') ){
4818                    bFirstName += b.get('firstName').trim().toLowerCase();
4819                }
4820                if(aFirstName < bFirstName) { return -1; }
4821                if(aFirstName > bFirstName) { return 1; }
4822                //they had the same name
4823                return 0;
4824            },
4825            sortByFirstName_descending( a, b ){
4826                var aFirstName = '';
4827                if( a.get('firstName') ){
4828                    aFirstName += a.get('firstName').trim().toLowerCase();
4829                }
4830                var bFirstName = '';
4831                if( b.get('firstName') ){
4832                    bFirstName += b.get('firstName').trim().toLowerCase();
4833                }
4834                if(aFirstName > bFirstName) { return -1; }
4835                if(aFirstName < bFirstName) { return 1; }
4836                return 0;
4837            },
4838            sortByLastName_ascending( a, b ){
4839                var aLastName = '';
4840                if( a.get('lastName') ){
4841                    aLastName += a.get('lastName').trim().toLowerCase();
4842                }
4843                var bLastName = '';
4844                if( b.get('lastName') ){
4845                    bLastName += b.get('lastName').trim().toLowerCase();
4846                }
4847                if(aLastName < bLastName) { return -1; }
4848                if(aLastName > bLastName) { return 1; }
4849                return 0;
4850            },
4851            sortByLastName_descending( a, b ){
4852                var aLastName = '';
4853                if( a.get('lastName') ){
4854                    aLastName += a.get('lastName').trim().toLowerCase();
4855                }
4856                var bLastName = '';
4857                if( b.get('lastName') ){
4858                    bLastName += b.get('lastName').trim().toLowerCase();
4859                }
4860                if(aLastName > bLastName) { return -1; }
4861                if(aLastName < bLastName) { return 1; }
4862                return 0;
4863            }, 
4864            sortByMemberNumber_ascending( a, b ){
4865                var aMemberNumber = '';
4866                if( a.get('membershipObject') && a.get('membershipObject').get('memberNumber') ){
4867                    aMemberNumber += a.get('membershipObject').get('memberNumber').trim().toLowerCase();
4868                }
4869                var bMemberNumber = '';
4870                if( b.get('membershipObject') && b.get('membershipObject').get('memberNumber') ){
4871                    bMemberNumber += b.get('membershipObject').get('memberNumber').trim().toLowerCase();
4872                }
4873                if(aMemberNumber < bMemberNumber) { return -1; }
4874                if(aMemberNumber > bMemberNumber) { return 1; }
4875                return 0;
4876            },
4877            sortByMemberNumber_descending( a, b ){
4878                var aMemberNumber = '';
4879                if( a.get('membershipObject') && a.get('membershipObject').get('memberNumber') ){
4880                    aMemberNumber += a.get('membershipObject').get('memberNumber').trim().toLowerCase();
4881                }
4882                var bMemberNumber = '';
4883                if( b.get('membershipObject') && b.get('membershipObject').get('memberNumber') ){
4884                    bMemberNumber += b.get('membershipObject').get('memberNumber').trim().toLowerCase();
4885                }
4886                if(aMemberNumber > bMemberNumber) { return -1; }
4887                if(aMemberNumber < bMemberNumber) { return 1; }
4888                return 0;
4889            },
4890            sortByStartDate_ascending( a, b ){
4891                var aStartDate;
4892                if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){
4893                    aStartDate = a.get('membershipObject').get('startDate');
4894                }
4895                var bStartDate;
4896                if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){
4897                    bStartDate = b.get('membershipObject').get('startDate');
4898                }
4899                if(aStartDate < bStartDate) { return -1; }
4900                if(aStartDate > bStartDate) { return 1; }
4901                return 0;
4902            },
4903            sortByStartDate_descending( a, b ){
4904                var aStartDate;
4905                if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){
4906                    aStartDate = a.get('membershipObject').get('startDate');
4907                }
4908                var bStartDate;
4909                if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){
4910                    bStartDate = b.get('membershipObject').get('startDate');
4911                }
4912                if(aStartDate > bStartDate) { return -1; }
4913                if(aStartDate < bStartDate) { return 1; }
4914                return 0;
4915            },
4916            sortByRenewed_ascending( a, b ){
4917                var aRenewed;
4918                if( a.get('membershipObject') && a.get('membershipObject').get('renewed') ){
4919                    aRenewed = a.get('membershipObject').get('renewed');
4920                }else if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){
4921                    aRenewed = a.get('membershipObject').get('startDate');
4922                }
4923                var bRenewed;
4924                if( b.get('membershipObject') && b.get('membershipObject').get('renewed') ){
4925                    bRenewed = b.get('membershipObject').get('renewed');
4926                }else if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){
4927                    bRenewed = b.get('membershipObject').get('startDate');
4928                }
4929                if(aRenewed < bRenewed) { return -1; }
4930                if(aRenewed > bRenewed) { return 1; }
4931                return 0;
4932            },
4933            sortByRenewed_descending( a, b ){
4934                var aRenewed;
4935                if( a.get('membershipObject') && a.get('membershipObject').get('renewed') ){
4936                    aRenewed = a.get('membershipObject').get('renewed');
4937                }else if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){
4938                    aRenewed = a.get('membershipObject').get('startDate');
4939                }
4940                var bRenewed;
4941                if( b.get('membershipObject') && b.get('membershipObject').get('renewed') ){
4942                    bRenewed = b.get('membershipObject').get('renewed');
4943                }else if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){
4944                    bRenewed = b.get('membershipObject').get('startDate');
4945                }
4946                if(aRenewed > bRenewed) { return -1; }
4947                if(aRenewed < bRenewed) { return 1; }
4948                return 0;
4949            },
4950            sortByEndDate_ascending( a, b ){
4951                var aEndDate;
4952                if( a.get('membershipObject') && a.get('membershipObject').get('endDate') ){
4953                    aEndDate = a.get('membershipObject').get('endDate');
4954                }
4955                var bEndDate;
4956                if( b.get('membershipObject') && b.get('membershipObject').get('endDate') ){
4957                    bEndDate = b.get('membershipObject').get('endDate');
4958                }
4959                if(aEndDate < bEndDate) { return -1; }
4960                if(aEndDate > bEndDate) { return 1; }
4961                return 0;
4962            },
4963            sortByEndDate_descending( a, b ){
4964                var aEndDate;
4965                if( a.get('membershipObject') && a.get('membershipObject').get('endDate') ){
4966                    aEndDate = a.get('membershipObject').get('endDate');
4967                }
4968                var bEndDate;
4969                if( b.get('membershipObject') && b.get('membershipObject').get('endDate') ){
4970                    bEndDate = b.get('membershipObject').get('endDate');
4971                }
4972                if(aEndDate > bEndDate) { return -1; }
4973                if(aEndDate < bEndDate) { return 1; }
4974                return 0;
4975            },
4976            load_membership_directory(){
4977                //update the ui
4978                store.components.membership_directory.loading = true;
4979                var promise = Parse.Promise.as();
4980                var thisPromise = promise.then(() => {
4981                    //get new data
4982                    var params = {};
4983                    params["client_id"] = $('.membership-directory-wrap').data('client-id');
4984                    params["request_url"] = window.location.href;
4985                    return Parse.Cloud.run('retrieve_data_for_component', params);
4986                }).then(result => {
4987                    //vue 
4988                    store.components.membership_directory.member_tags = result.member_tags;
4989                    store.components.membership_directory.loading = false;   
4990                    var json = [];
4991                    var retrieved_members = result.members;
4992                    for (var i = 0; i <= retrieved_members.length - 1; i++) {
4993                        var boats_string = "";
4994                        var number = "";
4995                        if( retrieved_members[i].get('membershipObject') ){
4996                            number = retrieved_members[i].get('membershipObject').get('memberNumber');
4997                        }
4998                        var boats_map = result.boats_map;
4999                        if( boats_map && retrieved_members[i].get('membershipObject') ){
5000                            var these_boats = boats_map[ retrieved_members[i].get('membershipObject').id ];
5001                            if( these_boats ){
5002                                for (var b = these_boats.length - 1; b >= 0; b--) {
5003                                    if( boats_string.length >= 1 ){
5004                                        boats_string += " ";
5005                                    }
5006                                    boats_string += returnNameStringForBoat( these_boats[b] );
5007                                }
5008                            }
5009                        }
5010                        retrieved_members[i].boats_string = boats_string
5011                        json.push({
5012                            "objectId": retrieved_members[i].id,
5013                            "name": retrieved_members[i].get('firstName') + " " + retrieved_members[i].get('lastName'),
5014                            "boats_string": boats_string,
5015                            "number": number
5016                        })
5017                    }
5018                    //refactor for search
5019                    store.components.membership_directory.allMembers = retrieved_members;
5020                    //vue 
5021                    store.components.membership_directory.json = json;
5022                    store.components.membership_directory.boats_map = result.boats_map;
5023                    store.components.membership_directory.custom_fields = result.custom_fields;
5024                    store.components.membership_directory.custom_field_responses = result.custom_field_responses;
5025                    store.components.membership_directory.documents = result.documents; //for scira
5026                }, function(error){
5027                    checkParseError( error );
5028                });
5029                return thisPromise;
5030            },
5031            /* ----- common methods ----- */
5032            clicked_sort( key ){
5033                if( this.sortObject.key == key ){
5034                    //already sorting by this one - flip it
5035                    if( this.sortObject.order == "descending" ){
5036                        this.sortObject.order = "ascending";
5037                    }else{
5038                        this.sortObject.order = "descending";
5039                    }
5040                }else{
5041                    //not sorting by this yet
5042                    this.sortObject.key = key;
5043                    this.sortObject.order = "descending";
5044                }
5045            },
5046            /* ----- table methods ----- */
5047            handleTagSelection( tag_id ){
5048                this.selectedTag = tag_id;
5049            },
5050            fillColumnClasses(){
5051                // let configureColumns = {
5052                //     'show_photo': {
5053                //         order: 1,
5054                //         headerClasses: 'column_photo lessPadding',
5055                //         headerStyle: 'padding-left:10px;0px',
5056                //         headerName: null,
5057                //     },
5058                //     'show_member_number': {
5059                //         order: 4,
5060                //         headerClasses: 'column_member_number sortable',
5061                //         'data-db-key': 'memberNumber',
5062                //         headerName: 'Number'
5063                //     },
5064                //     'show_email': {
5065                //         order: 5,
5066                //         headerClasses: 'column_email',
5067                //         headerName: 'Email'
5068                //     },
5069                //     'show_mobile': {
5070                //         order: 6,
5071                //         headerClasses: 'column_mobile',
5072                //         headerName: 'Mobile'
5073                //     },
5074                //     'show_phone_home': {
5075                //         order: 7,
5076                //         headerClasses: 'column_phone_home',
5077                //         headerName: 'Home Phone'
5078                //     },
5079                //     'show_member_tags': {
5080                //         order: 8,
5081                //         headerClasses: 'column_member_tags',
5082                //         headerName: 'Tags'
5083                //     },
5084                //     'show_ghin_number': {
5085                //         order: 9,
5086                //         headerClasses: 'column_ghin_number',
5087                //         headerName: 'GHIN Number'
5088                //     },
5089                //     'show_address': {
5090                //         order: 10,
5091                //         headerClasses: 'column_address',
5092                //         headerName: ['Street', 'City', 'State', 'Postal Code']
5093                //     },
5094                //     'show_address_secondary': {
5095                //         order: 11,
5096                //         headerClasses: 'column_address_secondary',
5097                //         headerName: ['Street (Secondary)', 'City (Secondary)', 'State (Secondary)', 'Postal Code (Secondary)']
5098                //     },
5099                //     'show_start_date': {
5100                //         order: 12,
5101                //         headerClasses: 'sortable column_start_date',
5102                //         'data-db-key': 'startDate',
5103                //         headerName: 'Start Date'
5104                //     },
5105                //     'show_end_date': {
5106                //         order: 13,
5107                //         headerClasses: 'sortable column_end_date',
5108                //         'data-db-key': 'endDate',
5109                //         headerName: 'End Date'
5110                //     },
5111                //     'show_renewed': {
5112                //         order: 14,
5113                //         headerClasses: 'sortable column_renewed',
5114                //         'data-db-key': 'renewed',
5115                //         headerName: 'Renewed'
5116                //     },
5117                //     'show_category': {
5118                //         order: 15,
5119                //         headerClasses: 'column_category',
5120                //         headerName: 'Category'
5121                //     },
5122                //     'show_plan': {
5123                //         order: 16,
5124                //         headerClasses: 'column_plan',
5125                //         headerName: 'Plan'
5126                //     },
5127                // }
5128                // let classStringArr = this.class_string.split(' ');
5129                // this.configurable_columns = classStringArr.reduce((acc, currentClass) => {
5130                //     if (configureColumns[currentClass] !== undefined){
5131                //         acc.push(configureColumns[currentClass])
5132                //     }
5133                //     return acc;
5134                // }, [
5135                //     {
5136                //         order: 2,
5137                //         headerClasses: 'sortable firstName',
5138                //         'data-db-key': 'firstName',
5139                //         required: true,  
5140                //         headerName: 'First Name'
5141                //     },
5142                //     {
5143                //         order: 3,
5144                //         headerClasses: 'sortable lastName',
5145                //         'data-db-key': 'lastName',
5146                //         required: true,  
5147                //         headerName: 'Last Name'
5148                //     },
5149                // ]).sort((a, b) => {
5150                //     return a.order - b.order;
5151                // })
5152            },
5153            check_archive_status( memberObject ){
5154                var membershipObject = memberObject.get('membershipObject');
5155                if(membershipObject && ! membershipObject.get('archived') && membershipObject.get('status') != 'canceled' ){
5156                    return true;
5157                }
5158                return false;
5159            },
5160            clicked_member_row( memberObject ){
5161                $('#overlay_membership-directory-popup').data('members', this.allMembers);
5162                $('#overlay_membership-directory-popup').data('boats_map', this.boats_map);
5163                prepThenDisplayMembershipDirectoryPopup( memberObject );
5164            },
5165            check_configurable_classes( className ){
5166                for (let i = 0; i < this.configurable_columns.length; i++){
5167                    if (this.configurable_columns[i].headerClasses.includes(className)){
5168                        return true;
5169                        break;
5170                    }
5171                }
5172                return false;
5173            },
5174            return_member_tags( memberObject ){
5175                var member_tags = memberObject.get('member_tags') || [];
5176                var tag_string = "";
5177                var tagsSituation = '';
5178                if( member_tags.length >= 1 ){
5179                    for (var i = 0; i <= member_tags.length - 1; i++) {
5180                        if( member_tags[i].className == "member_tags" ){
5181                            if( ! member_tags[i].get('archived') && ! member_tags[i].get('hidden') ){
5182                                if( tag_string.length >= 1 ){
5183                                    tag_string += ',';
5184                                }
5185                                tag_string += member_tags[i].id;
5186                                if( member_tags[i].get('hex_value') ){
5187                                    //custom color
5188                                    tagsSituation += '<p class="table-row-tag" style="background-color:#'+ member_tags[i].get('hex_value') +';">' + member_tags[i].get('name') + '</p>';
5189                                }else{
5190                                    //default color
5191                                    tagsSituation += '<p class="table-row-tag grey">' + member_tags[i].get('name') + '</p>';
5192                                }
5193                            }
5194                        }
5195                    }
5196                }
5197                if( tagsSituation.length >= 1 ){
5198                    return tagsSituation;
5199                }else{
5200                    return '<p class="incomplete">None</p>'
5201                }
5202            },
5203            format_utc_date( memberObject ){
5204                return formatUTCDate( memberObject, true, false, true )
5205            },
5206            return_no_results_msg(){
5207                if (this.clubIds.includes(this.clubId)){
5208                    return 'Search a member to get started.' //for mantoloking, cambridge yc, northport yc
5209                } else {
5210                    return 'No members found.';
5211                }
5212            },
5213            sortMembershipDirectory( members ){
5214                var sortObject = store.components.membership_directory.sortObject;
5215                if( ! sortObject ){
5216                    sortObject = {}
5217                }
5218                if( ! sortObject.key ){
5219                    sortObject.key = "startDate";
5220                }
5221                if( ! sortObject.order ){
5222                    sortObject.order = "descending"
5223                }
5224                if( sortObject.key == "firstName" ){
5225                    if( sortObject.order == "ascending" ){
5226                        members = members.sort( this.sortByFirstName_ascending );
5227                    }else{
5228                        members = members.sort( this.sortByFirstName_descending );
5229                    }
5230                }else if( sortObject.key == "lastName" ){
5231                    if( sortObject.order == "ascending" ){
5232                        members = members.sort( this.sortByLastName_ascending );
5233                    }else{
5234                        members = members.sort( this.sortByLastName_descending );
5235                    }
5236                }else if( sortObject.key == "memberNumber" ){
5237                    if( sortObject.order == "ascending" ){
5238                        members = members.sort( this.sortByMemberNumber_ascending );
5239                    }else{
5240                        members = members.sort( this.sortByMemberNumber_descending );
5241                    }
5242                }else if( sortObject.key == "startDate" ){
5243                    if( sortObject.order == "ascending" ){
5244                        members = members.sort( this.sortByStartDate_ascending );
5245                    }else{
5246                        members = members.sort( this.sortByStartDate_descending );
5247                    }
5248                }else if( sortObject.key == "endDate" ){
5249                    if( sortObject.order == "ascending" ){
5250                        members = members.sort( this.sortByEndDate_ascending );
5251                    }else{
5252                        members = members.sort( this.sortByEndDate_descending );
5253                    }
5254                }else if( sortObject.key == "renewed" ){
5255                    if( sortObject.order == "ascending" ){
5256                        members = members.sort( this.sortByRenewed_ascending );
5257                    }else{
5258                        members = members.sort( this.sortByRenewed_de
5258scending );
5259                    }
5260                }
5261                return members;
5262            }
5263        },
5264        computed: {
5265            members_json(){
5266                let json = [];
5267                this.allMembers.forEach(member => {
5268                    let this_search = '';
5269                    if( member.get('firstName') ){
5270                        this_search += ' ' + member.get('firstName').toLowerCase();
5271                    }
5272                    if( member.get('lastName') ){
5273                        this_search += ' ' + member.get('lastName').toLowerCase();
5274                    }
5275                    if( member['boats_string'] ){
5276                        this_search += ' ' + member['boats_string'].toLowerCase();
5277                    }
5278                    if( member?.get('membershipObject')?.get('memberNumber') ){
5279                        this_search += ' ' + member?.get('membershipObject')?.get('memberNumber').toLowerCase();
5280                    }
5281                    json.push({
5282                        "memberObject": member,
5283                        "search": this_search,
5284                    })
5285                });
5286                return json;
5287            },
5288            members_matching_search(){
5289                let val = this.search.toLowerCase();
5290                var keys = [ 'search' ];
5291                //sort them
5292                var these_matches = fuzzysort.go(val, this.members_json, {
5293                    threshold: -10000, // Don't return matches worse than this (higher is faster)
5294                    limit: 20, // Don't return more results than this (lower is faster)
5295                    allowTypo: false, // Allows a snigle transpoes (false is faster)
5296                    key: null, // For when targets are objects (see its example usage)
5297                    keys: keys, // For when targets are objects (see its example usage)
5298                    scoreFn: null, // For use with `keys` (see its example usage)
5299                });
5300                return these_matches.map(match => match.obj.memberObject);
5301            },
5302            filtered_members(){
5303                let val = this.search.toLowerCase();
5304                let filteredMembers;
5305                if (this.clubIds.includes(this.clubId)){
5306                    filteredMembers = []; //for mantoloking, cambridge yc, northport yc
5307                } else {
5308                    filteredMembers = this.allMembers;
5309                }
5310                // handle search results using all members
5311                if (this.search.length > 0){
5312                    //use the new method
5313                    filteredMembers = this.members_matching_search;
5314                }
5315                // handle member tag selection using filtered search results
5316                if (this.selectedTag !== 'all'){
5317                    filteredMembers = filteredMembers.filter(member => {
5318                        let memberTags = member.get('member_tags') || [];
5319                        for (let i = 0; i < memberTags.length; i++){
5320                            if (memberTags[i].id === this.selectedTag){
5321                                return member;
5322                                break;
5323                            }
5324                        }
5325                    })
5326                }
5327                if (this.clubIds.includes(this.clubId) && filteredMembers.length > 20){
5328                    return this.sortMembershipDirectory( filteredMembers.slice(0, 20) ); //for mantoloking, cambridge yc, northport yc
5329                } else {
5330                    return this.sortMembershipDirectory( filteredMembers );
5331                }
5332            },
5333            configurable_columns(){
5334                let configureColumns = {
5335                    'show_photo': {
5336                        order: 1,
5337                        headerClasses: 'column_photo lessPadding',
5338                        headerStyle: 'padding-left:10px;0px',
5339                        headerName: null,
5340                    },
5341                    'show_member_number': {
5342                        order: 4,
5343                        headerClasses: 'column_member_number sortable',
5344                        'data-db-key': 'memberNumber',
5345                        headerName: 'Number'
5346                    },
5347                    'show_email': {
5348                        order: 5,
5349                        headerClasses: 'column_email',
5350                        headerName: 'Email'
5351                    },
5352                    'show_mobile': {
5353                        order: 6,
5354                        headerClasses: 'column_mobile',
5355                        headerName: 'Mobile'
5356                    },
5357                    'show_phone_home': {
5358                        order: 7,
5359                        headerClasses: 'column_phone_home',
5360                        headerName: 'Home Phone'
5361                    },
5362                    'show_member_tags': {
5363                        order: 8,
5364                        headerClasses: 'column_member_tags',
5365                        headerName: 'Tags'
5366                    },
5367                    'show_ghin_number': {
5368                        order: 9,
5369                        headerClasses: 'column_ghin_number',
5370                        headerName: 'GHIN Number'
5371                    },
5372                    'show_address': {
5373                        order: 10,
5374                        headerClasses: 'column_address',
5375                        headerName: ['Street', 'City', 'State', 'Postal Code']
5376                    },
5377                    'show_address_secondary': {
5378                        order: 11,
5379                        headerClasses: 'column_address_secondary',
5380                        headerName: ['Street (Secondary)', 'City (Secondary)', 'State (Secondary)', 'Postal Code (Secondary)']
5381                    },
5382                    'show_start_date': {
5383                        order: 12,
5384                        headerClasses: 'sortable column_start_date',
5385                        'data-db-key': 'startDate',
5386                        headerName: 'Start Date'
5387                    },
5388                    'show_end_date': {
5389                        order: 13,
5390                        headerClasses: 'sortable column_end_date',
5391                        'data-db-key': 'endDate',
5392                        headerName: 'End Date'
5393                    },
5394                    'show_renewed': {
5395                        order: 14,
5396                        headerClasses: 'sortable column_renewed',
5397                        'data-db-key': 'renewed',
5398                        headerName: 'Renewed'
5399                    },
5400                    'show_category': {
5401                        order: 15,
5402                        headerClasses: 'column_category',
5403                        headerName: 'Category'
5404                    },
5405                    'show_plan': {
5406                        order: 16,
5407                        headerClasses: 'column_plan',
5408                        headerName: 'Plan'
5409                    },
5410                }
5411                let classStringArr = this.class_string.split(' ');
5412                return classStringArr.reduce((acc, currentClass) => {
5413                    if (configureColumns[currentClass] !== undefined){
5414                        acc.push(configureColumns[currentClass])
5415                    }
5416                    return acc;
5417                }, [
5418                    {
5419                        order: 2,
5420                        headerClasses: 'sortable firstName',
5421                        'data-db-key': 'firstName',
5422                        required: true,  
5423                        headerName: 'First Name'
5424                    },
5425                    {
5426                        order: 3,
5427                        headerClasses: 'sortable lastName',
5428                        'data-db-key': 'lastName',
5429                        required: true,  
5430                        headerName: 'Last Name'
5431                    },
5432                ]).sort((a, b) => {
5433                    return a.order - b.order;
5434                })
5435            },
5436        }
5437    })
5438</script>
5438    
5438<script type="text/html" id="template-event-list-card">
5439    <div
5440        @click.stop="tapped_event_list_event()"
5441        class="event-row overlay-event-row flexEventOne noBorderBottom"
5442        :class="'event-row_' + event.id"
5443        :data-event-id="event.id"
5444    > 
5445        <div
5446            class="event-list-card" 
5447            :class="{
5448                'small-card': formatting.card_size == 'small',
5449                'large-card': formatting.card_size == 'large',
5450                'box-shadow-light': formatting.box_shadow == 'light',
5451                'box-shadow-dark': formatting.box_shadow == 'dark',
5452            }"
5453            :style="{ 'border-radius': formatting.border_radius + 'px' || '0px' }"
5454        >
5455            <div 
5456                v-if="image_data.url"
5457                class="event-image" 
5458                :class="{
5459                    'small-card': formatting.card_size == 'small',
5460                    'large-card': formatting.card_size == 'large',
5461                    'use-contain': image_data.crop === 'contain'
5462                }"
5463                :style="{
5464                    '--event-image-url': `url(${image_data.url})`,
5465                    backgroundImage: 'var(--event-image-url)',
5466                    backgroundSize: image_data.crop || 'cover'
5467                }"
5468            >
5469            </div>
5470            <div 
5471                v-else 
5472                class="event-image"
5473                :class="{
5474                    'small-card': formatting.card_size == 'small',
5475                    'large-card': formatting.card_size == 'large'
5476                }"
5477            >
5478                <p class="no-background">{{ event.get('name').charAt(0).toUpperCase() }}</p>
5479            </div>
5480
5481            <div 
5482                class="card-content"
5483                :style="{ 'border-radius': formatting.border_radius + 'px' || '0px' }"
5484            >
5485                <p class="event-type">{{ event_type }}</p>
5486                <p
5487                    class="event-title"
5488                    :style="event_title_style"
5489                >{{ event_title }}</p>
5490                <div class="event-date-inline">
5491                    <i class="fa-regular fa-calendar" style="font-size: 0.8em;"></i>
5492                    <span>{{ short_month }} {{ days }}</span>
5493                </div>
5494                <div v-if="show_time" class="event-time">
5495                    <i class="fa-regular fa-clock" style="font-size: 0.8em;"></i>
5496                    <span>{{ event_time }}</span>
5497                </div>
5498                <button
5499                    @click.stop
5500                    v-if="button_label"
5501                    class="event-button exclude-from-custom-button-styles relative background"
5502                    v-html="button_label"
5503                ></button>
5504            </div>
5505        </div>
5506    </div>
5507</script>
5507
5508
5509<style>
5510    .event-list-card {
5511        width: 190px;
5512        overflow: hidden;
5513        background: white;
5514        font-family: 'Inter', sans-serif;
5515        display: flex;
5516        flex-direction: column;
5517        transition: transform 0.1s ease, background-color 0.15s ease;
5518    }
5519    .event-list-card.small-card {
5520        width: 190px;
5521    }
5522    .event-list-card.large-card {
5523        width: 230px;
5524    }
5525    .event-list-card.box-shadow-light {
5526        box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2);
5527    }
5528    .event-list-card.box-shadow-dark {
5529        box-shadow: 6px 6px 16px rgba(0, 0, 0, 0.2);
5530    }
5531    .event-list-card .event-image {
5532        display: flex;
5533        text-align: center;
5534        align-items: center;
5535        width: 100%;
5536        height: 170px;
5537        /* background-size now controlled via inline style / CSS class */
5538        background-repeat: no-repeat;
5539        background-position: center;
5540        background-color: #77818f;
5541        color: #fff;
5542        position: relative;
5543        overflow: hidden; /* hide blurred overflow */
5544    }
5545    /* When the image uses "contain", add a blurred cover background behind it */
5546    .event-list-card .event-image.use-contain::before {
5547        content: '';
5548        position: absolute;
5549        inset: 0;
5550        background-image: var(--event-image-url);
5551        background-size: cover;
5552        background-position: center;
5553        filter: blur(12px);
5554        transform: scale(1.1); /* avoid sharp edges from blur */
5555        z-index: 0;
5556        pointer-events: none;
5557    }
5558    /* Foreground, non-blurred "contain" image layer */
5559    .event-list-card .event-image.use-contain::after {
5560        content: '';
5561        position: absolute;
5562        inset: 0;
5563        background-image: var(--event-image-url);
5564        background-size: contain;
5565        background-position: center;
5566        background-repeat: no-repeat;
5567        z-index: 1;
5568        pointer-events: none;
5569    }
5570    /* Ensure foreground content (initials) sits above the image layers */
5571    .event-list-card .event-image .no-background {
5572        position: relative;
5573        z-index: 2;
5574    }
5575    .event-list-card .event-image.small-card{
5576        height: 170px;
5577    }
5578    .event-list-card .event-image.large-card{
5579        height: 220px;
5580    }
5581    .event-list-card .event-image .no-background {
5582        width: 100%;
5583        font-weight: 500;
5584        font-size: 5em;
5585    }
5586    .card-content {
5587        height: 140px;
5588        padding: 14px 12px;
5589        background-color: #f9f9f9;
5590        display: flex;
5591        flex-direction: column;
5592        transition: background-color 0.15s ease;
5593    }
5594    .event-row:hover .card-content {
5595        background-color: #f0f0f0;
5596    }
5597    .event-date-inline {
5598        display: flex;
5599        align-items: center;
5600        font-size: 14px;
5601        color: #000;
5602        margin-bottom: 2px;
5603    }
5604    .event-date-inline i {
5605        margin-right: 6px;
5606    }
5607    .event-list-card .event-type {
5608        font-size: 12px;
5609        text-transform: uppercase;
5610        color: #000;
5611        margin-bottom: 2px;
5612    }
5613    .event-list-card .event-title {
5614        font-size: 18px;
5615        text-transform: uppercase;
5616        margin-bottom: 8px;
5617        color: #000;
5618        font-family: serif;
5619        letter-spacing: 0.2px;
5620    }
5621    .event-list-card .event-time {
5622        display: flex;
5623        align-items: center;
5624        font-size: 14px;
5625        color: #000;
5626        margin-bottom: 12px;
5627    }
5628    .event-time i {
5629        margin-right: 6px;
5630    }
5631    .event-list-card .event-button {
5632        width: 100%;
5633        display: flex;
5634        align-items: center;
5635        justify-content: center;
5636        position: relative;
5637        z-index: 2;
5638        margin-top: auto;
5639        letter-spacing: 1px;
5640        line-height: 16px;
5641        cursor: pointer;
5642        font-size: 14px;
5643        font-weight: normal;
5644        padding: 6px 12px;
5645        text-transform: uppercase;
5646        transition: all 0.2s ease;
5647        border: 1.5px solid #000;
5648        border-radius: 2px;
5649    }
5650    .event-list-card .event-button:hover{
5651        background-color: #000;
5652        color: white;
5653    }
5654    .res-callout{
5655        display: flex;
5656        align-items: center;
5657        white-space: nowrap;
5658        height: fit-content;
5659        width: fit-content;
5660        padding: 3.5px;
5661        background-color: #fff;
5662        border: 1px solid #e4ebf1;
5663        border-radius: 5px;
5664        font-size: 10px;
5665        text-transform: uppercase;
5666        color: #5c6d7a;
5667    }
5668</style>
5669
5670<script>
5671    
5672    var config = defineComponent({
5673        template: "#template-event-list-card",
5674        data() {
5675            return {}
5676        },
5677        props: {
5678            event_data: {
5679                type: Object,
5680                required: true,
5681            },
5682            event_props: {
5683                type: Object,
5684                required: true,
5685            },
5686            formatting: Object,
5687        },
5688        methods: {
5689            tapped_event_list_event(){
5690                this.$emit('tapped-event-list-event', this.event_data);
5691            },
5692        },
5693        computed: {
5694            short_month(){
5695                if( this.start_date ){
5696                    return format_utc_date_v2({
5697                        "utcDate": this.start_date,
5698                        "timezone": store.timezone,
5699                        "formatString": "MMM"
5700                    });
5701                }
5702                return null;
5703            },
5704            days(){
5705                let string = '';
5706                if( this.start_date ){
5707                    let start_components = return_date_components_v2({
5708                        "date": this.start_date,
5709                        "timezone": store.timezone
5710                    });
5711                    string += start_components.date;
5712                    if( this.end_date && ! this.event.get('all_day') ){
5713                        let end_components = return_date_components_v2({
5714                            "date": this.end_date,
5715                            "timezone": store.timezone
5716                        });
5717                        if( start_components.date !== end_components.date ){
5718                            string += ' - ' + end_components.date;
5719                        }
5720                    }
5721                }
5722                return string; 
5723            },
5724            show_register_button(){
5725                return this.event_props.show_register_button;
5726            },
5727            show_time(){
5728                return this.event_props.show_time;
5729            },
5730            image_data(){
5731                return this.event_props.image_data;
5732            },
5733            start_date(){
5734                return this.event_props.start_date;
5735            },
5736            end_date(){
5737                return this.event_props.end_date;
5738            },
5739            event_title() {
5740                return this.event.get('name') || '';
5741            },
5742            event_title_style() {
5743                let style = {};
5744                if( this.formatting.event_title_overflow === 'wrap' ){
5745                    style['white-space'] = 'break-spaces';
5746                }
5747                if( this.formatting.event_title_font_size ){
5748                    style['font-size'] = this.formatting.event_title_font_size + 'px';
5749                }
5750                return style;
5751            },
5752            event_type(){
5753                if( this.event.className == 'camps' ){
5754                    return 'camp';
5755                }else if( this.event.className == 'regattas' ){
5756                    return 'regatta';
5757                }else{
5758                    return 'event';
5759                }
5760            },
5761            button_label(){
5762                let eventObject = this.event;
5763                let href_base = '';
5764                let clubObject = store.clubObject;
5765                let web_settings = null;
5766                if( clubObject ){
5767                    web_settings = clubObject.get('web_settings');
5768                }
5769                if( web_settings ){
5770                    if( web_settings.get('event_links') == "use_clubspot_regatta_links" && ( eventObject.className == "regattas" || eventObject.className == "subevents" ) ){
5771                        //force clubspot urls for regattas
5772                        href_base += 'https://theclubspot.com';
5773                    }   
5774                }
5775                var content;
5776                var show_register_button = this.show_register_button;
5777                if( eventObject.className == "regattas" || eventObject.className == "subevents" ){
5778                    if( eventObject.className == "subevents" ){
5779                        eventObject = eventObject.get('regattaObject');
5780                    }
5781                    //check for external events
5782                    if( eventObject.get('regatta_external') ){
5783                        //it's an external regatta - only render the external button if a URL is set; never fall back to register/results
5784                        if( eventObject.get('external_regatta_url') ){
5785                            content = 'View event<a class="absoluteA" target="_blank" href="'+ eventObject.get('external_regatta_url') +'"></a>';
5786                        }
5787                    }else{
5788                        //handle button status
5789                        if( show_register_button ){
5790                            //register
5791                            content = 'Register<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'"></a>';
5792                        }else{
5793                            //results
5794                            content = 'View Results<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'/results"></a>';
5795                        }
5796                    }
5797                }else if( eventObject.className == "events" ){
5798                    var registration_link;
5799                    var details_link;
5800                    if( eventObject.get('calendarOnly') ){
5801                        if( eventObject.get('external_event') ){
5802                            details_link = this.event_props.resolved_external_url || eventObject.get('external_event_url');
5803                        }
5804                    }else{
5805                        if( eventObject.get('events_v2') ){
5806                            registration_link = '/event/' + eventObject.id;
5807                        }else if( eventObject.get('external_event') && eventObject.get('external_register_link') ){
5808                            registration_link = eventObject.get('external_register_link');
5809                        }else if( eventObject.get('event_product') ){
5810                            if( eventObject.get('event_product').get('availability') && eventObject.get('event_product').get('availability').startDate ){
5811                                registration_link = "/reserve/" + eventObject.get('event_product').id + "?date=" + eventObject.get('event_product').get('availability').startDate;
5812                            }
5813                        }
5814                    }
5815                    if( registration_link && show_register_button ){
5816                        content = 'RSVP<a class="absoluteA" href="'+href_base+''+registration_link+'"></a>';
5817                    } else if( details_link && details_link.length > 0 ){
5818                        let button_label = eventObject.get('external_event_url_type') === 'dining' ? 'Reserve' : 'View details';
5819                        content = button_label + '<a class="absoluteA" target="_blank" href="'+ details_link +'"></a>';
5820                    }
5821                }else if( eventObject.className == "camps" ){
5822                    //handle button status
5823                    if( show_register_button ){
5824                        //register
5825                        content = 'Register<a href="'+href_base+'/register/camp/'+ eventObject.id +'/class" class="absoluteA"></a>';
5826                    }
5827                }
5828                return content;
5829            },
5830            event(){
5831                return this.event_data.event;
5832            },
5833            clubObject(){
5834                return store.clubObject;
5835            },
5836            event_time() {
5837                if( this.show_time ){
5838                    return format_utc_date_v2({
5839                        "utcDate": this.start_date,
5840                        "timezone": store.timezone,
5841                        "formatString": "h:mm A"
5842                    });
5843                }
5844                return "";
5845            },
5846        }
5847    })
5848    //define the component
5849    app_overlays.component("event-list-card-component", config);
5850    app_content.component("event-list-card-component", config);
5851
5852</script>
5852    
5852<script type="text/html" id="template-event-list-event">
5853    <div 
5854        @click.stop="tapped_event_list_event()"
5855        class="event-row overlay-event-row flexEventOne"
5856        :class="'event-row_' + event.id"
5857        :data-event-id="event.id"
5858    > 
5859        <!-- date and image wrap -->
5860        <div class="cs-signups-row-left flexNoWrapCenterAlign">
5861            <div class="cs-signups-date-wrap relative" style="width: 55px;">
5862                <div class="centeredBlock relative">
5863                    <div>
5864                        <p class="cs-signups-date-month">{{ short_month }}</p>
5865                        <p class="cs-signups-date-day">{{ days }}</p>
5866                    </div>
5867                </div>
5868            </div>
5869            <div 
5870                v-if="image_data.url" 
5871                class="cs-signups-row-image flexWrap" 
5872                :class="{ 'background-contain' : image_data.crop === 'contain', 'background-cover' : image_data.crop === 'cover'}"
5873                :style="{ backgroundImage: 'url(' + image_data.url + ')' }"
5874            >
5875                <div v-if="is_different_year" class="header-callout">{{ full_year }}</div>
5876            </div>
5877            <div 
5878                v-else 
5879                class="cs-signups-row-image flexWrap" 
5880            >
5881                <div v-if="is_different_year" class="header-callout">{{ full_year }}</div>
5882                {{ event.get('name').charAt(0).toUpperCase() }}
5883            </div>
5884        </div>
5885        <!-- event info, links, and host wrap -->
5886        <div class="cs-signups-row-inner flexWrapLeftAlign ellipsis align-items-center">
5887            <!-- event info -->
5888            <div class="cs-signups-name-wrap ellipsis textLeft">
5889                <span class="flexWrap" style="display: flex; align-items: center; justify-content: flex-start; margin-bottom: 5px;">
5890                    <div class="cs-signups-name-0 textLeft" style="flex-grow: 0; margin-right: 8px;">{{ event.get('name') }}</div>
5891                </span>
5892                <div class="event-date-wrap-v2">{{ niceDate }}</div>
5893                <div class="event-date-wrap-v2 tiniestPaddingTop">
5894                    <p>{{ clubObject.get('name') }}</p>
5895                    <p>{{ return_markup( event, 'event-host-2' )}}</p>
5896                </div>
5897            </div>
5898            <!-- event links -->
5899            <div @click.stop class="cs-signups-links-wrap flexGrowOne">
5900                <div class="cs-signups-links-wrap-inner textLeft">
5901                    <div class="event-nav-1" v-html="return_markup( event, 'links' )"></div>
5902                    <div class="event-nav-2" v-html="return_markup( event, 'button' )"></div>
5903                </div>
5904            </div>
5905        </div>
5906    </div>
5907</script>
5907
5908
5909<script>
5910    
5911    var config = defineComponent({
5912        template: "#template-event-list-event",
5913        data() {
5914            return {}
5915        },
5916        props: {
5917            event_data: {
5918                type: Object,
5919                required: true,
5920            },
5921            event_props: {
5922                type: Object,
5923                required: true,
5924            },
5925        },
5926        methods: {
5927            tapped_event_list_event(){
5928                this.$emit('tapped-event-list-event', this.event_data);
5929            },
5930            return_markup( eventObject, type ){
5931                var href_base = '';
5932                var clubObject = this.clubObject;
5933                var web_settings = clubObject.get('web_settings');
5934                if( web_settings ){
5935                    if( web_settings.get('event_links') == "use_clubspot_regatta_links" && ( eventObject.className == "regattas" || eventObject.className == "subevents" ) ){
5936                        //force clubspot urls for regattas
5937                        href_base += 'https://theclubspot.com';
5938                    }   
5939                }
5940                // returns here
5941                if ( type === 'event-host-2' ){
5942                    var club_string = '';
5943                    var location_params = {
5944                        eventObject: eventObject,
5945                        hideCountry: true, 
5946                        hideZip: true,
5947                        hideStreet: true,
5948                    }
5949                    var club_location_params = {
5950                        eventObject: clubObject,
5951                        hideCountry: true, 
5952                        hideZip: true,
5953                        hideStreet: true,
5954                    }
5955                    if(formatLocation(location_params) ){
5956                        if( club_string.length >= 1 ){
5957                            club_string += ', ';
5958                        }
5959                        club_string += formatLocation(location_params);
5960                    }else if( clubObject && formatLocation(club_location_params) ){
5961                        if( club_string.length >= 1 ){
5962                            club_string += ', ';
5963                        }
5964                        club_string += formatLocation(club_location_params);
5965                    }
5966                    return club_string;
5967                }  else if ( type === 'links' ){
5968                    var content;
5969                    if( eventObject.className == "regattas" || eventObject.className == "subevents" ){
5970                        //check for external events
5971                        if( eventObject.get('regatta_external') ){
5972                            //it's an external regatta - only render the external link if a URL is set; never fall back to internal links
5973                            if( eventObject.get('external_regatta_url') ){
5974                                content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ eventObject.get('external_regatta_url') +'">Event page</a></p>';
5975                            }
5976                        }else{
5977                            if( eventObject.className == "subevents" ){
5978                                eventObject = eventObject.get('regattaObject');
5979                            }
5980                            //event page
5981                            content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'">Event page</a></p>';
5982                            //entries
5983                            content += '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'/#entry-list">Entries</a></p>';
5984                            //race docs
5985                            content += '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'/#notice-board">Notice board</a></p>';
5986                        }
5987                    }else if( eventObject.className == "events" ){
5988                        //check for external events
5989                        if( ! eventObject.get('calendarOnly') ){
5990                            if( eventObject.get('external_event') && eventObject.get('external_event_url') ){
5991                                //it's an external event
5992                                content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ eventObject.get('external_event_url') +'">Event page</a></p>';
5993                            }else{
5994                                //event page
5995                                content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/event/'+ eventObject.id +'">Event page</a></p>';
5996                            }
5997                        }else{
5998                            //calendar only event: use resolved URL per instance (recurring dining) or stored URL
5999                            let external_url = this.event_props.resolved_external_url || eventObject.get('external_event_url');
6000                            if(
6001                                eventObject.get('external_event')
6002                                && eventObject.get('external_event_url_type') !== 'dining'
6003                                && external_url
6004                            ){
6005                                content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ external_url +'">View details</a></p>';
6006                            }
6007                        }
6008                    }else if( eventObject.className == "camps" ){
6009                        //it's a camp
6010                        content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/camp-registrations/'+ eventObject.id +'">Entry list</a></p>';
6011                    }
6012                    return content;
6013                } else if ( type === 'button' ){
6014                    var content;
6015                    var show_register_button = this.show_register_button;
6016                    if( eventObject.className == "regattas" || eventObject.className == "subevents" ){
6017                        if( eventObject.className == "subevents" ){
6018                            eventObject = eventObject.get('regattaObject');
6019                        }
6020                        //check for external events
6021                        if( eventObject.get('regatta_external') ){
6022                            //it's an external regatta - only render the external button if a URL is set; never fall back to register/results
6023                            if( eventObject.get('external_regatta_url') ){
6024                                content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">View event<a class="absoluteA" target="_blank" href="'+ eventObject.get('external_regatta_url') +'"></a></button>';
6025                            }
6026                        }else{
6027                            //handle button status
6028                            if( show_register_button ){
6029                                //register
6030                                content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">Register<a class="absoluteA" href="'+href_base+'/register/regatta/'+ eventObject.id +'/class"></a></button>';
6031                            }else{
6032                                //results
6033                                content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">View Results<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'/results"></a></button>';
6034                            }
6035                        }
6036                    }else if( eventObject.className == "events" ){
6037                        var registration_link;
6038                        var details_link;
6039                        if( eventObject.get('calendarOnly') ){
6040                            if( eventObject.get('external_event') ){
6041                                details_link = this.event_props.resolved_external_url || eventObject.get('external_event_url');
6042                            }
6043                        }else{
6044                            if( eventObject.get('events_v2') ){
6045                                registration_link = '/event/' + eventObject.id;
6046                            }else if( eventObject.get('external_event') && eventObject.get('external_register_link') ){
6047                                registration_link = eventObject.get('external_register_link');
6048                            }
6049                        }
6050                        if( registration_link && show_register_button ){
6051                            content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">RSVP<a class="absoluteA" href="'+href_base+''+registration_link+'"></a></button>';
6052                        }else if( details_link ){
6053                            if( eventObject.get('external_event_url_type') === 'dining' ){
6054                                content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">Reserve<a class="absoluteA" target="_blank" href="'+ details_link +'"></a></button>';
6055                            }else{
6056                                content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">View details<a class="absoluteA" target="_blank" href="'+ details_link +'"></a></button>';
6057                            }
6058                        } 
6059                    }else if( eventObject.className == "camps" ){
6060                        //handle button status
6061                        if( show_register_button ){
6062                            //register
6063                            content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">Register<a href="'+href_base+'/register/camp/'+ eventObject.id +'/class" class="absoluteA"></a></button>';
6064                        }
6065                    }
6066                    return content;
6067                }
6068            },
6069        },
6070        computed: {
6071            show_register_button(){
6072                return this.event_props.show_register_button;
6073            },
6074            show_time(){
6075                return this.event_props.show_time;
6076            },
6077            image_data(){
6078                return this.event_props.image_data;
6079            },
6080            start_date(){
6081                return this.event_props.start_date;
6082            },
6083            end_date(){
6084                return this.event_props.end_date;
6085            },
6086            niceDate(){
6087                if( this.start_date ){
6088                    if( this.show_time ){
6089                        //show the time
6090                        return format_utc_date_v2({
6091                            "utcDate": this.start_date,
6092                            "timezone": store.timezone,
6093                            "formatString": "MMM D, YYYY, h:mm A"
6094                        });
6095                    }else{
6096                        //show the date only
6097                        return format_utc_date_v2({
6098                            "utcDate": this.start_date,
6099                            "timezone": store.timezone,
6100                            "formatString": "MMM D, YYYY"
6101                        });
6102                    }
6103                }
6104                return null;
6105            },
6106            full_year(){
6107                if( this.start_date ){
6108                    return format_utc_date_v2({
6109                        "utcDate": this.start_date,
6110                        "timezone": store.timezone,
6111                        "formatString": "YYYY"
6112                    });
6113                }
6114                return null;
6115            },
6116            days(){
6117                let string = '';
6118                if( this.start_date ){
6119                    let start_components = return_date_components_v2({
6120                        "date": this.start_date,
6121                        "timezone": store.timezone
6122                    });
6123                    string += start_components.date;
6124                    if( this.end_date && ! this.event.get('all_day') ){
6125                        let end_components = return_date_components_v2({
6126                            "date": this.end_date,
6127                            "timezone": store.timezone
6128                        });
6129                        if( start_components.date !== end_components.date ){
6130                            string += ' - ' + end_components.date;
6131                        }
6132                    }
6133                }
6134                return string; 
6135            },
6136            short_month(){
6137                if( this.start_date ){
6138                    return format_utc_date_v2({
6139                        "utcDate": this.start_date,
6140                        "timezone": store.timezone,
6141                        "formatString": "MMM"
6142                    });
6143                }
6144                return null;
6145            },
6146            is_different_year(){
6147                if( this.start_date ){
6148                    let start_components = return_date_components_v2({
6149                        "date": this.start_date,
6150                        "timezone": store.timezone
6151                    });
6152                    let now_components = return_date_components_v2({
6153                        "date": new Date(),
6154                        "timezone": store.timezone
6155                    });
6156                    return start_components.year !== now_components.year;
6157                }
6158                return false;
6159            },
6160            event(){
6161                return this.event_data.event;
6162            },
6163            clubObject(){
6164                return store.clubObject;
6165            },
6166            event_time() {
6167                if( this.show_time ){
6168                    return format_utc_date_v2({
6169                        "utcDate": this.start_date,
6170                        "timezone": store.timezone,
6171                        "formatString": "h:mm A"
6172                    });
6173                }
6174                return "";
6175            },
6176        },
6177    })
6178    //define the component
6179    app_overlays.component("event-list-event-component", config);
6180    app_content.component("event-list-event-component", config);
6181
6182</script>
6182
6183
6184<style>
6185    .res-callout{
6186        display: flex; /* Add this to make the container a flexbox */
6187        align-items: center; /* Center items vertically */
6188        white-space: nowrap; /* Prevent wrapping */
6189        height: fit-content;
6190        width: fit-content;
6191        padding: 3.5px;
6192        background-color: #fff;
6193        border: 1px solid #e4ebf1;
6194        border-radius: 5px;
6195        font-size: 10px;
6196        text-transform: uppercase;
6197        color: #5c6d7a;
6198    }
6199</style>    
6199<script type="text/html" id="template-event-list">
6200    <div class="cs-background-image relative" :class="class_string" :style="style_string">
6201        <div class="element-wrapper" 
6202            :class="'element-wrapper_' + component_type" 
6203            :style="element_style_string" 
6204            style="max-width:100% !important;min-width:90%;"
6205        >
6206            <!-- list view -->
6207            <template v-if="formatting_obj.layout === 'list' || !formatting_obj.layout">
6208                <div class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id" :data-client_id="client_id">
6209                    <div 
6210                        class="flexNoWrap align-items-center"
6211                        style="padding-bottom:5px;"
6212                    >
6213                        <h3 class="smallMarginRight" :style="{ 'font-size': '20px !important', 'font-weight': '600 !important', 'padding': '10px' }">{{ date_filter.charAt(0).toUpperCase() + date_filter.slice(1) || 'Upcoming' }} events</h3>
6214                        <div 
6215                            class="view-all-events-wrapper tinyMarginLeft"
6216                            @click="popViewAllEventsOverlay()"
6217                        >
6218                            <div :style="{ 'font-size': '14px !important', 'font-weight': '400' }">View all</div>
6219                            <div class="smallDrilldownHeaderBackButton flip"></div>
6220                        </div>
6221                    </div>
6222                    <div class="standardCardBody noBorderBottom fff noPadding overflowHidden">
6223                        <div v-if="!loading && sorted_event_data.length >= 1" class="tableInsert hundop flexWrapEvent">
6224                            <template v-for="(event_data, index) in sorted_event_data" :key="event_key(event_data)">
6225                                <p 
6226                                    v-if="is_first_visible_event_of_month(event_data)"
6227                                    class="cs-section-header"
6228                                >{{ month_string( event_data ) }}</p>
6229                                <event-list-event-component
6230                                    :event_data="event_data"
6231                                    :event_props="event_props( event_data )"
6232                                    @tapped-event-list-event="tapped_event_list_event"
6233                                ></event-list-event-component>
6234                            </template>
6235                        </div>
6236                        <div v-if="loading" class="cardBodySpinnerWrap active relative">
6237                            <div class="cardBodySpinner centeredBlock"></div>
6238                        </div>
6239                        <no-objects-found 
6240                            v-if="!loading && sorted_event_data.length == 0"
6241                            subtext="No events found"
6242                            icon_class="fa-light fa-calendar-days"
6243                        ></no-objects-found>
6244                    </div>
6245                </div>
6246            </template>
6247
6248            <!-- carousel view -->
6249            <template v-if="formatting_obj.layout === 'carousel'">
6250                <div class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id" :data-client_id="client_id">
6251                    <div 
6252                        class="flexNoWrap align-items-center event_list_carousel_default"
6253                        style="padding: 0px 0px 15px 5px;"
6254                    >
6255                        <h3
6256                            class="event_list_carousel_header"
6257                        >{{ date_filter || 'upcoming' }} events</h3>
6258                    </div>
6259                    <span
6260                        @mouseenter="start_auto_scroll('left')"
6261                        @mouseleave="stop_auto_scroll"
6262                        class="event-list-auto-scroll left"
6263                    ></span>
6264                    <div class="standardCardBody noBorderTop noBorderBottom fff noPadding">
6265                        <div 
6266                            ref="event_list_card_wrapper"
6267                            v-if="!loading && sorted_event_data.length >= 1"
6268                            class="tableInsert hundop event-list-card-wrapper"
6269                            :style="{ gap: formatting_obj.card_spacing + 'px' || '25px' }"
6270                        >
6271                            <template v-for="(event_data, index) in sorted_event_data" :key="event_key(event_data)">
6272                                <event-list-card-component
6273                                    :event_data="event_data"
6274                                    :formatting="formatting_obj"
6275                                    :event_props="event_props( event_data )"
6276                                    @tapped-event-list-event="tapped_event_list_event"
6277                                ></event-list-card-component>
6278                            </template>
6279                        </div>
6280                        <div v-if="loading" class="cardBodySpinnerWrap active relative">
6281                            <div class="cardBodySpinner centeredBlock"></div>
6282                        </div>
6283                        <no-objects-found 
6284                            v-if="!loading && sorted_event_data.length == 0"
6285                            subtext="No events found"
6286                            icon_class="fa-light fa-calendar-days"
6287                        ></no-objects-found>
6288                    </div>
6289                    <span
6290                        @mouseenter="start_auto_scroll('right')"
6291                        @mouseleave="stop_auto_scroll"
6292                        class="event-list-auto-scroll right"
6293                    ></span>
6294                </div>
6295            </template>
6296        </div>
6297    </div>
6298</script>
6298
6299
6300<style>
6301    .event_list_carousel_default {
6302        --event-list-carousel-font-family: 'Cormorant Garamond';
6303        --event-list-carousel-font-size: 16px;
6304        --event-list-carousel-font-weight: 600;
6305        --event-list-carousel-text-transform: uppercase;
6306        --event-list-carousel-letter-spacing: 1.2px;
6307        --event-list-carousel-color: #000;
6308    }
6309    .event_list_carousel_header {
6310        /* font-family: var(--event-list-carousel-font-family) !important; */
6311        font-size: var(--event-list-carousel-font-size) !important;
6312        font-weight: var(--event-list-carousel-font-weight) !important;
6313        text-transform: var(--event-list-carousel-text-transform) !important;
6314        letter-spacing: var(--event-list-carousel-letter-spacing) !important;
6315        color: var(--event-list-carousel-color) !important;
6316    }
6317    .event_list_carousel_header.select_filter {
6318        width: 100px !important;
6319        text-overflow: ellipsis !important;
6320        border: none !important;
6321        border-bottom: 0.5px solid black !important;
6322        appearance: none !important;
6323        -webkit-appearance: none !important;
6324        -moz-appearance: none !important;
6325        background: none !important;
6326        padding: 0px !important;
6327        box-shadow: none !important;
6328        display: inline-block !important;
6329        background-image: none !important;
6330    }
6331    .event-list-card-wrapper {
6332        position: relative;
6333        display: flex;
6334        justify-content: flex-start;
6335        align-items: center;
6336        padding: 10px;
6337        overflow: scroll !important;
6338        scrollbar-width: none !important;
6339    }
6340    .event-list-auto-scroll {
6341        position: absolute;
6342        top: 0;
6343        height: 100%;
6344        width: 20px;
6345    }
6346    .event-list-auto-scroll.left {
6347        left: 0;
6348    }
6349    .event-list-auto-scroll.right {
6350        right: 0;
6351    }
6352</style>
6353
6354<script>    
6355    
6356    app_content.component('event-list-component', {
6357        template: '#template-event-list',
6358        data() {
6359            return {
6360                event_data: [],
6361                loading: true,
6362                sortObject: {
6363                    order: 'ascending',
6364                    key: 'startDate'
6365                },
6366                scroll_animation_frame: null,
6367            }
6368        },
6369        props: {
6370            class_string: String,
6371            style_string: String,
6372            element_style_string: String,
6373            formatting: String,
6374            filter_types: String,
6375            filter_tag_ids: String,
6376            date_filter: {
6377                type: String,
6378                default: 'upcoming',
6379            },
6380            component_type: String,
6381            client_id: String,
6382            //admin
6383            component: Object,
6384            last_update: Number,
6385        },
6386        mounted() {
6387            if( this.date_filter == "past" ){
6388                this.sortObject = {
6389                    order: "descending",
6390                    key: "startDate"
6391                };
6392            }
6393            this.loadEventList(this.client_id);
6394        },
6395        methods: {
6396            is_first_visible_event_of_month( event_data ){
6397                return this.first_visible_events.includes( this.event_key(event_data) );
6398            },
6399            event_key( event_data ){
6400                return event_data.event.id + '_' + event_data.ts;
6401            },
6402            month_string( event_data ){
6403                let start_date = new Date(event_data.ts);
6404                return format_utc_date_v2({
6405                    "utcDate": start_date,
6406                    "timezone": store.timezone,
6407                    "formatString": "MMMM YYYY"
6408                });
6409            },
6410            event_props( event_data ){
6411                return {
6412                    show_register_button: this.show_register_button( event_data ),
6413                    show_time: this.show_time( event_data.event ),
6414                    image_data: this.image_data( event_data.event ),
6415                    start_date: this.start_date( event_data ),
6416                    end_date: this.end_date( event_data ),
6417                    resolved_external_url: this.resolve_external_url_for_instance( event_data ),
6418                };
6419            },
6420            resolve_external_url_for_instance( event_data ){
6421                let event = event_data.event;
6422                if ( !event || !event.get('calendarOnly') ) {
6423                    return null;
6424                }
6425                if ( event.get('external_event_url_type') === 'dining' ) {
6426                    if ( event_data.external_event_url ) {
6427                        return event_data.external_event_url;
6428                    }
6429                    let startDate = event.get('startDate');
6430                    let components = return_date_components_v2({
6431                        "date": startDate,
6432                        "timezone": 'UTC'
6433                    });
6434                    let date_string = components.year + '-' + ( components.month + 1 ) + '-' + components.date;
6435                    let dining_url = '/dining?date_string=' + date_string;
6436                    if ( event.get('startTime') ) {
6437                        dining_url += '&time=' + event.get('startTime');
6438                    }
6439                    return dining_url;
6440                }
6441                if ( event.get('external_event_url') ) {
6442                    let url = event.get('external_event_url');
6443                    if ( url && !/^https?:\/\//i.test(url) ) {
6444                        url = 'https://' + url;
6445                    }
6446                    return url;
6447                }
6448                return null;
6449            },
6450            start_date( event_data ){
6451                if( event_data.ts ){
6452                    return new Date(event_data.ts);
6453                }
6454                return null;
6455            },
6456            end_date( event_data ){
6457                if( event_data.end_ts ){
6458                    return new Date(event_data.end_ts);
6459                }
6460                return null;
6461            },
6462            tapped_event_list_event( event_data ){
6463                let event = event_data.event;
6464                if(event && event.className == 'events') {
6465                    $('#overlay_event-description-popup h3').text(event.get('name'));
6466                    let start_date = this.start_date( event_data );
6467                    let nice_date = format_utc_date_v2({
6468                        "utcDate": start_date,
6469                        "timezone": store.timezone,
6470                        "formatString": "MMM D, YYYY"
6471                    });
6472                    $('#overlay_event-description-popup .metric_start_date').text( nice_date );
6473                    if( this.show_time( event ) ){
6474                        let event_time = format_utc_date_v2({
6475                            "utcDate": start_date,
6476                            "timezone": store.timezone,
6477                            "formatString": "h:mm A"
6478                        });
6479                        $('#overlay_event-description-popup .metric_start_date_time').text( event_time ).removeClass('incomplete');
6480                    }else{
6481                        $('#overlay_event-description-popup .metric_start_date_time').text('Not provided').addClass('incomplete');
6482                    }
6483                    if(event.get('description')) {
6484                        $('#overlay_event-description-popup .metric_description').html(event.get('description')).removeClass('incomplete');
6485                    } else {
6486                        $('#overlay_event-description-popup .metric_description').text('Not provided').addClass('incomplete');
6487                    }
6488                    fadeIn('#overlay_event-description-popup');
6489                }
6490            },
6491            show_register_button( event_data ){
6492                var now = new Date();
6493                var ended = false;
6494                let eventObject = event_data.event;
6495                var registration_closed = eventObject.get('registration_closed');
6496                var lastChanceDate = eventObject.get('lastChanceDate');
6497                var startDate = new Date(event_data.ts);
6498                if( event_data.end_ts ){
6499                    var endDate = new Date(event_data.end_ts);
6500                }
6501                if( ! endDate && startDate ){
6502                    endDate = startDate;
6503                }
6504                var endDate_plus_one;
6505                if( endDate ){
6506                    endDate_plus_one = new Date( endDate.getTime() ); 
6507                    endDate_plus_one.setDate( endDate.getDate() + 1 );
6508                }
6509                if( now > endDate_plus_one && endDate_plus_one ){ 
6510                    ended = true; 
6511                }
6512                if( now > lastChanceDate && lastChanceDate ){ 
6513                    registration_closed = true; 
6514                }
6515                if( ended || registration_closed || eventObject.get('calendarOnly') ){ 
6516                    return false;
6517                } else {
6518                    return true;
6519                }
6520            },
6521            show_time( event ){
6522                if( 
6523                    event.get('calendarOnly') && event.get('all_day') 
6524                ){
6525                    return false;
6526                }else if( 
6527                    event.className == "camps" ||
6528                    event.className == "regattas" ||
6529                    event.className == "subevents"
6530                ){
6531                    return false;
6532                }
6533                return true;
6534            },
6535            image_data( event ){
6536                if( event.className == "subevents" && event.get('regattaObject') ){
6537                    event = event.get('regattaObject');
6538                }
6539                return {
6540                    url: event.get('imageURL'),
6541                    crop: event.get('imageCrop')
6542                }
6543            },
6544            popViewAllEventsOverlay(){
6545                view_all_events({
6546                    "class_string": this.class_string,
6547                    "style_string": this.style_string,
6548                    "element_style_string": this.element_style_string,
6549                    "component_type": this.component_type,
6550                    "client_id": this.client_id,
6551                    "event_data": this.event_data,
6552                    "props_generator": this.event_props,
6553                });
6554            },
6555            start_auto_scroll(direction) {
6556                const wrapper = this.$refs.event_list_card_wrapper;
6557                const amount = direction === 'right' ? 10 : -10; //scroll speed
6558                const scrollStep = () => {
6559                    wrapper.scrollBy({ left: amount, behavior: 'auto' });
6560                    this.scroll_animation_frame = requestAnimationFrame(scrollStep);
6561                };
6562                scrollStep();
6563            },
6564            stop_auto_scroll() {
6565                cancelAnimationFrame(this.scroll_animation_frame);
6566                this.scroll_animation_frame = null;
6567            },
6568            loadEventList( client_id, overrides = {} ){
6569                var promise = Parse.Promise.as();
6570                var thisPromise = promise.then(() => {
6571                    var params = {};
6572                    params["client_id"] = client_id;
6573                    params["overrides"] = overrides;
6574                    params["request_url"] = window.location.href;
6575                    params["date_filter"] = this.date_filter;
6576                    return Parse.Cloud.run('retrieve_data_for_component', params);
6577                }).then(event_data => {
6578                    this.event_data = event_data;
6579                    return Promise.resolve();
6580                }).then(() => {
6581                    this.loading = false;
6582                }, error => {
6583                    checkParseError(error);
6584                    this.loading = false;
6585                });
6586                return thisPromise;
6587            },
6588            sortEventList( event_data, sortObject ){
6589                event_data = [... event_data];
6590                if( ! sortObject ){
6591                    sortObject = {}
6592                }
6593                if( ! sortObject.key ){
6594                    sortObject.key = "startDate";
6595                }
6596                if( ! sortObject.order ){
6597                    sortObject.order = "ascending"
6598                }
6599                if( sortObject.key == "name" ){
6600                    if( sortObject.order == "ascending" ){
6601                        return event_data.sort( this.sortByEventName_ascending );
6602                    }else{
6603                        return event_data.sort( this.sortByEventName_descending );
6604                    }
6605                }else if( sortObject.key == "startDate" ){
6606                    if( sortObject.order == "ascending" ){
6607                        return event_data.sort( this.sortByStartDate_event_ascending );
6608                    }else{
6609                        return event_data.sort( this.sortByStartDate_event_descending );
6610                    }
6611                }else if( sortObject.key == "endDate" ){
6612                    if( sortObject.order == "ascending" ){
6613                        return event_data.sort( this.sortByEndDate_event_ascending );
6614                    }else{
6615                        return event_data.sort( this.sortByEndDate_event_descending );
6616                    }
6617                }
6618                return event_data;
6619            },
6620            sortByEventName_ascending( a, b ){
6621                let event_a = a.event;
6622                let event_b = b.event;
6623                var aName = '';
6624                if( event_a.get('name') ){
6625                    aName += event_a.get('name').trim().toLowerCase();
6626                }
6627                var bName = '';
6628                if( event_b.get('name') ){
6629                    bName += event_b.get('name').trim().toLowerCase();
6630                }
6631                if(aName < bName) { return -1; }
6632                if(aName > bName) { return 1; }
6633                //they had the same name
6634                return 0;
6635            },
6636            sortByEventName_descending( a, b ){
6637                let event_a = a.event;
6638                let event_b = b.event;
6639                var aName = '';
6640                if( event_a.get('name') ){
6641                    aName += event_a.get('name').trim().toLowerCase();
6642                }
6643                var bName = '';
6644                if( event_b.get('name') ){
6645                    bName += event_b.get('name').trim().toLowerCase();
6646                }
6647                if(aName > bName) { return -1; }
6648                if(aName < bName) { return 1; }
6649                //they had the same name
6650                return 0;
6651            },
6652            sortByStartDate_event_ascending( a, b ){
6653                let a_ts = a.ts;
6654                let b_ts = b.ts;
6655                if(a_ts < b_ts) { return -1; }
6656                if(a_ts > b_ts) { return 1; }
6657                return 0;
6658            },
6659            sortByStartDate_event_descending( a, b ){
6660                let a_ts = a.ts;
6661                let b_ts = b.ts;
6662                if(a_ts > b_ts) { return -1; }
6663                if(a_ts < b_ts) { return 1; }
6664                return 0;
6665            },
6666            sortByEndDate_event_ascending( a, b ){
6667                let a_end_ts = a.end_ts;
6668                let b_end_ts = b.end_ts;
6669                if(a_end_ts < b_end_ts) { return -1; }
6670                if(a_end_ts > b_end_ts) { return 1; }
6671                return 0;
6672            },
6673            sortByEndDate_event_descending( a, b ){
6674                let a_end_ts = a.end_ts;
6675                let b_end_ts = b.end_ts;
6676                if(a_end_ts < b_end_ts) { return 1; }
6677                if(a_end_ts > b_end_ts) { return -1; }
6678                return 0;
6679            },
6680        },
6681        computed: {
6682            pre_filtered_event_data(){
6683                //filter by date first
6684                let pre_filtered_event_data = [...this.event_data];
6685                var timestamp = new Date().getTime();
6686                if ( this.date_filter !== 'all' ){
6687                    pre_filtered_event_data = pre_filtered_event_data.filter(event_data => {
6688                        let start = event_data.ts;
6689                        let end = event_data.end_ts;
6690                        if (this.date_filter === 'past'){
6691                            if( start < timestamp && ( end < timestamp || ! end || end == "undefined" ) ){
6692                                return true;
6693                            }
6694                        } else if (this.date_filter === 'upcoming'){
6695                            if ( start > timestamp || end > timestamp ){
6696                                return true;
6697                            }
6698                            // Single-day events: ts and end_ts are both midnight, so they get filtered out
6699                            // when the event is today. Include if the event's date is today or later.
6700                            if ( start === end || !end ){
6701                                let startOfEventDay = new Date(start);
6702                                startOfEventDay.setUTCHours(0, 0, 0, 0);
6703                                let startOfToday = new Date();
6704                                startOfToday.setUTCHours(0, 0, 0, 0);
6705                                if ( startOfEventDay.getTime() >= startOfToday.getTime() ){
6706                                    return true;
6707                                }
6708                            }
6709                        }
6710                        return false;
6711                    })
6712                }
6713                return pre_filtered_event_data;
6714            },
6715            first_visible_events(){
6716                let first_visible_events = [];
6717                let handled_keys = [];
6718                //loop through the event_data
6719                this.sorted_event_data.forEach(event_data => {
6720                    let start_date = new Date(event_data.ts);
6721                    let start_components = return_date_components_v2({
6722                        "date": start_date,
6723                        "timezone": store.timezone
6724                    });
6725                    let key = start_components.year + '_' + start_components.month;
6726                    if( ! handled_keys.includes(key) ){
6727                        first_visible_events.push( this.event_key(event_data) );
6728                        handled_keys.push(key);
6729                    }
6730                });
6731                return first_visible_events;
6732            },
6733            filtered_event_data(){
6734                let filtered_event_data = this.pre_filtered_event_data;
6735                //filter by selected tag IDs (configured in admin settings panel)
6736                if( this.filter_tag_ids_arr && this.filter_tag_ids_arr.length > 0 ){
6737                    filtered_event_data = filtered_event_data.filter(event_data => {
6738                        let event_tag_ids = event_data.event_tag_ids;
6739                        return event_tag_ids && this.filter_tag_ids_arr.some(id => event_tag_ids.includes(id));
6740                    });
6741                }
6742                //return them
6743                return filtered_event_data;
6744            },
6745            sorted_event_data(){
6746                let sorted = this.sortEventList( this.filtered_event_data, this.sortObject );
6747                if( this.formatting_obj.layout === 'carousel' ){
6748                    return sorted.splice(0, 20);
6749                }else{
6750                    return sorted.splice(0, 5);
6751                }
6752            },
6753            formatting_obj(){
6754                if (this.formatting){
6755                    return JSON.parse(this.formatting);
6756                }
6757                return {};
6758            },
6759            filter_tag_ids_arr(){
6760                if (this.filter_tag_ids){
6761                    return JSON.parse(this.filter_tag_ids);
6762                }
6763                return [];
6764            },
6765            filter_types_arr(){
6766                if (this.filter_types){
6767                    return JSON.parse(this.filter_types);
6768                }
6769                return [];
6770            },
6771        },
6772        watch: {
6773            last_update(newVal, oldVal){
6774                if (newVal !== oldVal){
6775                    let event_types = this.component.get('config_latest')?.event_types || [];
6776                    this.loadEventList( this.client_id, { event_types } );
6777                }
6778            }
6779        },
6780    });
6781</script>
6781    
6781<script type="text/html" id="template-iframe">
6782    <div v-if="!loading && !is_admin" class="cs-iframe-wrap" :class="class_string" :style="style_string">
6783        <iframe class="cs-iframe" :src="iframe_src"></iframe>
6784    </div>
6785    <div v-if="!loading && is_admin" class="cs-iframe-wrap cs-min-section-width" :class="class_string" :style="[style_string, { 'max-width': formatting_obj.section_width + '%' }]">
6786        <template v-if="iframe_src" >
6787            <iframe class="cs-iframe" sandbox="allow-forms allow-scripts" :src="iframe_src"></iframe>
6788        </template>
6789        <template v-if="!iframe_src">
6790            <p class="noObjectsMSG centeredBlock centeredText">Click on edit to configure your iframe.</p>
6791        </template>
6792    </div>
6793</script>
6793
6794
6795<style>
6796    /* Draft-editor only floor (the admin wrap carries cs-min-section-width;
6797       the live wrap does not). Without it an iframe with no configured
6798       section_height — or an empty/unconfigured embed — collapses shorter
6799       than the component toolbar + inline "add section" button, so those
6800       controls overlap. A configured section_height (inline min-height)
6801       still wins since it's taller. */
6802    .cs-iframe-wrap.cs-min-section-width {
6803        min-height: 130px;
6804    }
6805</style>
6806
6807<script>    
6808    /* ----- COMPONENT ----- */
6809    app_content.component('iframe-component', {
6810        template: '#template-iframe',
6811        data() {
6812            return {
6813                global: store,
6814                loading: true,
6815                is_admin: false,
6816            }
6817        },
6818        props: {
6819            class_string: String,
6820            style_string: String,
6821            iframe_src: String,
6822            //admin
6823            component: Object,
6824            formatting_string: String,
6825            last_update: Number
6826        },
6827        mounted() {
6828            this.is_admin = window.location.href.indexOf('website-builder') !== -1;
6829            this.loading = false;
6830        },
6831        computed: {
6832            formatting_obj(){
6833                if (this.formatting_string){
6834                    return JSON.parse(this.formatting_string);
6835                }
6836                return {};
6837            },
6838        },
6839    });
6840</script>
6840    
6840<script type="text/html" id="template-camera-feed">
6841    <div v-if="!loading && !is_admin" class="cs-iframe-wrap" :class="class_string" :style="style_string">
6842        <div v-if="!errored" style="position:relative;padding:8px;width:100%;box-sizing:border-box;">
6843            <div v-if="!image_loaded" style="width:100%;aspect-ratio:16 / 9;border-radius:12px;background:#e9ecef;display:flex;align-items:center;justify-content:center;color:#adb5bd;font-size:13px;">Loading live camera…</div>
6844            <img v-show="image_loaded" :src="img_src" style="width:100%;height:100%;object-fit:contain;display:block;border-radius:12px;">
6845            <span style="position:absolute;top:16px;left:16px;display:inline-flex;align-items:center;gap:5px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;font-weight:600;letter-spacing:0.5px;padding:3px 9px;border-radius:12px;">
6846                <span style="width:7px;height:7px;border-radius:50%;background:#ff3b30;display:inline-block;"></span>LIVE
6847            </span>
6848            <span v-if="last_image_time" style="position:absolute;top:16px;right:16px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;font-weight:600;padding:3px 9px;border-radius:12px;">{{ last_image_time }}</span>
6849        </div>
6850        <p v-if="errored" class="noObjectsMSG centeredBlock centeredText">The live camera is temporarily unavailable.</p>
6851    </div>
6852    <div v-if="!loading && is_admin" class="cs-iframe-wrap cs-min-section-width" :class="class_string" :style="[style_string, { 'max-width': formatting_obj.section_width + '%' }]">
6853        <template v-if="has_camera">
6854            <div style="position:relative;padding:8px;width:100%;box-sizing:border-box;">
6855                <div v-if="!image_loaded" style="width:100%;aspect-ratio:16 / 9;border-radius:12px;background:#e9ecef;display:flex;align-items:center;justify-content:center;color:#adb5bd;font-size:13px;">Loading live camera…</div>
6856                <img v-show="image_loaded" :src="img_src" style="width:100%;height:100%;object-fit:contain;display:block;border-radius:12px;">
6857                <span style="position:absolute;top:16px;left:16px;display:inline-flex;align-items:center;gap:5px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;font-weight:600;letter-spacing:0.5px;padding:3px 9px;border-radius:12px;">
6858                    <span style="width:7px;height:7px;border-radius:50%;background:#ff3b30;display:inline-block;"></span>LIVE
6859                </span>
6860                <span v-if="last_image_time" style="position:absolute;top:16px;right:16px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;font-weight:600;padding:3px 9px;border-radius:12px;">{{ last_image_time }}</span>
6861            </div>
6862        </template>
6863        <template v-if="!has_camera">
6864            <p class="noObjectsMSG centeredBlock centeredText">Click on edit to configure your camera.</p>
6865        </template>
6866    </div>
6867</script>
6867
6868
6869<script>
6870    app_content.component('camera-feed-component', {
6871        template: '#template-camera-feed',
6872        data() {
6873            return {
6874                global: store,
6875                loading: true,
6876                is_admin: false,
6877                img_src: '',
6878                errored: false,
6879                refresh_timer: null,
6880                loader: null,
6881                last_image_time: '',
6882                image_loaded: false,
6883            }
6884        },
6885        props: {
6886            class_string: String,
6887            style_string: String,
6888            client_id: String,
6889            camera_feed_id: String,
6890            refresh_interval_ms: [Number, String],
6891            //admin
6892            component: Object,
6893            formatting_string: String,
6894            last_update: Number,
6895        },
6896        mounted() {
6897            this.is_admin = window.location.href.indexOf('website-builder') !== -1;
6898            this.loading = false;
6899            if (this.has_camera) {
6900                this.start_refresh();
6901            }
6902        },
6903        beforeUnmount() {
6904            this.stop_refresh();
6905        },
6906        methods: {
6907            snapshot_base() {
6908                let base = `/camera-feed/${this.client_id}/snapshot.jpg`;
6909                //editor has no subdomain rewrite, so pass the club id explicitly
6910                if (this.is_admin && store.clubObject) {
6911                    base += `?club_id=${store.clubObject.id}`;
6912                }
6913                return base;
6914            },
6915            return_refresh_interval() {
6916                let interval = Number(this.refresh_interval_ms);
6917                if (!interval || isNaN(interval) || interval < 500) {
6918                    interval = 2000;
6919                }
6920                return interval;
6921            },
6922            return_next_snapshot_url() {
6923                let base = this.snapshot_base();
6924                let separator = base.indexOf('?') === -1 ? '?' : '&';
6925                return `${base}${separator}t=${Date.now()}`;
6926            },
6927            start_refresh() {
6928                this.stop_refresh();
6929                this.load_next_frame();
6930            },
6931            //fetch the next frame into an off-DOM Image so the visible <img>
6932            //is only swapped once bytes are fully loaded. we never reassign a
6933            //pending src, so the browser never cancels an in-flight request.
6934            load_next_frame() {
6935                let started = Date.now();
6936                let url = this.return_next_snapshot_url();
6937                let loader = new Image();
6938                loader.onload = () => {
6939                    //loaded bytes are in cache, so this swap paints instantly
6940                    this.img_src = url;
6941                    this.image_loaded = true;
6942                    this.errored = false;
6943                    this.last_image_time = this.format_time(new Date());
6944                    this.schedule_next_frame(started);
6945                };
6946                loader.onerror = () => {
6947                    this.errored = true;
6948                    this.schedule_next_frame(started);
6949                };
6950                loader.src = url;
6951                this.loader = loader;
6952            },
6953            //only advance after the current frame finishes, subtracting elapsed
6954            //so cadence tracks the target interval (never firing early)
6955            schedule_next_frame(started) {
6956                let interval = this.return_refresh_interval();
6957                let elapsed = Date.now() - started;
6958                let delay = Math.max(0, interval - elapsed);
6959                this.refresh_timer = setTimeout(() => {
6960                    this.load_next_frame();
6961                }, delay);
6962            },
6963            stop_refresh() {
6964                if (this.refresh_timer) {
6965                    clearTimeout(this.refresh_timer);
6966                    this.refresh_timer = null;
6967                }
6968                if (this.loader) {
6969                    this.loader.onload = null;
6970                    this.loader.onerror = null;
6971                    this.loader = null;
6972                }
6973            },
6974            format_time(date) {
6975                let hours = date.getHours();
6976                let minutes = date.getMinutes().toString().padStart(2, '0');
6977                let seconds = date.getSeconds().toString().padStart(2, '0');
6978                let period = hours >= 12 ? 'PM' : 'AM';
6979                hours = hours % 12;
6980                if (hours === 0) {
6981                    hours = 12;
6982                }
6983                return `${hours}:${minutes}:${seconds} ${period}`;
6984            },
6985        },
6986        computed: {
6987            has_camera() {
6988                if (!this.client_id) {
6989                    return false;
6990                }
6991                if (this.camera_feed_id) {
6992                    return true;
6993                }
6994                return false;
6995            },
6996            formatting_obj() {
6997                if (this.formatting_string) {
6998                    return JSON.parse(this.formatting_string);
6999                }
7000                return {};
7001            },
7002        },
7003        watch: {
7004            camera_feed_id() {
7005                if (this.has_camera) {
7006                    this.start_refresh();
7007                } else {
7008                    this.stop_refresh();
7009                }
7010            },
7011            refresh_interval_ms() {
7012                if (this.has_camera) {
7013                    this.start_refresh();
7014                }
7015            },
7016        },
7017    });
7018</script>
7018
7019    
7019<script type="text/html" id="template-blog-list">
7020    <div class="relative" :class="class_string" :style="style_string">
7021        <div class="element-wrapper cs-min-section-width" :class="'element-wrapper_' + component_type" :style="[element_style_string, { 'min-width': `${formatting_obj.section_width}%`, 'max-width': `${formatting_obj.section_width}%` }]">
7022            <div class="blog-list-wrap centeredBlock centeredText" :class="'blog-list-wrap_' + client_id" :data-client_id="client_id">
7023                <div class="ease flexNoWrap show_when_loaded" style="justify-content: center; align-items: center;">
7024                    <!-- template type - list -->
7025                    <template v-if="!formatting_obj.template || formatting_obj.template === 'list'">
7026                        <div class="inputWrap searchBarWrap flexGrowOne" style="min-width:100%;">
7027                            <input class="searchBar searchBar_blog-list easeFast" placeholder="Search posts..." v-model="search">
7028                        </div>
7029                        <div class="flexGrowOne"></div>
7030                    </template>
7031                    <!-- template type - bulletin -->
7032                    <template v-if="formatting_obj.template === 'bulletin'">
7033                        <div class="searchBarWrap_v2" style="width: 100%;
7034                        display: flex;
7035                        flex-direction: row;
7036                        align-items: center;
7037                        justify-content: center;
7038                        margin-right: 8px;
7039                        margin-left: 8px;
7040                        margin-bottom: 10px;">
7041                            <input id="searchBar_blog-list_v2" type="text" placeholder="Search posts..." v-model="search" />
7042                            <button id="searchBar_blog-list_submit" type="submit">
7043                                <svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="#666666" d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z" /></svg>
7044                            </button>
7045                        </div>
7046                    </template>
7047                </div> 
7048                <div class="fff noPadding">
7049                    <!-- template type - list -->
7050                    <template v-if="!formatting_obj.template || formatting_obj.template === 'list'">
7051                        <div v-if="!loading && truncated_posts.length >= 1" class="tableInsert hundop">
7052                            <template v-for="(post, index) in truncated_posts" :key="post.id">
7053                                <div 
7054                                    :style="{ 'background-color': formatting_obj.background_color || '#fff', 'border-radius': '4px' }"
7055                                    class="blog-row flexWrapLeftAlign relative pointer blog-row-border"
7056                                    :class="'blog-row_' + post.id" 
7057                                    :data-post-id="post.id"
7058                                >
7059                                    <div v-if="post && post.get('thumbnail')" class="blog-post-thumbnail" :style="'background-image:url(' + post.get('thumbnail') + ')'"></div>
7060                                    <div v-if="post && !post.get('thumbnail')" class="blog-post-thumbnail default-image-icon"></div>
7061                                    <div class="blog-row-inner flexGrowOne">
7062                                        <div class="blog-name-wrap-column">
7063                                            <p 
7064                                                class="blog-title" 
7065                                                :style="{ 
7066                                                    'color': formatting_obj.title_color || '#000',
7067                                                    'font-weight': formatting_obj.title_styling && formatting_obj.title_styling.bold ? '600 !important' : '400 !important',
7068                                                    'font-style': formatting_obj.title_styling && formatting_obj.title_styling.italic ? 'italic' : 'normal',
7069                                                    'text-decoration': formatting_obj.title_styling && formatting_obj.title_styling.underline ? 'underline' : 'none',
7070                                                }"
7071                                            >{{ post.get('title') }}</p>
7072                                            <p 
7073                                                v-if="post && post.get('subtitle')"
7074                                                class="blog-subtitle" 
7075                                                :style="{
7076                                                    'color': formatting_obj.subtitle_color || '#000',
7077                                                    'font-weight': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.bold ? '600 !important' : '400 !important',
7078                                                    'font-style': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.italic ? 'italic' : 'normal',
7079                                                    'text-decoration': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.underline ? 'underline' : 'none',
7080                                                }"
7081                                            >{{ post.get('subtitle') }}</p>
7082                                            <p class="blog-date" :style="{ 'color': formatting_obj.date_color || '#000' }">{{ niceDate(post) }}</p>
7083                                            <p><a class="absoluteA" :href="'/blog-post/' + post.id" style="text-transform: none !important; border-bottom: none !important;"></a>
7084                                                <a
7085                                                :href="'/blog-post/' + post.id"
7086                                                :style="{
7087                                                    'font-variant': 'small-caps',
7088                                                    'opacity': '0.9',
7089                                                    'color': formatting_obj.date_color || '#000',
7090                                                    'border-bottom': 'none !important',
7091                                                    'text-decoration': 'underline !important',
7092                                                    'text-decoration-color': (formatting_obj.date_color || '#000') + ' !important'
7093                                                }"
7094                                                >Read more</a>
7095                                            </p>
7096                                        </div>
7097                                    </div>
7098                                    <div v-if="post && post.get('pinned') === true">
7099                                        <i class="fa-solid fa-thumbtack thumbtack-icon" style="height: 1em; transform: rotate(45deg);"></i>
7100                                    </div>
7101                                </div>
7102                            </template>
7103                            <div 
7104                                v-if="has_more_posts"
7105                                class="view_more_button flexNoWrapCenter align-items-center"
7106                                @click="view_all = true"
7107                                style="margin-bottom:0px;margin-left:0px;margin-right:0px;"
7108                            >
7109                                <div>
7110                                    <p>View more</p>
7111                                </div>
7112                                <i class="fa-regular fa-arrow-right easeFast"></i>
7113                            </div>
7114                        </div>
7115                    </template>
7116                    <!-- template type - bulletin -->
7117                    <template v-if="formatting_obj.template === 'bulletin'">
7118                        <div 
7119                            v-if="!loading && truncated_posts.length >= 1" 
7120                            :class="class_string"
7121                        >
7122                            <div class="blog-list-container">
7123                                <section 
7124                                    class="blog-list-cards-v2" 
7125                                >
7126                                    <div v-for="(post, index) in truncated_posts" :key="post.id">
7127                                        <article 
7128                                            :style="{ 'background-color': formatting_obj.background_color || '#fff' }"
7129                                            style="display: flex; flex-direction: column; align-items: center; justify-content: flex-start; position: relative;"
7130                                            class="blog_post_card_v2"
7131                                            @click="go_to_post( post )"
7132                                        >
7133                                            <div class="blog-list-image-container">
7134                                                <img v-if="post && post.get('thumbnail')" class="blog_post_thumbnail" :src="post.get('thumbnail')">
7135                                                <div v-if="post && !post.get('thumbnail')" style="display: flex; align-items: center; justify-content: center;">
7136                                                    <i class="fa-duotone fa-solid fa-newspaper" style="font-size: 10em;"></i>
7137                                                </div>
7138                                            </div>
7139                                            <i v-if="post && post.get('pinned') === true" class="fa-solid fa-thumbtack" style="margin: 3px 0 0 2px; font-size: 1em; color: #fff; transform: rotate(45deg); position: absolute; top: 0; right: 5px;"></i>
7140                                            <div class="card__content">
7141                                                <div 
7142                                                    v-if="post && post.get('title')" 
7143                                                    class="card__title_v2" 
7144                                                    :style="{ 
7145                                                        'color': formatting_obj.title_color || '#000',
7146                                                        'font-weight': formatting_obj.title_styling && formatting_obj.title_styling.bold ? '600 !important' : '400 !important',
7147                                                        'font-style': formatting_obj.title_styling && formatting_obj.title_styling.italic ? 'italic' : 'normal',
7148                                                        'text-decoration': formatting_obj.title_styling && formatting_obj.title_styling.underline ? 'underline' : 'none',
7149                                                    }"
7150                                                >
7151                                                    {{ post.get('title') }}
7152                                                </div>
7153                                                <div 
7154                                                    v-if="post && post.get('subtitle')"
7155                                                    class="card__subtitle tinyMarginTop"
7156                                                    :style="{ 
7157                                                        'color': formatting_obj.subtitle_color || '#000',
7158                                                        'font-weight': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.bold ? '600 !important' : '400 !important',
7159                                                        'font-style': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.italic ? 'italic' : 'normal',
7160                                                        'text-decoration': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.underline ? 'underline' : 'none',
7161                                                    }"
7162                                                >
7163                                                    {{ post.get('subtitle') }}
7164                                                </div>
7165                                                <div class="card__date" :style="{ 'color': formatting_obj.date_color || '#000' }">{{ niceDate(post) }}</div>
7166                                                <div class="card__excerpt">
7167                                                    <a 
7168                                                        @click.stop 
7169                                                        :href="'/blog-post/' + post.id" 
7170                                                        :style="{
7171                                                            'font-variant': 'small-caps',
7172                                                            'opacity': '0.9',
7173                                                            'color': formatting_obj.date_color || '#000',
7174                                                            'border-bottom': 'none !important',
7175                                                            'text-decoration': 'underline !important',
7176                                                            'text-decoration-color': (formatting_obj.date_color || '#000') + ' !important'
7177                                                        }"
7178                                                    >
7179                                                        read more
7180                                                    </a>
7181                                                </div>
7182                                            </div>
7183                                        </article>
7184                                    </div>
7185                                </section>
7186                            </div>
7187                            <div 
7188                                v-if="has_more_posts"
7189                                class="view_more_button flexNoWrapCenter align-items-center"
7190                                :class="{'larchmont': is_larchmont || is_larchmont === 'true'}"
7191                                @click="view_all = true"
7192                                style="margin-bottom:0px;"
7193                            >
7194                                <div>
7195                                    <p>View more</p>
7196                                </div>
7197                                <i class="fa-regular fa-arrow-right easeFast"></i>
7198                            </div>
7199                        </div>
7200                    </template>
7201                    <div v-if="!loading && filtered_posts.length == 0" class="tableInsert hundop blog-list-contain">
7202                        <p class="noObjectsMSG withPadding textLeft blog-row flexWrapLeftAlign relative pointer">No posts found.</p>
7203                    </div>
7204                </div>         
7205                <div v-if="loading" class="cardBodySpinnerWrap active relative">
7206                    <div class="cardBodySpinner centeredBlock"></div>
7207                </div>
7208            </div>
7209        </div>
7210    </div>
7211</script>
7211
7212
7213<style>
7214
7215    /*------------------------ view more buttons --------------------- */
7216
7217    .view_more_button.card-layout{
7218        margin-top:0px;
7219    }
7220    .view_more_button{
7221        color: #6a7482;
7222        margin: 8px;
7223        border: 1px solid #e5eaec;
7224        border-top: 1px solid #e5eaec;
7225        border-radius: 5px;
7226        cursor: pointer;
7227        font-size: 16px;
7228        padding: 16px;
7229        margin-top: 8px;
7230    }
7231    .view_more_button:hover{
7232        color: #081333;
7233        background-color: #f8f9fb;
7234    }
7235    .view_more_button.larchmont{
7236        color: #fff;
7237        margin: 8px;
7238        border: 1px solid #fff;
7239        border-top: 1px solid #fff;
7240        border-radius: 5px;
7241        cursor: pointer;
7242        font-size: 16px;
7243        padding: 16px;
7244        margin-top: 8px;
7245    }
7246    .view_more_button.larchmont:hover{
7247        background-color: #002D62;
7248        color: #fff;
7249    }
7250    .view_more_button i{
7251        padding-left: 8px;
7252    }
7253    .view_more_button:hover i{
7254        color: #4a56f8;
7255        transform: translateX(4px);
7256    }
7257
7258    /* -- end view more button --*/
7259
7260    section.blog-list-cards-v2 {
7261        margin: 0 auto;
7262        display: grid;
7263        grid-template-columns: repeat(3, 1fr);
7264        grid-auto-rows: auto;
7265        grid-gap: 16px;
7266        align-content: center;
7267        justify-content: center;
7268        padding: 8px;
7269        width: 100%;
7270        box-sizing: border-box;
7271    }
7272    .blog-list-image-container {
7273        width: 100%;
7274        height: 300px;
7275        display: flex;
7276        align-items: center;
7277        justify-content: center;
7278        overflow: hidden;
7279        border-top-left-radius: 6px;
7280        border-top-right-radius: 6px;
7281        border-bottom-left-radius: 0px;
7282        border-bottom-right-radius: 0px;
7283        background-color: #f2f2f2;
7284    }
7285    .blog_post_thumbnail {
7286        width: 100%;
7287        height: 100%;
7288        object-fit: cover;
7289        display: block;
7290    }
7291    .default_image_icon {
7292        background-image: url('https://s3-us-west-2.amazonaws.com/myclubspot/upload-photo-icon.jpg');
7293        background-size: contain;
7294        background-position: center;
7295        background-repeat: no-repeat;
7296        width: 100%;
7297        height: 100%;
7298    }
7299    @media (max-width: 768px) {
7300        section.blog-list-cards-v2 {
7301            grid-template-columns: repeat(1, 1fr);
7302        }
7303    }
7304</style>
7305
7306<script>
7307
7308    store.components["blog_list"] = {
7309        allPosts: [],
7310        search: '',
7311        loading: true,
7312        view_all: false,
7313        post_limit: 6 //how many to show on initial load (before clicking "see more")
7314    };
7315
7316    app_content.component("blog-list-component", {
7317        template: "#template-blog-list",
7318        data() {
7319            return store.components['blog_list']
7320        },
7321        props: {
7322            client_id: String,
7323            class_string: String,
7324            style_string: String,
7325            element_style_string: String,
7326            formatting: String,
7327            component_type: String,
7328            is_larchmont: Boolean, String,
7329            //admin
7330            component: Object,
7331            last_update: Number,
7332        },
7333        mounted() {
7334            this.loadBlogList( this.client_id )
7335        },
7336        methods: {
7337            go_to_post( post ){
7338                window.location.href = "blog-post/" + post.id;
7339            },
7340            loadBlogList( client_id, overrides = {} ){
7341                var promise = Parse.Promise.as();
7342                var thisPromise = promise.then(() => {
7343                    //get new data
7344                    var params = {};
7345                    params["client_id"] = client_id;
7346                    params["overrides"] = overrides;
7347                    params["request_url"] = window.location.href;
7348                    return Parse.Cloud.run('retrieve_data_for_component', params);
7349                }).then(retrieved_posts => {
7350                    // sort with 'pinned' posts on top
7351                    this.allPosts = retrieved_posts.sort((a, b) => (a.get('pinned') === b.get('pinned') ? 0 : a.get('pinned') ? -1 : 1));
7352                    this.loading = false;
7353                    //and return
7354                    return Parse.Promise.as();
7355                }, error => {
7356                    checkParseError( error );
7357                });
7358                return thisPromise;
7359            },
7360            niceDate( postObject ){
7361                return dayjs(postObject.get('post_date')).format('MMM DD, YYYY');
7362            },
7363            return_grid_column_css(){
7364                if (window.innerWidth <= 990) {
7365                    //for mobile
7366                    return 'repeat(1, 256px)';
7367                } else {
7368                    return 'repeat(4, 256px)';
7369                }
7370            },
7371        },
7372        computed: {
7373            has_more_posts(){
7374                if( this.view_all ){
7375                    return false;
7376                }
7377                if( this.filtered_posts.length > this.post_limit ){
7378                    return true;
7379                }
7380                return false;
7381            },
7382            truncated_posts(){
7383                if( this.view_all ){
7384                    return this.filtered_posts;
7385                }else{
7386                    return this.filtered_posts.slice(0, this.post_limit);
7387                }
7388            },
7389            filtered_posts(){
7390                let val = this.search.toLowerCase();
7391                let filteredPosts = this.allPosts;
7392                // handle search results using all posts
7393                if (this.search.length > 0){
7394                    filteredPosts = this.allPosts.filter(post => {
7395                        let title = post.get('title').toLowerCase();
7396                        if ( title.includes(val) ){
7397                            return post;
7398                        }
7399                    })
7400                }
7401                return filteredPosts;
7402            },
7403            formatting_obj(){
7404                if (this.formatting){
7405                    return JSON.parse(this.formatting);
7406                }
7407                return {};
7408            },
7409        }
7410    })
7411</script>
7411    
7411<script type="text/html" id="template-custom-code">
7412    <template v-if="preview_element">
7413        <div
7414            class="cs-custom-code-wrap"
7415            :class="[class_string, {
7416                'preview-mode': active_tab === 'preview',
7417                'first-page-section': is_first,
7418                'active-ai-thread-target': ai_panel_is_open_for_this_component,
7419                'custom-code-preview-blank': !custom_code_is_empty && custom_code_preview_is_blank,
7420            }]"
7421            :style="ai_loading_wrap_style"
7422            @mouseenter="activate_ai_builder_target_from_presence"
7423        >
7424            <!-- Tab bar -->
7425            <div class="custom-code-tab-bar">
7426                <div
7427                    class="custom-code-tab"
7428                    :class="{ active: active_tab === 'edit' }"
7429                    @click="active_tab = 'edit'"
7430                >Code</div>
7431                <div
7432                    class="custom-code-tab"
7433                    :class="{ active: active_tab === 'preview' }"
7434                    @click="active_tab = 'preview'"
7435                >Preview</div>
7436            </div>
7437            <!-- Code pane -->
7438            <div v-show="active_tab === 'edit'" class="custom-code-pane">
7439                <div ref="cm_editor" class="custom-code-editor"></div>
7440            </div>
7441            <!-- Preview pane -->
7442            <!-- Hide the welcome page during the pre-first-delta state.
7443                 The loading pane below owns that window completely so
7444                 the preview area does not show both the composer and
7445                 "thinking" UI at the same time. -->
7446            <div
7447                v-show="active_tab === 'preview' && custom_code_is_empty && !has_ai_thread_for_component && !ai_streaming && !ai_streaming_pending && !ai_welcome_generating"
7448                class="custom-code-pane custom-code-preview-pane custom-code-welcome-pane"
7449                :class="{ 'is-welcome-drop-active': welcome_drop_active }"
7450                style="display: flex; align-items: center; justify-content: center; min-height: 420px; padding: 48px 20px; background: #fbfcff;"
7451                @dragenter="handle_welcome_dragenter"
7452                @dragover.prevent="handle_welcome_dragover"
7453                @dragleave="handle_welcome_dragleave"
7454                @drop.prevent="handle_welcome_drop"
7455            >
7456                <!-- Drop overlay (welcome composer) — same pattern as
7457                     the chat panel's drop overlay; mounts only while
7458                     a file drag is hovering this pane. -->
7459                <div
7460                    v-if="welcome_file_upload_enabled && welcome_drop_active"
7461                    aria-hidden="true"
7462                    style="position: absolute; inset: 0; z-index: 5; display: flex; align-items: center; justify-content: center; background: rgba(244, 246, 255, 0.85); pointer-events: none;"
7463                >
7464                    <div style="display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 18px 22px; border-radius: 12px; background: #fff; border: 1px solid #d9dfeb; color: #4a56f8; font-size: 13px; font-weight: 600; box-shadow: 0 6px 22px rgba(74, 86, 248, 0.15);">
7465                        <i class="fa-regular fa-image" style="font-size: 22px;"></i>
7466                        <p class="noMargin">Drop images to attach</p>
7467                    </div>
7468                </div>
7469                <div style="width: min(680px, 92%); margin: 0 auto; text-align: center;">
7470                    <div style="width: 54px; height: 54px; border-radius: 18px; margin: 0 auto 18px auto; display: flex; align-items: center; justify-content: center; background: rgba(74, 86, 248, 0.08); color: #4a56f8;">
7471                        <i class="fa-regular fa-wand-magic-sparkles" style="font-size: 22px;"></i>
7472                    </div>
7473                    <h3 class="noMargin" style="font-size: 24px; line-height: 32px; color: #081333; font-weight: 700;">Build a custom section</h3>
7474                    <p class="noMargin" style="font-size: 14px; line-height: 22px; color: #748095; margin-top: 6px !important;">Start with AI, then refine the code directly whenever you need to.</p>
7475                    <div class="flexWrap" style="gap: 8px; justify-content: center; margin: 22px auto 14px auto;">
7476                        <button
7477                            v-for="example in ai_prompt_examples"
7478                            :key="example"
7479                            type="button"
7480                            class="ai-composer-pill pointer exclude-from-custom-button-styles"
7481                            @click.stop.prevent="set_ai_prompt_example(example)"
7482                        >{{ example }}</button>
7483                    </div>
7484                    <!-- Attached image thumbnails — read-only mirror of
7485                         the chat panel's mcp_attachments. Files queued
7486                         here flow into the chat panel's state via the
7487                         global ai_mcp_panel_attach_files helper, so
7488                         submit ships them alongside the prompt without
7489                         this composer needing its own prep pipeline. -->
7490                    <div
7491                        v-if="welcome_file_upload_enabled && welcome_attachments && welcome_attachments.length"
7492                        style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 10px;"
7493                    >
7494                        <div
7495                            v-for="(att, ai) in welcome_attachments"
7496                            :key="'welcome-att-' + att.id"
7497                            :title="att.name"
7498                            style="position: relative; width: 56px; height: 56px; border-radius: 8px; overflow: hidden; background: #eef0f4; border: 1px solid #d9dfeb; flex: 0 0 auto;"
7499                            :style="att.status === 'error' ? 'border-color: #e
749907b6f;' : ''"
7500                        >
7501                            <img
7502                                v-if="att.preview_url"
7503                                :src="att.preview_url"
7504                                :alt="att.name"
7505                                style="width: 100%; height: 100%; object-fit: cover; display: block;"
7506                                :style="att.status === 'pending' ? 'opacity: 0.55;' : ''"
7507                            />
7508                            <div
7509                                v-if="att.status === 'pending'"
7510                                style="position: absolute; top: 50%; left: 50%; width: 18px; height: 18px; margin: -9px 0 0 -9px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.45); border-top-color: #fff; animation: aiMcpLauncherSpin 1s linear infinite; pointer-events: none;"
7511                                aria-hidden="true"
7512                            ></div>
7513                            <button
7514                                type="button"
7515                                class="pointer exclude-from-custom-button-styles"
7516                                style="position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; padding: 0; margin: 0; border: none; border-radius: 999px; background: rgba(8, 19, 51, 0.65); color: #fff; font-size: 9px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;"
7517                                :title="'Remove ' + (att.name || 'image')"
7518                                @click.stop.prevent="welcome_remove_attachment(ai)"
7519                            >
7520                                <i class="fa-regular fa-xmark"></i>
7521                            </button>
7522                        </div>
7523                    </div>
7524                    <div style="position: relative; display: flex; align-items: center; gap: 10px; border: 1px solid rgba(8, 19, 51, 0.1); border-radius: 16px; background: #fff; box-shadow: 0 14px 45px rgba(8, 19, 51, 0.08); padding: 11px 12px 11px 15px; text-align: left;">
7525                        <div style="flex: 1 1 auto; min-width: 0;">
7526                            <p class="noMargin" style="font-size: 12px; line-height: 16px; color: #4a56f8; font-weight: 700; margin-bottom: 8px !important;">
7527                                <i class="fa-regular fa-sparkles" style="padding-right: 4px;"></i>Design with AI
7528                            </p>
7529                            <textarea
7530                                ref="ai_prompt_textarea"
7531                                v-model="ai_prompt"
7532                                rows="1"
7533                                class="ai-prompt-textarea"
7534                                style="display: block; width: 100%; height: 21px; min-height: 21px; max-height: 168px; overflow-y: auto; border: none; box-shadow: none; resize: none; padding: 0; font-size: 15px; line-height: 21px; color: #081333;"
7535                                placeholder="Describe the section you want to build..."
7536                                :disabled="ai_prompt_loading"
7537                                @input="auto_resize_ai_prompt"
7538                                @paste="handle_welcome_paste"
7539                                @keydown.enter.exact.prevent="submit_ai_prompt"
7540                            ></textarea>
7541                        </div>
7542                        <input
7543                            v-if="welcome_file_upload_enabled"
7544                            ref="ai_prompt_file_input"
7545                            type="file"
7546                            accept="image/*"
7547                            multiple
7548                            style="display: none;"
7549                            @change="handle_welcome_file_input_change"
7550                        />
7551                        <button
7552                            v-if="welcome_file_upload_enabled"
7553                            type="button"
7554                            class="pointer exclude-from-custom-button-styles"
7555                            :disabled="ai_prompt_loading"
7556                            title="Attach image"
7557                            style="width: 32px; min-width: 32px; height: 32px; border: 0; border-radius: 999px; background: transparent; color: #5f6f8d; display: flex; justify-content: center; align-items: center; cursor: pointer; font-size: 15px;"
7558                            @click.stop.prevent="open_welcome_file_picker"
7559                        >
7560                            <i class="fa-regular fa-paperclip"></i>
7561                        </button>
7562                        <button
7563                            type="button"
7564                            class="ai-submit-button easeFast exclude-from-custom-button-styles"
7565                            :class="{
7566                                inactive: !ai_prompt_can_submit,
7567                                loading: ai_prompt_loading
7568                            }"
7569                            :disabled="!ai_prompt_can_submit"
7570                            title="Send"
7571                            @click.stop.prevent="submit_ai_prompt"
7572                        >
7573                            <i class="fa-regular fa-paper-plane"></i>
7574                        </button>
7575                    </div>
7576                </div>
7577            </div>
7578            <!-- Welcome "Generating…" state — shown after the blank welcome
7579                 composer hands the prompt to the page agent, until the first
7580                 result lands (content arrives) or the safety timer fires. -->
7581            <div
7582                v-show="active_tab === 'preview' && custom_code_is_empty && ai_welcome_generating"
7583                class="custom-code-pane custom-code-preview-pane custom-code-welcome-generating-pane"
7584                style="display: flex; align-items: center; justify-content: center; min-height: 420px; padding: 48px 20px; background: #fbfcff;"
7585                aria-live="polite"
7586            >
7587                <div style="display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;">
7588                    <span class="custom-code-welcome-generating-spinner" aria-hidden="true"></span>
7589                    <p class="noMargin" style="font-size: 15px; line-height: 22px; color: #4a56f8; font-weight: 600;">Generating…</p>
7590                </div>
7591            </div>
7592            <div
7593                v-show="active_tab === 'preview' && custom_code_is_empty && has_ai_thread_for_component && !ai_streaming && !ai_streaming_pending && !ai_welcome_generating"
7594                class="custom-code-pane custom-code-preview-pane custom-code-empty-state-pane"
7595            >
7596                <div class="custom-code-empty-state-copy">
7597                    <i class="fa-regular fa-code"></i>
7598                    <span>No content</span>
7599                </div>
7600            </div>
7601            <!-- Preview strips scripts / non-visual tags, so a section that
7602                 only has those would otherwise collapse to 0 height. -->
7603            <div
7604                v-show="active_tab === 'preview' && !custom_code_is_empty && custom_code_preview_is_blank && (!ai_streaming || ai_streaming_pending || !ai_generation_active)"
7605                class="custom-code-pane custom-code-preview-pane custom-code-empty-state-pane custom-code-nonvisual-pane"
7606            >
7607                <div class="custom-code-empty-state-copy">
7608                    <i class="fa-regular fa-code"></i>
7609                    <span>This section has code that isn't visible in preview</span>
7610                </div>
7611            </div>
7612            <!-- Static preview pane: HTML is mounted into an open shadow
7613                 root (see ensure_isolated_custom_code_host) so host theme
7614                 CSS cannot restyle the section — same isolation as live. -->
7615            <div
7616                v-show="active_tab === 'preview' && !custom_code_is_empty && !custom_code_preview_is_blank && (!ai_streaming || ai_streaming_pending || !ai_generation_active)"
7617                ref="custom_code_preview"
7618                class="custom-code-pane custom-code-preview-pane"
7619                :style="ai_loading_pane_style"
7620            ></div>
7621            <!-- Pre-first-delta loading. Empty sections get a full pane;
7622                 edits to existing content use the same large dots as an
7623                 overlay so the current section remains visible while the
7624                 updated HTML is being prepared. -->
7625            <div
7626                v-show="active_tab === 'preview' && ai_streaming_pending"
7627                class="custom-code-pane custom-code-preview-pane custom-code-loading-pane"
7628                :class="{ 'is-overlay': !custom_code_is_empty }"
7629                :style="ai_loading_pane_style"
7630                aria-live="polite"
7631            >
7632                <div class="custom-code-pending-dots" aria-label="Generating">
7633                    <span></span><span></span><span></span>
7634                </div>
7635            </div>
7636            <!-- Streaming preview pane: managed by flush_streaming_preview_pane
7637                 during a live SSE codegen stream. We bypass v-html so a
7638                 tiny morph diff can append-or-replace top-level children
7639                 by index — keeps already-rendered sections stable as new
7640                 ones stream in below them. Snapped back to the v-html
7641                 pane on stream `done`. -->
7642            <div
7643                v-show="active_tab === 'preview' && ai_streaming && !ai_streaming_pending && ai_generation_active"
7644                ref="custom_code_stream_preview"
7645                class="custom-code-pane custom-code-preview-pane"
7646                :style="ai_loading_pane_style"
7647            ></div>
7648
7649            <!-- AI builder launcher — bottom-right of the component.
7650                 Clicking sets this component as the active target for
7651                 the shared right-side AI panel (defined in
7652                 ai-mcp-panel.html) and expands it. The panel's webbuilder
7653                 mode then reads `current_code` server-side from
7654                 pageComponents.config_latest.custom_code and runs in a
7655                 thread pinned to this component instance. -->
7656            <button
7657                type="button"
7658                class="custom-code-ai-launcher pointer exclude-from-custom-button-styles"
7659                :class="{ 'has-active-stream': ai_generation_active }"
7660                @mousedown.stop.prevent
7661                @mouseup.stop
7662                @touchstart.stop
7663                @click.stop.prevent="open_ai_builder"
7664                :title="ai_launcher_title"
7665                v-if="ai_launcher_visible"
7666            >
7667                <i class="fa-regular fa-sparkles"></i>
7668                <span class="custom-code-ai-launcher-label">
7669                    <template v-if="effective_ai_streaming_status === 'queued'">Queued…</template>
7670                    <template v-else-if="ai_generation_active">Generating…</template>
7671                    <template v-else>Edit with AI</template>
7672                </span>
7673                <span
7674                    v-if="ai_generation_active"
7675                    class="custom-code-ai-launcher-spinner"
7676                    aria-hidden="true"
7677                ></span>
7678            </button>
7679        </div>
7680    </template>
7681    <template v-if="!preview_element">
7682        <!-- Live host: same shadow isolation as builder preview. -->
7683        <span v-if="!loading" ref="custom_code_static" class="cs-custom-code-live-host"></span>
7684    </template>
7685</script>
7685
7686
7687<script>    
7688    // Reset + inheritance for custom-code shadow roots. Host theme element
7689    // selectors (h1/a/details/…) cannot pierce the shadow; :host inherits
7690    // color/font from the light-DOM wrapper so club theme still flows in.
7691    var CS_CUSTOM_CODE_ISOLATION_CSS = [
7692        ':host { display: block; width: 100%; color: inherit; font-family: inherit; font-size: inherit; line-height: inherit; }',
7693        '.cs-cc-root { display: block; width: 100%; color: inherit; font: inherit; box-sizing: border-box; }',
7694        '.cs-cc-root *, .cs-cc-root *::before, .cs-cc-root *::after { box-sizing: border-box; }',
7695        '.cs-cc-root summary { list-style: none; cursor: pointer; }',
7696        '.cs-cc-root summary::-webkit-details-marker, .cs-cc-root summary::marker { display: none; }',
7697        '.cs-cc-root img { max-width: 100%; }',
7698    ].join('\n');
7699
7700    // Builder-only overlays live inside the shadow tree. Document CSS
7701    // cannot pierce it, so these rules must ship with the isolation sheet
7702    // or Add/Change chips render unstyled and get clipped by overflow.
7703    var CS_CUSTOM_CODE_PREVIEW_CHROME_CSS = [
7704        '.cs-asset-placeholder-host, .cs-asset-attached-host { position: relative; }',
7705        '.cs-asset-background-placeholder-host:hover { outline: 2px dashed rgba(74, 86, 248, 0.55) !important; outline-offset: -4px !important; }',
7706        '.cs-asset-background-placeholder-chip { position: absolute !important; top: 10px !important; right: 10px !important; z-index: 10000 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; width: 34px !important; height: 34px !important; padding: 0 !important; margin: 0 !important; border: 1px solid rgba(74, 86, 248, 0.45) !important; border-radius: 999px !important; background: rgba(255, 255, 255, 0.88) !important; color: #4a56f8 !important; box-shadow: 0 6px 18px rgba(8, 19, 51, 0.14) !important; cursor: pointer !important; appearance: none !important; }',
7707        '.cs-asset-background-placeholder-chip:hover { background: #ffffff !important; border-color: rgba(74, 86, 248, 0.85) !important; }',
7708        '.cs-asset-placeholder-overlay { position: absolute !important; inset: 0 !important; z-index: 125 !important; display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; gap: 4px !important; margin: 0 !important; padding: 12px !important; border: 2px dashed rgba(74, 86, 248, 0.55) !important; border-radius: 8px !important; background: rgba(74, 86, 248, 0.08) !important; color: #2a2f5a !important; font-family: Roboto, Arial, Helvetica, sans-serif !important; font-size: 13px !important; font-style: normal !important; font-weight: 500 !important; line-height: 1.25 !important; letter-spacing: 0 !important; text-align: center !important; white-space: normal !important; cursor: pointer !important; backdrop-filter: blur(2px) !important; appearance: none !important; }',
7709        '.cs-asset-placeholder-overlay:hover { background: rgba(74, 86, 248, 0.16) !important; border-color: rgba(74, 86, 248, 0.85) !important; }',
7710        '.cs-asset-placeholder-icon { display: inline-flex !important; align-items: center !important; justify-content: center !important; width: 28px !important; height: 28px !important; flex: 0 0 28px !important; margin-bottom: 4px !important; border-radius: 50% !important; background: rgba(255, 255, 255, 0.65) !important; color: #4a56f8 !important; font-size: 14px !important; font-style: normal !important; line-height: 1 !important; }',
7711        '.cs-asset-placeholder-label { display: inline-block !important; padding: 4px 10px !important; border-radius: 999px !important; background: rgba(255, 255, 255, 0.92) !important; color: #2a2f5a !important; font-family: Roboto, Arial, Helvetica, sans-serif !important; font-size: 13px !important; font-weight: 600 !important; line-height: 1.2 !important; min-width: 118px !important; max-width: 180px !important; white-space: normal !important; }',
7712        '.cs-asset-change-button { position: absolute !important; top: 8px !important; right: 8px !important; z-index: 10000 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; padding: 6px 10px !important; margin: 0 !important; border: none !important; border-radius: 6px !important; background: rgba(20, 24, 45, 0.82) !important; color: #fff !important; font-family: Roboto, Arial, Helvetica, sans-serif !important; font-size: 12px !important; font-weight: 600 !important; font-style: normal !important; line-height: 1 !important; letter-spacing: 0 !important; text-transform: none !important; cursor: pointer !important; opacity: 0.92 !important; pointer-events: auto !important; appearance: none !important; box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22) !important; }',
7713        '.cs-asset-change-button:hover, .cs-asset-change-button:focus { opacity: 1 !important; background: rgba(20, 24, 45, 0.95) !important; }',
7714        '.cs-ai-asset-hover-highlight { outline: 2px solid rgba(74, 86, 248, 0.32) !important; outline-offset: 4px !important; box-shadow: 0 0 0 6px rgba(74, 86, 248, 0.08) !important; }',
7715        '.cs-cc-root i[class*="fa-"] { font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important; font-style: normal !important; }',
7716    ].join('\n');
7717
7718    function createDebounce() {
7719        let timeout = null;
7720        return function (fnc, delayMs) {
7721            clearTimeout(timeout);
7722            timeout = setTimeout(() => {
7723                fnc();
7724            }, delayMs || 800);
7725        };
7726    }
7727
7728    // ─── External image detection (shared) ──────────────────────────────
7729    //
7730    // Shared by the custom-code component and section-actions overlay.
7731    var WEBBUILDER_CLUBSPOT_ASSET_HOSTS = [
7732        'd282wvk2qi4wzk.cloudfront.net',
7733        'myclubspot.s3-us-west-2.amazonaws.com',
7734        'myclubspot.s3.us-west-2.amazonaws.com',
7735    ];
7736
7737    function webbuilder_is_external_image_url(url) {
7738        if (typeof url !== 'string' || !url.trim()) {
7739            return false;
7740        }
7741        let parsed;
7742        try {
7743            parsed = new URL(url.trim());
7744        } catch (err) {
7745            return false;
7746        }
7747        if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
7748            return false;
7749        }
7750        return WEBBUILDER_CLUBSPOT_ASSET_HOSTS.indexOf((parsed.hostname || '').toLowerCase()) === -1;
7751    }
7752
7753    // Parse an HTML string and return the unique external image URLs it
7754    // references — both <img src> and inline `background-image: url(...)`.
7755    function webbuilder_collect_external_image_urls(source_html) {
7756        let source = (typeof source_html === 'string') ? source_html : '';
7757        if (!source.trim()) {
7758            return [];
7759        }
7760        let found = [];
7761        let seen = {};
7762        let push_if_external = (raw_url) => {
7763            let candidate = (typeof raw_url === 'string') ? raw_url.trim() : '';
7764            if (!candidate || seen[candidate]) {
7765                return;
7766            }
7767            if (!webbuilder_is_external_image_url(candidate)) {
7768                return;
7769            }
7770            seen[candidate] = true;
7771            found.push(candidate);
7772        };
7773        let template = document.createElement('template');
7774        template.innerHTML = source;
7775        let root = template.content;
7776        let imgs = Array.from(root.querySelectorAll('img[src]'));
7777        for (let img of imgs) {
7778            push_if_external(img.getAttribute('src'));
7779        }
7780        let styled = Array.from(root.querySelectorAll('[style*="background-image"]'));
7781        for (let el of styled) {
7782            let style = el.getAttribute('style') || '';
7783            let bg_pattern = /background-image\s*:\s*url\(\s*(['"]?)([^'"\)]+)\1\s*\)/gi;
7784            let match;
7785            while ((match = bg_pattern.exec(style)) !== null) {
7786                push_if_external(match[2]);
7787            }
7788        }
7789        return found;
7790    }
7791
7792    // ─── Text editing ───────────────────────────────────────────────────
7793    //
7794    // In PREVIEW mode we walk the rendered DOM looking for "text leaf"
7795    // elements (headings, paragraphs, buttons, links, list items, etc.)
7796    // and decorate each one with a click-to-edit affordance. Clicking
7797    // opens a small inline popover with a textarea; on save we mutate
7798    // the persisted custom_code source and persist via
7799    // save_changes_for_component, mirroring the placeholder fill flow.
7800    //
7801    // Unlike images, there's no "placeholder" concept for text — the AI
7802    // writes plausible draft copy directly into the section and admins
7803    // tweak it inline. Round-tripping (preview path → source path)
7804    // works because the preview's DOM is parsed from the same source
7805    // string we mutate, with the only structural divergence being
7806    // 
7806<script> tags removed by sanitize_custom_code_html (which we
7807    // skip on the source side when walking).
7808    //
7809    // A "text leaf" is any element whose element children are all
7810    // inline tags (or none). That treats `<h1>Welcome <em>back</em></h1>`
7811    // as a single editable string — emphasis collapses on save, by
7812    // design. Admins can use the Code tab for mixed inline formatting.
7813
7814    var TEXT_INLINE_TAGS = new Set([
7815        'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'em', 'i',
7816        'kbd', 'mark', 'q', 's', 'samp', 'small', 'span', 'strong',
7817        'sub', 'sup', 'time', 'u', 'var', 'wbr',
7818    ]);
7819
7820    var TEXT_SKIP_TAGS = new Set([
7821        'script', 'style', 'template', 'noscript', 'iframe', 'svg',
7822        'video', 'audio', 'canvas', 'img', 'picture', 'source', 'track',
7823        'input', 'textarea', 'select', 'option', 'optgroup', 'meta',
7824        'link', 'br', 'hr', 'col', 'param', 'embed', 'object',
7825    ]);
7826
7827    // Tags that never produce a layout box in the sanitized preview.
7828    var CUSTOM_CODE_PREVIEW_SKIP_TAGS = new Set([
7829        'script', 'style', 'template', 'noscript', 'meta', 'link', 'head',
7830    ]);
7831    // Replaced / form controls that take space even without text.
7832    var CUSTOM_CODE_PREVIEW_VISUAL_TAGS = new Set([
7833        'img', 'iframe', 'video', 'audio', 'canvas', 'svg', 'object', 'embed',
7834        'picture', 'input', 'textarea', 'select', 'hr', 'br',
7835    ]);
7836
7837    function custom_code_element_has_explicit_preview_box(element) {
7838        let style = element.getAttribute('style');
7839        if (!style || typeof style !== 'string') {
7840            return false;
7841        }
7842        if (/background-image\s*:\s*url\s*\(/i.test(style)) {
7843            return true;
7844        }
7845        let height_match = style.match(/(?:^|;)\s*(?:min-)?height\s*:\s*([^;]+)/i);
7846        if (!height_match) {
7847            return false;
7848        }
7849        let value = height_match[1].trim().toLowerCase();
7850        if (!value || value === '0' || value === '0px' || value === '0%' || value === 'auto') {
7851            return false;
7852        }
7853        return true;
7854    }
7855
7856    function custom_code_node_has_visible_preview_content(node) {
7857        if (!node) {
7858            return false;
7859        }
7860        if (node.nodeType === 8) {
7861            return false;
7862        }
7863        if (node.nodeType === 3) {
7864            return !!(node.nodeValue && node.nodeValue.trim());
7865        }
7866        if (node.nodeType !== 1) {
7867            let children = node.childNodes || [];
7868            for (let i = 0; i < children.length; i++) {
7869                if (custom_code_node_has_visible_preview_content(children[i])) {
7870                    return true;
7871                }
7872            }
7873            return false;
7874        }
7875        let tag = (node.tagName || '').toLowerCase();
7876        if (CUSTOM_CODE_PREVIEW_SKIP_TAGS.has(tag)) {
7877            return false;
7878        }
7879        if (CUSTOM_CODE_PREVIEW_VISUAL_TAGS.has(tag)) {
7880            return true;
7881        }
7882        if (custom_code_element_has_explicit_preview_box(node)) {
7883            return true;
7884        }
7885        for (let i = 0; i < node.childNodes.length; i++) {
7886            if (custom_code_node_has_visible_preview_content(node.childNodes[i])) {
7887                return true;
7888            }
7889        }
7890        return false;
7891    }
7892
7893    function custom_code_html_has_visible_preview_content(html) {
7894        if (!html || typeof html !== 'string' || !html.trim()) {
7895            return false;
7896        }
7897        let template = document.createElement('template');
7898        template.innerHTML = html;
7899        return custom_code_node_has_visible_preview_content(template.content);
7900    }
7901
7902    app_content.component('custom-code-component', {
7903        template: '#template-custom-code',
7904        data() {
7905            return {
7906                loading: true,
7907                debounce: createDebounce(),
7908                custom_code_value: '',
7909                active_tab: 'preview',
7910                // True while the section-actions "save external images"
7911                // batch is in flight, so duplicate overlay triggers no-op.
7912                importing_external_images: false,
7913                ai_prompt: '',
7914                ai_prompt_examples: [
7915                    'A modern hero section for our club homepage',
7916                    'A membership benefits section with three cards',
7917                    'An event promo section with image placeholders',
7918                ],
7919                ai_launcher_title: 'Edit with AI',
7920                // ─── Streaming state ───────────────────────────────
7921                // Flipped to true by begin_streaming_code() and back
7922                // to false by finish_streaming_code(). Controls the
7923                // template's preview-pane selection (v-html static vs.
7924                // morph-managed div) and the overlay visibility.
7925                ai_streaming: false,
7926                ai_streaming_status: null, // 'streaming' | 'queued' | 'aborted_local' | 'error' | 'done'
7927                // Snapshot of the editor contents at stream begin —
7928                // used by apply_generated_code as the return value
7929                // (Undo target) when the panel's done handler runs.
7930                ai_streaming_pre_code: null,
7931                // Accumulated code chunks. Always the full snapshot
7932                // of what the stream has emitted so far (chunks are
7933                // appended as they arrive).
7934                ai_streaming_buffer: '',
7935                // Local reactive tick for store.ai_streams snapshots.
7936                // Bumped by subscriber events; lets computeds that
7937                // read entry.code_buffer / entry.message_text
7938                // re-evaluate. Launcher status no longer depends on
7939                // this — that lives in store.codegen_request_state.
7940                ai_stream_event_tick: 0,
7941                ai_loading_pane_height: null,
7942                ai_loading_pane_height_release_timer: null,
7943                ai_streaming_child_min_heights: [],
7944                // Welcome composer drag-drop counter — increments on
7945                // dragenter, decrements on dragleave so the overlay
7946                // doesn't flicker on nested-element drag events. The
7947                // attachment LIST itself lives on the chat panel
7948                // (store.components.ai_mcp_panel.mcp_attachments) so
7949                // both composers stay in sync without a second prep
7950                // pipeline.
7951                welcome_drop_counter: 0,
7952                welcome_drop_active: false,
7953                // True between submitting from the blank welcome composer and
7954                // the page agent's first result landing. Swaps the welcome
7955                // composer for a "Generating…" pane. The codegen stream no
7956                // longer drives this flow (the page agent does), so this is a
7957                // local flag cleared when content arrives or on a safety timer.
7958                ai_welcome_generating: false,
7959            }
7960        },
7961        props: {
7962            custom_code: String,
7963            //admin
7964            class_string: String,
7965            preview_element: {
7966                type: Boolean,
7967                default: false,
7968            },
7969            is_first: {
7970                type: Boolean,
7971                default: false,
7972            },
7973            component: {
7974                type: Object,
7975                default: null,
7976            },
7977            first_section_top_spacer_px_override: {
7978                type: Number,
7979                default: null,
7980            },
7981            last_update: Number,
7982        },
7983        watch: {
7984            active_tab(val) {
7985                if (val !== 'preview') {
7986                    this.close_text_edit_popover();
7987                }
7988                if (val === 'edit' && this.cm_instance) {
7989                    this.$nextTick(() => this.cm_instance.refresh());
7990                }
7991                if (val === 'preview') {
7992                    this.$nextTick(() => this.initialize_custom_code_interactions());
7993                }
7994                this.publish_active_tab_to_store();
7995            },
7996            // First result from the page agent has landed — drop the welcome
7997            // "Generating…" pane so the new section renders.
7998            custom_code_value(val) {
7999                if (this.ai_welcome_generating && (val || '').trim()) {
8000                    this.clear_welcome_generating_state();
8001                }
8002            },
8003            last_update() {
8004                this.close_text_edit_popover();
8005                // Don't fight a LIVE stream — the streaming buffer is the
8006                // source of truth until the build settles. But only an actually
8007                // active stream (entry still streaming/queued) should block the
8008                // resync: a stale `ai_streaming` flag (entry terminal or gone,
8009                // e.g. an aborted/errored build) must NOT wedge the section on
8010                // old HTML, or the agent's authoritative __refresh re-pull can
8011                // never land. Otherwise sync the preview straight from the
8012                // component config — the live preview repaints off
8013                // custom_code_value even when the (hidden) editor isn't mounted.
8014                let entry = this.active_codegen_stream_entry;
8015                let stream_active = this.ai_streaming
8016                    && entry
8017                    && (entry.status === 'streaming' || entry.status === 'queued');
8018                if (!stream_active) {
8019                    if (this.ai_streaming) {
8020                        // Stale flag — release the editor lock before syncing.
8021                        this.force_clear_ai_loading_state();
8022                    }
8023                    let config_latest = this.component.get('config_latest') || {};
8024                    let new_value = config_latest.custom_code || '';
8025                    if (this.cm_instance && new_value !== this.cm_instance.getValue()) {
8026                        this.cm_instance.setValue(new_value);
8027                    }
8028                    this.custom_code_value = new_value;
8029                    this.publish_current_code_to_store();
8030                }
8031                this.$nextTick(() => this.initialize_custom_code_interactions());
8032            },
8033            // Re-measure the auto-grow welcome composer whenever the
8034            // prompt changes from outside the textarea — example pill
8035            // click, post-submit clear. Typing still routes through
8036            // @input for synchronous response.
8037            ai_prompt() {
8038                this.$nextTick(() => this.auto_resize_ai_prompt());
8039            },
8040            // Editor-lock cleanup. The launcher computed reads the
8041            // store entry directly (and the 1Hz heartbeat keeps it
8042            // fresh), so this watcher is no longer the launcher's
8043            // safety net — it just makes sure `ai_streaming` and the
8044            // editor's readOnly flag release when the entry goes
8045            // terminal, since the morph-preview / editor lock are
8046            // still driven from local flags rather than the store.
8047            ai_stream_event_tick() {
8048                let entry = this.active_codegen_stream_entry;
8049                if (!entry) {
8050                    return;
8051                }
8052                if (entry.status !== 'done' && entry.status !== 'error' && entry.status !== 'aborted_local') {
8053                    return;
8054                }
8055                // Editor-lock-only cleanup: this watcher does not
8056                // gate launcher visibility (the launcher binds to
8057                // store.codegen_request_state). It exists to release
8058                // ai_streaming / readOnly when the central store
8059                // says the request is over, in case the subscription
8060                // path missed an event.
8061                let needs_clear = this.ai_streaming
8062                    || this.ai_streaming_status === 'streaming'
8063                    || this.ai_streaming_status === 'queued';
8064                if (!needs_clear) {
8065                    return;
8066                }
8067                this.force_clear_ai_loading_state();
8068            },
8069        },
8070        computed: {
8071            safe_custom_code_value() {
8072                return this.sanitize_custom_code_html(this.custom_code_value);
8073            },
8074            safe_custom_code() {
8075                return this.sanitize_custom_code_html(this.custom_code || '');
8076            },
8077            // Source emptiness, not sanitized HTML — a 
8077<script> load is
8078            // still "has code" even though preview strips it.
8079            custom_code_is_empty() {
8080                return !(this.custom_code_value || '').trim();
8081            },
8082            custom_code_preview_is_blank() {
8083                return !custom_code_html_has_visible_preview_content(this.safe_custom_code_value);
8084            },
8085            // Unique external (non-Clubspot-CDN) image URLs referenced
8086            // by this section's persisted HTML — both <img src> and
8087            // inline `background-image: url(...)`. Drives the bulk
8088            // "save external images to my library" affordance. Recomputes
8089            // only when custom_code_value changes, so parsing cost is
8090            // bounded to actual edits.
8091            section_external_image_urls() {
8092                return this.collect_section_external_image_urls(this.custom_code_value);
8093            },
8094            section_external_image_count() {
8095                return this.section_external_image_urls.length;
8096            },
8097            ai_loading_pane_style() {
8098                if (!this.ai_loading_pane_height) {
8099                    return null;
8100                }
8101                return {
8102                    minHeight: this.ai_loading_pane_height + 'px',
8103                };
8104            },
8105            ai_loading_wrap_style() {
8106                if (!this.ai_loading_pane_height) {
8107                    return null;
8108                }
8109                return {
8110                    minHeight: this.ai_loading_pane_height + 'px',
8111                };
8112            },
8113            has_ai_thread_for_component() {
8114                if (typeof store === 'undefined') {
8115                    return false;
8116                }
8117                let component_id = this.component && this.component.id;
8118                if (!component_id) {
8119                    return false;
8120                }
8121                let config_latest = this.component && typeof this.component.get === 'function'
8122                    ? (this.component.get('config_latest') || {})
8123                    : {};
8124                if (config_latest.ai_thread_id) {
8125                    return true;
8126                }
8127                let thread_map = store.webbuilder_code_component_has_ai_thread || {};
8128                return !!thread_map[component_id];
8129            },
8130            ai_prompt_loading() {
8131                if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) {
8132                    return false;
8133                }
8134                return !!store.components.ai_mcp_panel.mcp_loading;
8135            },
8136            ai_prompt_can_submit() {
8137                if (!(this.ai_prompt || '').trim().length) {
8138                    return false;
8139                }
8140                return !this.ai_prompt_loading;
8141            },
8142            // ─── Welcome composer image attachments ──────────────────
8143            //
8144            // The welcome-state composer (rendered when a custom-code
8145            // section is empty and there's no prior AI thread) gets a
8146            // paperclip + drag-drop + thumbnail row mirroring the
8147            // chat panel. To avoid duplicating the canvas-resize /
8148            // base64 prep pipeline, we delegate ALL state to the chat
8149            // panel via the global ai_mcp_panel_attach_files helper.
8150            // This computed is just a reactive read of that shared
8151            // state so the welcome composer's thumbnails update in
8152            // lockstep with the chat panel's thumbnails.
8153            welcome_file_upload_enabled() {
8154                if (typeof store === 'undefined' || !store.ai_mcp_panel_component_instance) {
8155                    return false;
8156                }
8157                return !!store.ai_mcp_panel_component_instance.mode_enable_file_upload;
8158            },
8159            welcome_attachments() {
8160                if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) {
8161                    return [];
8162                }
8163                let panel = store.components.ai_mcp_panel;
8164                return Array.isArray(panel.mcp_attachments) ? panel.mcp_attachments : [];
8165            },
8166            // ─── Single source of truth: store.codegen_request_state ──
8167            //
8168            // The launcher's "Generating…" UI reads ONE reactive field:
8169            //   store.codegen_request_state[component_id]
8170            //
8171            // That field is owned by store.ai_streams (the central
8172            // streams manager in ai-mcp-panel.html) and written ONLY
8173            // from there as the request transitions through:
8174            //   'starting' → 'queued' → 'streaming' → 'done' / 'error' / 'aborted'
8175            //
8176            // Because `store` is a Vue 3 `reactive()` proxy, both the
8177            // launcher (this file) and the chat panel bind to the same
8178            // key reactively. START and STOP are visible to both
8179            // surfaces from a single write — no subscriptions, no
8180            // ticks, no heartbeats, no watchdog needed.
8181            //
8182            // Local flags (`ai_streaming`, `ai_streaming_buffer`,
8183            // `ai_streaming_status`) still drive the EDITOR LOCK /
8184            // morph preview pane via the subscription path; those
8185            // concerns are deliberately decoupled from the launcher
8186            // so a missed editor-side event can't strand the spinner.
8187            codegen_request_status() {
8188                if (typeof store === 'undefined' || !store.codegen_request_state) {
8189                    return null;
8190                }
8191                let component_id = this.component && this.component.id;
8192                if (!component_id) {
8193                    return null;
8194                }
8195                return store.codegen_request_state[component_id] || null;
8196            },
8197            active_codegen_stream_entry() {
8198                /* eslint-disable-next-line no-unused-expressions */
8199                this.ai_stream_event_tick;
8200                if (typeof store === 'undefined' || !store.ai_streams || !store.ai_streams.get) {
8201                    return null;
8202                }
8203                let component_id = this.component && this.component.id;
8204                if (!component_id) {
8205                    return null;
8206                }
8207                return store.ai_streams.get('codegen:' + component_id);
8208            },
8209            effective_ai_streaming_status() {
8210                let s = this.codegen_request_status;
8211                if (s) {
8212                    return s;
8213                }
8214                return this.ai_streaming_status;
8215            },
8216            // Launcher visibility — one rule, one field.
8217            ai_generation_active() {
8218                let s = this.codegen_request_status;
8219                return s === 'starting' || s === 'queued' || s === 'streaming';
8220            },
8221            ai_panel_is_open() {
8222                if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) {
8223                    return false;
8224                }
8225                return !!store.components.ai_mcp_panel.expanded;
8226            },
8227            ai_panel_is_open_for_this_component() {
8228                if (!this.ai_panel_is_open || typeof store === 'undefined') {
8229                    return false;
8230                }
8231                store.webbuilder_code_active_component_revision;
8232                let component_id = this.component && this.component.id;
8233                if (!component_id) {
8234                    return false;
8235                }
8236                return store.webbuilder_code_active_component_id === component_id;
8237            },
8238            // Idle Edit with AI lives on the hover toolbar. This in-section
8239            // pill is only the generating/queued indicator.
8240            ai_launcher_visible() {
8241                if (this.ai_panel_is_open) {
8242                    return false;
8243                }
8244                return this.ai_generation_active;
8245            },
8246            // Dots overlay — visible while we have a stream that
8247            // hasn't started emitting code yet. Reads the central
8248            // status for the bookended states; reads the entry's
8249            // code_buffer (still owned by the streams manager) to
8250            // distinguish "streaming but no chunks yet" from
8251            // "streaming with content".
8252            ai_streaming_pending() {
8253                let s = this.codegen_request_status;
8254                if (s === 'starting' || s === 'queued') {
8255                    return true;
8256                }
8257                if (s !== 'streaming') {
8258                    return false;
8259                }
8260                let entry = this.active_codegen_stream_entry;
8261                if (this.ai_streaming_buffer && this.ai_streaming_buffer.trim()) {
8262                    return false;
8263                }
8264                return !!(entry && !(entry.code_buffer || '').trim());
8265            },
8266        },
8267        mounted() {
8268            this.loading = false;
8269            // Listen for the section-actions overlay's "Save external
8270            // images" action. The overlay only has the component Parse
8271            // object, so it broadcasts the id and the matching live
8272            // instance runs the batch (which owns the persist +
8273            // re-render path).
8274            if (typeof store !== 'undefined' && store.eventBus) {
8275                this._save_external_images_handler = (component) => {
8276                    let target_id = component && component.id ? component.id : component;
8277                    if (this.component && this.component.id === target_id) {
8278                        this.import_all_external_images_in_section();
8279                    }
8280                };
8281                store.eventBus.$on('webbuilderSaveExternalImages', this._save_external_images_handler);
8282                this._webbuilder_refresh_replay_handler = () => {
8283                    let component_id = this.component && this.component.id;
8284                    if (!component_id || !store.ai_streams || typeof store.ai_streams.get !== 'function') {
8285                        return;
8286                    }
8287                    let entry = store.ai_streams.get('codegen:' + component_id);
8288                    if (!entry) {
8289                        return;
8290                    }
8291                    this.subscribe_to_codegen_stream();
8292                };
8293                store.eventBus.$on('webbuilderPageRefreshReconciled', this._webbuilder_refresh_replay_handler);
8294            }
8295            if (this.preview_element) {
8296                let config_latest = (this.component && this.component.get('config_latest')) || {};
8297                this.custom_code_value = config_latest.custom_code || '';
8298                // Publish the live editor contents to the shared store
8299                // so the chat panel's per-message apply-state badge
8300                // can diff against it reactively without needing a
8301                // back-reference to this component.
8302                this.publish_current_code_to_store();
8303                this.$nextTick(() => {
8304                    this.cm_instance = CodeMirror(this.$refs.cm_editor, {
8305                        value: this.custom_code_value,
8306                        mode: 'htmlmixed',
8307                        lineNumbers: true,
8308                        autoCloseBrackets: true,
8309                        autoCloseTags: true,
8310                        matchBrackets: true,
8311                        lineWrapping: true,
8312                        tabSize: 2,
8313                        indentWithTabs: false,
8314                        extraKeys: {
8315                            'Tab': function(cm) {
8316                                cm.replaceSelection('  ', 'end');
8317                            }
8318                        },
8319                    });
8320                    this.cm_instance.on('change', (editor) => {
8321                        // Suppress save while we're driving the editor
8322                        // from a streaming buffer — the stream's `done`
8323                        // handler runs the canonical save once at the
8324                        // end, and persisting partial frames would
8325                        // spam the API.
8326                        if (this._suppress_change_save) {
8327                            // Still surface the live value to the
8328                            // store so the apply-state badge stays
8329                            // accurate during the typewriter — just
8330                            // don't fire the debounced save.
8331                            this.custom_code_value = editor.getValue();
8332                            this.publish_current_code_to_store();
8333                            return;
8334                        }
8335                        this.debounce(() => {
8336                            let value = editor.getValue();
8337                            this.custom_code_value = value;
8338                            this.publish_current_code_to_store();
8339                            save_changes_for_component(this.component, 'custom_code', value);
8340                            this.mark_ai_code_revision_changed();
8341                        });
8342                    });
8343                });
8344                // Subscribe to this component's codegen stream key so
8345                // the editor + preview update live, independently of
8346                // whether the chat panel is open. Re-subscribing on
8347                // every mount also covers the page-reload recovery
8348                // case — if a passive entry exists for our key, the
8349                // replay event hands us the partial buffer.
8350                this.subscribe_to_codegen_stream();
8351            }
8352            this.$nextTick(() => {
8353                this.initialize_custom_code_interactions();
8354                this.auto_resize_ai_prompt();
8355                this.setup_ai_builder_presence_observer();
8356                this.publish_active_tab_to_store();
8357                if (typeof store !== 'undefined' &&
8358                        store.webbuilder_code_active_component_id === (this.component && this.component.id)) {
8359                    store.webbuilder_code_active_component = this;
8360                    store.webbuilder_code_active_component_revision = Date.now();
8361                    this.subscribe_to_codegen_stream();
8362                }
8363            });
8364        },
8365        beforeUnmount() {
8366            this.close_text_edit_popover();
8367            this.cm_instance = null;
8368            if (typeof store !== 'undefined' && store.eventBus && this._save_external_images_handler) {
8369                if (typeof store.eventBus.$off === 'function') {
8370                    store.eventBus.$off('webbuilderSaveExternalImages', this._save_external_images_handler);
8371                } else if (store.eventBus.listeners && Array.isArray(store.eventBus.listeners.webbuilderSaveExternalImages)) {
8372                    store.eventBus.listeners.webbuilderSaveExternalImages = store.eventBus.listeners.webbuilderSaveExternalImages.filter(callback => {
8373                        return callback !== this._save_external_images_handler;
8374                    });
8375                }
8376                this._save_external_images_handler = null;
8377            }
8378            if (typeof store !== 'undefined' && store.eventBus && this._webbuilder_refresh_replay_handler) {
8379                if (typeof store.eventBus.$off === 'function') {
8380                    store.eventBus.$off('webbuilderPageRefreshReconciled', this._webbuilder_refresh_replay_handler);
8381                } else if (store.eventBus.listeners && Array.isArray(store.eventBus.listeners.webbuilderPageRefreshReconciled)) {
8382                    store.eventBus.listeners.webbuilderPageRefreshReconciled = store.eventBus.listeners.webbuilderPageRefreshReconciled.filter(callback => {
8383                        return callback !== this._webbuilder_refresh_replay_handler;
8384                    });
8385                }
8386                this._webbuilder_refresh_replay_handler = null;
8387            }
8388            // Unsubscribe — but do NOT cancel the underlying stream.
8389            // The entry stays alive in store.ai_streams so a remount
8390            // (e.g. component re-rendered after a save) can re-attach
8391            // and continue showing progress.
8392            if (typeof this._codegen_stream_unsubscribe === 'function') {
8393                try { this._codegen_stream_unsubscribe(); } catch (e) {}
8394                this._codegen_stream_unsubscribe = null;
8395            }
8396            if (this._codegen_stream_raf) {
8397                cancelAnimationFrame(this._codegen_stream_raf);
8398                this._codegen_stream_raf = null;
8399            }
8400            if (this._ai_streaming_starting_timer) {
8401                clearTimeout(this._ai_streaming_starting_timer);
8402                this._ai_streaming_starting_timer = null;
8403            }
8404            if (this._ai_welcome_generating_timer) {
8405                clearTimeout(this._ai_welcome_generating_timer);
8406                this._ai_welcome_generating_timer = null;
8407            }
8408            if (this.ai_loading_pane_height_release_timer) {
8409                clearTimeout(this.ai_loading_pane_height_release_timer);
8410                this.ai_loading_pane_height_release_timer = null;
8411            }
8412            if (this._ai_builder_presence_observer) {
8413                this._ai_builder_presence_observer.disconnect();
8414                this._ai_builder_presence_observer = null;
8415            }
8416            if (this._ai_builder_presence_timeout) {
8417                clearTimeout(this._ai_builder_presence_timeout);
8418                this._ai_builder_presence_timeout = null;
8419            }
8420            // If this component was the active target for the AI
8421            // builder, clear the pointer so the panel doesn't try to
8422            // apply code into a Vue instance that's no longer mounted.
8423            if (typeof store !== 'undefined' && store.webbuilder_code_active_component === this) {
8424                store.webbuilder_code_active_component = null;
8425                store.webbuilder_code_active_component_id = null;
8426            }
8427            // Drop this section's published tab state so a stale 'edit' value
8428            // can't keep the launcher shown after the section is gone.
8429            if (typeof store !== 'undefined'
8430                    && store.webbuilder_custom_code_tab_by_client_id
8431                    && this.component
8432                    && typeof this.component.get === 'function') {
8433                let cid = this.component.get('client_id');
8434                if (cid) {
8435                    delete store.webbuilder_custom_code_tab_by_client_id[cid];
8436                }
8437            }
8438        },
8439        methods: {
8440            page_has_multiple_custom_code_components() {
8441                if (typeof store === 'undefined' || !Array.isArray(store.page_components_latest)) {
8442                    return false;
8443                }
8444                let custom_code_count = 0;
8445                for (let i = 0; i < store.page_components_latest.length; i++) {
8446                    let page_component = store.page_components_latest[i];
8447                    if (page_component && page_component.get && page_component.get('type') === 'custom-code') {
8448                        custom_code_count++;
8449                    }
8450                    if (custom_code_count > 1) {
8451                        return true;
8452                    }
8453                }
8454                return false;
8455            },
8456            setup_ai_builder_presence_observer() {
8457                if (!this.preview_element) {
8458                    return;
8459                }
8460                if (!this.page_has_multiple_custom_code_components()) {
8461                    return;
8462                }
8463                if (typeof IntersectionObserver === 'undefined') {
8464                    return;
8465                }
8466                if (!this.$el || this.$el.nodeType !== 1) {
8467                    return;
8468                }
8469                this._ai_builder_presence_observer = new IntersectionObserver(entries => {
8470                    entries.forEach(entry => {
8471                        if (entry && entry.isIntersecting && entry.intersectionRatio >= 0.55) {
8472                            this.activate_ai_builder_target_from_presence();
8473                        }
8474                    });
8475                }, {
8476                    threshold: [0.55],
8477                });
8478                this._ai_builder_presence_observer.observe(this.$el);
8479            },
8480            activate_ai_builder_target_from_presence() {
8481                if (typeof store === 'undefined') {
8482                    return;
8483                }
8484                if (!this.page_has_multiple_custom_code_components()) {
8485                    return;
8486                }
8487                if (!store.ai_mcp_floating_panel_ui) {
8488                    return;
8489                }
8490                let panel_state = store.components && store.components.ai_mcp_panel;
8491                if (!panel_state || !panel_state.expanded) {
8492                    return;
8493                }
8494                if (panel_state.mcp_loading || (panel_state.mcp_prompt || '').trim()) {
8495                    return;
8496                }
8497                let messages = Array.isArray(panel_state.mcp_messages) ? panel_state.mcp_messages : [];
8498                let last_message = messages.length ? messages[messages.length - 1] : null;
8499                if (last_message && last_message.streaming) {
8500                    return;
8501                }
8502                let component_id = this.component && this.component.id ? this.component.id : null;
8503                if (!component_id || store.webbuilder_code_active_component_id === component_id) {
8504                    return;
8505                }
8506                if (this._ai_builder_presence_timeout) {
8507                    clearTimeout(this._ai_builder_presence_timeout);
8508                }
8509                this._ai_builder_presence_timeout = setTimeout(() => {
8510                    this.activate_ai_builder_target();
8511                    this._ai_builder_presence_timeout = null;
8512                }, 120);
8513            },
8514            activate_ai_builder_target(options) {
8515                if (typeof store === 'undefined') {
8516                    return;
8517                }
8518                // Reclaim the per-component codegen mode in case the
8519                // page-level webbuilder-agent launcher switched it.
8520                store.ai_mcp_mode = 'webbuilder-code';
8521                let opts = options || {};
8522                let previous_component_id = store.webbuilder_code_active_component_id || null;
8523                store.webbuilder_code_active_component = this;
8524                store.webbuilder_code_active_component_id = this.component && this.component.id ? this.component.id : null;
8525                store.webbuilder_code_active_component_revision = Date.now();
8526                this.subscribe_to_codegen_stream();
8527                let panel = store.ai_mcp_panel_component_instance || null;
8528                if (panel && typeof panel.switch_webbuilder_component_thread === 'function') {
8529                    panel.switch_webbuilder_component_thread(
8530                        store.webbuilder_code_active_component_id,
8531                        previous_component_id,
8532                        opts
8533                    );
8534                }
8535            },
8536            set_ai_prompt_example(example) {
8537                this.ai_prompt = example || '';
8538            },
8539            // ─── Welcome composer attachment handlers ────────────────
8540            //
8541            // All paths funnel through the global helper
8542            // ai_mcp_panel_attach_files which writes into the shared
8543            // chat panel state (canvas-resize + base64 prep happens
8544            // there). The welcome composer is a UI-only mirror — no
8545            // local Blob storage, no local prep.
8546            open_welcome_file_picker() {
8547                if (!this.welcome_file_upload_enabled) {
8548                    return;
8549                }
8550                if (this.ai_prompt_loading) {
8551                    return;
8552                }
8553                let input = this.$refs && this.$refs.ai_prompt_file_input;
8554                if (!input) {
8555                    return;
8556                }
8557                input.click();
8558            },
8559            handle_welcome_file_input_change(event) {
8560                let files = event && event.target && event.target.files
8561                    ? Array.from(event.target.files)
8562                    : [];
8563                if (event && event.target) {
8564                    event.target.value = '';
8565                }
8566                if (!files.length) {
8567                    return;
8568                }
8569                if (typeof ai_mcp_panel_attach_files === 'function') {
8570                    ai_mcp_panel_attach_files({
8571                        component: this,
8572                        files:     files,
8573                    });
8574                }
8575            },
8576            handle_welcome_dragenter(event) {
8577                if (!this.welcome_file_upload_enabled) {
8578                    return;
8579                }
8580                if (!this.welcome_event_has_image_files(event)) {
8581                    return;
8582                }
8583                event.preventDefault();
8584                this.welcome_drop_counter++;
8585                this.welcome_drop_active = this.welcome_drop_counter > 0;
8586            },
8587            handle_welcome_dragover(event) {
8588                if (!this.welcome_file_upload_enabled) {
8589                    return;
8590                }
8591                if (!this.welcome_event_has_image_files(event)) {
8592                    return;
8593                }
8594                if (event.dataTransfer) {
8595                    try {
8596                        event.dataTransfer.dropEffect = 'copy';
8597                    } catch (drop_effect_err) {}
8598                }
8599            },
8600            handle_welcome_dragleave() {
8601                if (!this.welcome_file_upload_enabled) {
8602                    return;
8603                }
8604                if (this.welcome_drop_counter > 0) {
8605                    this.welcome_drop_counter--;
8606                }
8607                if (this.welcome_drop_counter <= 0) {
8608                    this.welcome_drop_counter = 0;
8609                    this.welcome_drop_active  = false;
8610                }
8611            },
8612            handle_welcome_drop(event) {
8613                this.welcome_drop_counter = 0;
8614                this.welcome_drop_active  = false;
8615                if (!this.welcome_file_upload_enabled) {
8616                    return;
8617                }
8618                let files = event && event.dataTransfer && event.dataTransfer.files
8619                    ? Array.from(event.dataTransfer.files)
8620                    : [];
8621                if (!files.length) {
8622                    return;
8623                }
8624                let images = files.filter(function (file) {
8625                    return file && typeof file.type === 'string' && file.type.indexOf('image/') === 0;
8626                });
8627                if (!images.length) {
8628                    return;
8629                }
8630                if (typeof ai_mcp_panel_attach_files === 'function') {
8631                    ai_mcp_panel_attach_files({
8632                        component: this,
8633                        files:     images,
8634                    });
8635                }
8636            },
8637            handle_welcome_paste(event) {
8638                if (!this.welcome_file_upload_enabled) {
8639                    return;
8640                }
8641                if (!event || !event.clipboardData || !event.clipboardData.items) {
8642                    return;
8643                }
8644                let items = Array.from(event.clipboardData.items);
8645                let pasted = [];
8646                for (let i = 0; i < items.length; i++) {
8647                    let item = items[i];
8648                    if (!item || item.kind !== 'file') {
8649                        continue;
8650                    }
8651                    if (typeof item.type !== 'string' || item.type.indexOf('image/') !== 0) {
8652                        continue;
8653                    }
8654                    let file = item.getAsFile && item.getAsFile();
8655                    if (file) {
8656                        pasted.push(file);
8657                    }
8658                }
8659                if (!pasted.length) {
8660                    return;
8661                }
8662                event.preventDefault();
8663                if (typeof ai_mcp_panel_attach_files === 'function') {
8664                    ai_mcp_panel_attach_files({
8665                        component: this,
8666                        files:     pasted,
8667                    });
8668                }
8669            },
8670            welcome_event_has_image_files(event) {
8671                if (!event || !event.dataTransfer) {
8672                    return false;
8673                }
8674                let types = event.dataTransfer.types;
8675                if (!types) {
8676                    return false;
8677                }
8678                for (let i = 0; i < types.length; i++) {
8679                    if (types[i] === 'Files' || types[i] === 'application/x-moz-file') {
8680                        return true;
8681                    }
8682                }
8683                return false;
8684            },
8685            welcome_remove_attachment(index) {
8686                if (typeof store === 'undefined' || !store.ai_mcp_panel_component_instance) {
8687                    return;
8688                }
8689                let panel = store.ai_mcp_panel_component_instance;
8690                if (typeof panel.remove_attachment === 'function') {
8691                    panel.remove_attachment(index);
8692                }
8693            },
8694            // Auto-grow the welcome-page composer textarea. Starts at
8695            // a single line (height matches one line-height) and grows
8696            // up to the CSS max-height (~8 lines), after which the
8697            // textarea scrolls. We reset height to '0' before measuring
8698            // scrollHeight so the height can shrink as well as grow
8699            // (otherwise scrollHeight only ever increases).
8700            //
8701            // Called from @input (typing), the watcher on `ai_prompt`
8702            // (programmatic changes like example pill click /
8703            // post-submit clear), and once on mounted() to settle the
8704            // initial layout.
8705            auto_resize_ai_prompt() {
8706                let el = this.$refs && this.$refs.ai_prompt_textarea;
8707                if (!el) {
8708                    return;
8709                }
8710                el.style.height = '0px';
8711                let next = el.scrollHeight;
8712                if (!next) {
8713                    next = 21;
8714                }
8715                el.style.height = next + 'px';
8716            },
8717            submit_ai_prompt() {
8718                if (!this.ai_prompt_can_submit) {
8719                    return;
8720                }
8721                // The blank welcome composer now seeds the single page agent
8722                // (focused on this section) rather than the per-component
8723                // codegen chat. The page agent owns the conversation + send.
8724                if (typeof ai_mcp_panel_open_agent_with_reference !== 'function') {
8725                    this.open_ai_builder();
8726                    return;
8727                }
8728                let opened = ai_mcp_panel_open_agent_with_reference({
8729                    client_id:      this.component && this.component.get('client_id'),
8730                    component_type: 'custom-code',
8731                    seed_prompt:    this.ai_prompt,
8732                    auto_submit:    true,
8733                });
8734                if (opened) {
8735                    this.ai_prompt = '';
8736                    this.start_welcome_generating_state();
8737                } else {
8738                    this.open_ai_builder();
8739                }
8740            },
8741            // Swap the welcome composer for the "Generating…" pane while the
8742            // page agent produces the first result. Cleared when content lands
8743            // (custom_code_value watcher) or by a safety timer.
8744            start_welcome_generating_state() {
8745                this.active_tab = 'preview';
8746                this.close_text_edit_popover();
8747                this.ai_welcome_generating = true;
8748                if (this._ai_welcome_generating_timer) {
8749                    clearTimeout(this._ai_welcome_generating_timer);
8750                }
8751                this._ai_welcome_generating_timer = setTimeout(() => {
8752                    this.clear_welcome_generating_state();
8753                }, 90000);
8754            },
8755            clear_welcome_generating_state() {
8756                this.ai_welcome_generating = false;
8757                if (this._ai_welcome_generating_timer) {
8758                    clearTimeout(this._ai_welcome_generating_timer);
8759                    this._ai_welcome_generating_timer = null;
8760                }
8761            },
8762            // Writes 'starting' into the central store so the
8763            // launcher reflects intent instantly. store.ai_streams.start
8764            // overwrites this with 'queued' or 'streaming' once the
8765            // entry materializes. The 15s timer is the safety net for
8766            // the rare case where the panel never actually fires a
8767            // submit (e.g. the panel app didn't mount yet).
8768            start_ai_streaming_starting_state() {
8769                this.capture_ai_loading_pane_height();
8770                this.blur_ai_prompt_before_loading();
8771                let prior_scroll_y = window.scrollY;
8772                let component_id = this.component && this.component.id;
8773                if (typeof store !== 'undefined' && store.ai_streams && typeof store.ai_streams.mark_starting === 'function' && component_id) {
8774                    store.ai_streams.mark_starting(component_id);
8775                }
8776                this.active_tab = 'preview';
8777                this.close_text_edit_popover();
8778                this.$nextTick(() => {
8779                    if (Math.abs(window.scrollY - prior_scroll_y) > 1) {
8780                        window.scrollTo(window.scrollX, prior_scroll_y);
8781                    }
8782                });
8783                if (this._ai_streaming_starting_timer) {
8784                    clearTimeout(this._ai_streaming_starting_timer);
8785                }
8786                this._ai_streaming_starting_timer = setTimeout(() => {
8787                    this.clear_ai_streaming_starting_state();
8788                }, 15000);
8789            },
8790            // Clears the 'starting' bridge from central state if (and
8791            // only if) it's still the current value — never overwrites
8792            // a real downstream status like 'streaming' / 'done'.
8793            clear_ai_streaming_starting_state() {
8794                if (typeof store !== 'undefined' && store.codegen_request_state) {
8795                    let component_id = this.component && this.component.id;
8796                    if (component_id && store.codegen_request_state[component_id] === 'starting') {
8797                        delete store.codegen_request_state[component_id];
8798                    }
8799                }
8800                if (this._ai_streaming_starting_timer) {
8801                    clearTimeout(this._ai_streaming_starting_timer);
8802                    this._ai_streaming_starting_timer = null;
8803                }
8804            },
8805            // Idempotent hard-reset of every flag that feeds the
8806            // launcher / loading-overlay computeds. Called from the
8807            // panel's done / fail / snap paths so the launcher always
8808            // converges to idle even if a stream subscriber missed
8809            // an event, threw mid-handler, or fired its `done` while
8810            // the component was unmounted. Safe to call multiple
8811            // times. Does NOT touch the editor contents — the panel's
8812            // apply_generated_code is responsible for the canonical
8813            // code save.
8814            force_clear_ai_loading_state() {
8815                this.clear_ai_streaming_starting_state();
8816                this.ai_streaming = false;
8817                this.release_ai_loading_pane_height_after_render();
8818                if (this.ai_streaming_status === 'streaming' || this.ai_streaming_status === 'queued' || this.ai_streaming_status === null) {
8819                    this.ai_streaming_status = 'done';
8820                }
8821                this.ai_streaming_buffer = '';
8822                this._last_flushed_buffer_len = -1;
8823                this.ai_streaming_child_min_heights = [];
8824                if (this._codegen_stream_raf) {
8825                    cancelAnimationFrame(this._codegen_stream_raf);
8826                    this._codegen_stream_raf = null;
8827                }
8828                if (this.cm_instance && this.cm_instance.getOption && this.cm_instance.getOption('readOnly')) {
8829                    this.cm_instance.setOption('readOnly', false);
8830                }
8831                this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0;
8832            },
8833            capture_ai_loading_pane_height() {
8834                if (this.ai_loading_pane_height_release_timer) {
8835                    clearTimeout(this.ai_loading_pane_height_release_timer);
8836                    this.ai_loading_pane_height_release_timer = null;
8837                }
8838                // `$el` can be a Comment / Text node when the root
8839                // `<template v-if>` is currently false (Vue 3 treats
8840                // a falsy single-root v-if as a placeholder comment),
8841                // and it lacks querySelectorAll. Bail in that case
8842                // so the streaming subscriber doesn't throw on every
8843                // pane-height capture.
8844                let root = this.$el;
8845                if (!root || typeof root.querySelectorAll !== 'function') {
8846                    return;
8847                }
8848                let panes = Array.prototype.slice.call(root.querySelectorAll('.custom-code-preview-pane'));
8849                let visible = panes.find((pane) => {
8850                    if (!pane || pane.classList.contains('custom-code-loading-pane')) {
8851                        return false;
8852                    }
8853                    let style = window.getComputedStyle(pane);
8854                    return style.display !== 'none' && pane.getClientRects().length > 0;
8855                });
8856                if (!visible) {
8857                    let hidden_height = this.measure_hidden_custom_code_preview_height();
8858                    if (hidden_height > 0) {
8859                        this.ai_loading_pane_height = Math.max(420, hidden_height);
8860                    }
8861                    return;
8862                }
8863                let rect_height = visible.getBoundingClientRect().height || 0;
8864                let scroll_height = visible.scrollHeight || 0;
8865                let height = Math.ceil(Math.max(rect_height, scroll_height));
8866                if (height > 0) {
8867                    this.ai_loading_pane_height = Math.max(420, height);
8868                }
8869                this.capture_streaming_child_min_heights_from_node(visible);
8870            },
8871            measure_hidden_custom_code_preview_height() {
8872                let root = this.$el;
8873                if (!root || typeof root.getBoundingClientRect !== 'function') {
8874                    return 0;
8875                }
8876                let html = this.safe_custom_code_value || '';
8877                if (!html.trim()) {
8878                    return 0;
8879                }
8880                let root_rect = root.getBoundingClientRect();
8881                let width = Math.floor(root_rect.width || root.clientWidth || 0);
8882                if (width <= 0 && root.parentElement) {
8883                    let parent_rect = root.parentElement.getBoundingClientRect();
8884                    width = Math.floor(parent_rect.width || root.parentElement.clientWidth || 0);
8885                }
8886                if (width <= 0) {
8887                    return 0;
8888                }
8889                let probe = document.createElement('div');
8890                probe.className = 'custom-code-pane custom-code-preview-pane';
8891                probe.style.position = 'absolute';
8892                probe.style.visibility = 'hidden';
8893                probe.style.pointerEvents = 'none';
8894                probe.style.left = '-10000px';
8895                probe.style.top = '0';
8896                probe.style.width = width + 'px';
8897                probe.style.height = 'auto';
8898                probe.style.overflow = 'visible';
8899                probe.style.boxSizing = 'border-box';
8900                probe.innerHTML = html;
8901                document.body.appendChild(probe);
8902                let rect_height = probe.getBoundingClientRect().height || 0;
8903                let scroll_height = probe.scrollHeight || 0;
8904                let height = Math.ceil(Math.max(rect_height, scroll_height));
8905                this.capture_streaming_child_min_heights_from_node(probe);
8906                probe.remove();
8907                return height;
8908            },
8909            capture_streaming_child_min_heights_from_node(node) {
8910                if (!node || !node.childNodes) {
8911                    this.ai_streaming_child_min_heights = [];
8912                    return;
8913                }
8914                this.ai_streaming_child_min_heights = Array.prototype.slice.call(node.childNodes).map((child) => {
8915                    if (!child || child.nodeType !== 1) {
8916                        return 0;
8917                    }
8918                    let rect_height = child.getBoundingClientRect().height || 0;
8919                    let scroll_height = child.scrollHeight || 0;
8920                    return Math.ceil(Math.max(rect_height, scroll_height));
8921                });
8922            },
8923            release_ai_loading_pane_height_after_render() {
8924                if (this.ai_loading_pane_height_release_timer) {
8925                    clearTimeout(this.ai_loading_pane_height_release_timer);
8926                    this.ai_loading_pane_height_release_timer = null;
8927                }
8928                if (!this.ai_loading_pane_height) {
8929                    this.ai_streaming_child_min_heights = [];
8930                    return;
8931                }
8932                this.ai_loading_pane_height_release_timer = setTimeout(() => {
8933                    this.ai_loading_pane_height = null;
8934                    this.ai_streaming_child_min_heights = [];
8935                    this.ai_loading_pane_height_release_timer = null;
8936                }, 50);
8937            },
8938            blur_ai_prompt_before_loading() {
8939                let prompt = this.$refs && this.$refs.ai_prompt_textarea;
8940                if (!prompt || document.activeElement !== prompt) {
8941                    return;
8942                }
8943                prompt.blur();
8944            },
8945            // Launcher click — pin this component as the AI panel's
8946            // active target and expand the panel. The panel runs in
8947            // a separate Vue app (app_ai_mcp, mounted in
8948            // web-builder/primary.ejs) and watches
8949            // store.webbuilder_code_active_component_id; setting it
8950            // here triggers the panel to load this component's thread.
8951            open_ai_builder() {
8952                if (typeof store === 'undefined') {
8953                    return;
8954                }
8955                // Open the single page agent focused on this section. (The old
8956                // per-component codegen activation is left dormant below for a
8957                // future live agent->codegen stream hookup.)
8958                if (typeof ai_mcp_panel_open_agent_with_reference === 'function'
8959                    && this.component
8960                    && typeof this.component.get === 'function') {
8961                    ai_mcp_panel_open_agent_with_reference({
8962                        client_id:      this.component.get('client_id'),
8963                        component_type: 'custom-code',
8964                    });
8965                    return;
8966                }
8967                let panel_state = store.components && store.components.ai_mcp_panel;
8968                if (panel_state) {
8969                    panel_state.user_toggled = true;
8970                    panel_state.expanded = true;
8971                    if (panel_state.mcp_panel_width) {
8972                        document.body.style.setProperty('--mcp-panel-width', panel_state.mcp_panel_width + 'px');
8973                    }
8974                    document.body.classList.add('ai-mcp-expanded');
8975                }
8976                this.activate_ai_builder_target({ force: true });
8977            },
8978            mark_ai_thread_for_component() {
8979                if (typeof store === 'undefined') {
8980                    return;
8981                }
8982                let component_id = this.component && this.component.id;
8983                if (!component_id) {
8984                    return;
8985                }
8986                if (!store.webbuilder_code_component_has_ai_thread) {
8987                    store.webbuilder_code_component_has_ai_thread = {};
8988                }
8989                store.webbuilder_code_component_has_ai_thread[component_id] = true;
8990            },
8991            contains_ai_builder_target(target) {
8992                if (!target) {
8993                    return false;
8994                }
8995                if (this.$el && this.$el.contains(target)) {
8996                    return true;
8997                }
8998                if (this.$refs.cm_editor && this.$refs.cm_editor.contains(target)) {
8999                    return true;
9000                }
9001                // The text-edit popover lives at document.body level
9002                // (position: fixed) so it isn't inside $el. Treat clicks
9003                // landing on it as in-component interactions so the AI
9004                // panel's outside-click handler doesn't auto-close
9005                // while the admin is typing in the popover.
9006                if (typeof target.closest === 'function' && target.closest('.cs-text-edit-popover')) {
9007                    return true;
9008                }
9009                return false;
9010            },
9011            get_current_code() {
9012                if (this.cm_instance) {
9013                    return this.cm_instance.getValue();
9014                }
9015                if (typeof this.custom_code_value === 'string') {
9016                    return this.custom_code_value;
9017                }
9018                if (this.component) {
9019                    let config_latest = this.component.get('config_latest') || {};
9020                    return config_latest.custom_code || '';
9021                }
9022                return '';
9023            },
9024            mark_ai_code_revision_changed() {
9025                if (typeof store === 'undefined') {
9026                    return;
9027                }
9028                if (store.webbuilder_code_active_component !== this) {
9029                    return;
9030                }
9031                store.webbuilder_code_active_component_revision = Date.now();
9032            },
9033            custom_code_render_root() {
9034                let host = null;
9035                if (this.preview_element) {
9036                    host = this.$refs.custom_code_preview || null;
9037                } else {
9038                    host = this.$refs.custom_code_static || null;
9039                }
9040                if (!host) {
9041                    return null;
9042                }
9043                let html = this.preview_element
9044                    ? (this.safe_custom_code_value || '')
9045                    : (this.safe_custom_code || '');
9046                let root = this.ensure_isolated_custom_code_host(host, html);
9047                this.apply_first_section_top_spacer(root);
9048                return root;
9049            },
9050            first_section_top_spacer_px(){
9051                if( this.first_section_top_spacer_px_override !== null ){
9052                    return normalize_first_section_top_spacer_px(this.first_section_top_spacer_px_override);
9053                }
9054                if( !this.is_first || !this.component ){
9055                    return 0;
9056                }
9057                let config_latest = this.component.get('config_latest') || {};
9058                let formatting = config_latest.formatting || {};
9059                return normalize_first_section_top_spacer_px(formatting.first_section_top_spacer_px);
9060            },
9061            apply_first_section_top_spacer(root){
9062                if( typeof apply_first_section_top_spacer_to_root === 'function' ){
9063                    apply_first_section_top_spacer_to_root(root, this.first_section_top_spacer_px());
9064                    return;
9065                }
9066                if( !root ){
9067                    return;
9068                }
9069            },
9070            // Mount sanitized HTML into an open shadow root so hosted theme
9071            // CSS cannot override section styles (preview ≈ live).
9072            ensure_isolated_custom_code_host(host, html) {
9073                if (!host || typeof host.attachShadow !== 'function') {
9074                    // Very old browsers: fall back to light-DOM innerHTML.
9075                    host.innerHTML = html || '';
9076                    return host;
9077                }
9078                let shadow = host.shadowRoot;
9079                if (!shadow) {
9080                    shadow = host.attachShadow({ mode: 'open' });
9081                    // Font Awesome lives on the light DOM page; clone its
9082                    // stylesheet so icons inside custom-code still render.
9083                    let fa_link = document.querySelector(
9084                        'link[href*="font-awesome"], link[href*="fontawesome"], link[href*="FontAwesome"]'
9085                    );
9086                    if (fa_link) {
9087                        let cloned = fa_link.cloneNode(true);
9088                        cloned.setAttribute('data-cs-fa-isolation', '1');
9089                        shadow.appendChild(cloned);
9090                    }
9091                    let root = document.createElement('div');
9092                    root.className = 'cs-cc-root';
9093                    shadow.appendChild(root);
9094                }
9095                // Only direct shadow children — never a <style> inside the
9096                // section HTML (querySelector would walk into .cs-cc-root).
9097                let isolation_style = null;
9098                let shadow_children = shadow.children;
9099                for (let i = 0; i < shadow_children.length; i++) {
9100                    let node = shadow_children[i];
9101                    if (node.tagName === 'STYLE') {
9102                        if (node.getAttribute('data-cs-isolation') === '1') {
9103                            isolation_style = node;
9104                            break;
9105                        }
9106                    }
9107                }
9108                if (!isolation_style) {
9109                    isolation_style = document.createElement('style');
9110                    isolation_style.setAttribute('data-cs-isolation', '1');
9111                    shadow.insertBefore(isolation_style, shadow.firstChild);
9112                }
9113                isolation_style.textContent = CS_CUSTOM_CODE_ISOLATION_CSS;
9114                if (this.preview_element) {
9115                    isolation_style.textContent += '\n' + CS_CUSTOM_CODE_PREVIEW_CHROME_CSS;
9116                }
9117                let content_root = shadow.querySelector('.cs-cc-root');
9118                if (!content_root) {
9119                    content_root = document.createElement('div');
9120                    content_root.className = 'cs-cc-root';
9121                    shadow.appendChild(content_root);
9122                }
9123                // Compare against the source we last wrote, not live
9124                // innerHTML. Decoration injects Change/Add chips into
9125                // the tree; a live compare would see that as a mismatch
9126                // and wipe the chips on the next render_root call
9127                // (text-edit decorate runs immediately after).
9128                let next_html = html || '';
9129                if (content_root.__cs_source_html !== next_html) {
9130                    content_root.innerHTML = next_html;
9131                    content_root.__cs_source_html = next_html;
9132                }
9133                return content_root;
9134            },
9135            sanitize_custom_code_html(html) {
9136                if (!html || typeof html !== 'string') {
9137                    return '';
9138                }
9139                let template = document.createElement('template');
9140                template.innerHTML = html;
9141                template.content.querySelectorAll('script').forEach((node) => {
9142                    node.remove();
9143                });
9144                // Drop external FA kits — they override Clubspot's Pro icons document-wide.
9145                template.content.querySelectorAll('link[rel="stylesheet"], link[rel="preload"]').forEach((node) => {
9146                    let href = String(node.getAttribute('href') || '').toLowerCase();
9147                    if (
9148                        href.indexOf('font-awesome') >= 0
9149                        || href.indexOf('fontawesome') >= 0
9150                        || href.indexOf('kit.fontawesome') >= 0
9151                    ) {
9152                        node.remove();
9153                    }
9154                });
9155                template.content.querySelectorAll('*').forEach((node) => {
9156                    Array.prototype.slice.call(node.attributes || []).forEac
9156h((attr) => {
9157                        let name = String(attr.name || '').toLowerCase();
9158                        let value = String(attr.value || '').trim().toLowerCase();
9159                        if (name.indexOf('on') === 0) {
9160                            node.removeAttribute(attr.name);
9161                        }
9162                        if ((name === 'href' || name === 'src' || name === 'xlink:href') && value.indexOf('javascript:') === 0) {
9163                            node.removeAttribute(attr.name);
9164                        }
9165                    });
9166                });
9167                return template.innerHTML;
9168            },
9169            initialize_custom_code_interactions() {
9170                let root = this.custom_code_render_root();
9171                if (!root) {
9172                    return;
9173                }
9174                this.apply_first_section_top_spacer(root);
9175                // Show/hide a tab panel. The `hidden` attribute alone is
9176                // not reliable: panels usually carry an inline `display`
9177                // (e.g. `display:flex`), and an inline declaration beats
9178                // the UA `[hidden]{display:none}` rule, so inactive panels
9179                // would stay visible and every tab would render at once.
9180                // Drive `display` directly (remembering each panel's
9181                // original inline value) and keep `hidden` for semantics.
9182                let set_panel_active = (panel, active) => {
9183                    if (panel.__csPanelDisplay === undefined) {
9184                        panel.__csPanelDisplay = panel.style.display || '';
9185                    }
9186                    panel.hidden = !active;
9187                    panel.classList.toggle('is-active', active);
9188                    panel.style.display = active ? panel.__csPanelDisplay : 'none';
9189                };
9190                let find_tab_panels = (group) => {
9191                    let panels = Array.prototype.slice.call(group.querySelectorAll('[data-cs-panel]'));
9192                    if (panels.length) {
9193                        return panels;
9194                    }
9195                    let panel_scope = group.closest('section') || root;
9196                    return Array.prototype.slice.call(panel_scope.querySelectorAll('[data-cs-panel]'));
9197                };
9198                let sync_tabs = () => {
9199                    root.querySelectorAll('[data-cs-tabs]').forEach((group) => {
9200                        let tabs = Array.prototype.slice.call(group.querySelectorAll('[data-cs-tab]'));
9201                        let panels = find_tab_panels(group);
9202                        if (!tabs.length || !panels.length) {
9203                            return;
9204                        }
9205                        let active_tab = tabs.find((tab) => tab.classList.contains('is-active')) || tabs[0];
9206                        let active_name = active_tab.getAttribute('data-cs-tab');
9207                        tabs.forEach((tab) => {
9208                            let active = tab === active_tab;
9209                            tab.classList.toggle('is-active', active);
9210                            tab.setAttribute('aria-selected', active ? 'true' : 'false');
9211                        });
9212                        panels.forEach((panel) => {
9213                            set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name);
9214                        });
9215                    });
9216                };
9217                // The switcher group for a `<select data-cs-select>`: its wrapping
9218                // [data-cs-tabs] if present, else its section, else root — the same
9219                // scope rule tabs use, so a select can drive the same panels.
9220                let find_switcher_group = (el) => el.closest('[data-cs-tabs]') || el.closest('section') || root;
9221                // `<select data-cs-select>` whose `<option value="X">` reveal the
9222                // matching `[data-cs-panel="X"]`. The native control renders/opens
9223                // with no JS; only the panel switch needs the runtime. Ideal for a
9224                // many-option filter (year/category) where tabs would be unwieldy.
9225                let sync_selects = () => {
9226                    root.querySelectorAll('[data-cs-select]').forEach((select) => {
9227                        let panels = find_tab_panels(find_switcher_group(select));
9228                        if (!panels.length) {
9229                            return;
9230                        }
9231                        let active_name = (select.value !== null && select.value !== '')
9232                            ? select.value
9233                            : panels[0].getAttribute('data-cs-panel');
9234                        panels.forEach((panel) => {
9235                            set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name);
9236                        });
9237                    });
9238                };
9239                let sync_all = () => {
9240                    sync_tabs();
9241                    sync_selects();
9242                };
9243                if (root.__csCustomCodeInteractionsInitialized) {
9244                    sync_all();
9245                    if (this.preview_element) {
9246                        this.decorate_asset_placeholders();
9247                        this.decorate_editable_text_nodes();
9248                    }
9249                    return;
9250                }
9251                root.__csCustomCodeInteractionsInitialized = true;
9252                if (this.preview_element) {
9253                    this.decorate_asset_placeholders();
9254                    this.decorate_editable_text_nodes();
9255                }
9256                root.addEventListener('click', (event) => {
9257                    let trigger = event.target && event.target.closest('[data-cs-toggle], [data-cs-tab]');
9258                    if (!trigger || !root.contains(trigger)) {
9259                        return;
9260                    }
9261
9262                    let toggle_selector = trigger.getAttribute('data-cs-toggle');
9263                    if (toggle_selector) {
9264                        event.preventDefault();
9265                        let target = root.querySelector(toggle_selector);
9266                        if (target) {
9267                            let class_name = trigger.getAttribute('data-cs-toggle-class') || 'is-active';
9268                            target.classList.toggle(class_name);
9269                            let expanded = target.classList.contains(class_name);
9270                            trigger.setAttribute('aria-expanded', expanded ? 'true' : 'false');
9271                        }
9272                    }
9273
9274                    let tab_name = trigger.getAttribute('data-cs-tab');
9275                    if (tab_name) {
9276                        event.preventDefault();
9277                        let group = trigger.closest('[data-cs-tabs]') || root;
9278                        group.querySelectorAll('[data-cs-tab]').forEach((tab) => {
9279                            tab.classList.toggle('is-active', tab === trigger);
9280                            tab.setAttribute('aria-selected', tab === trigger ? 'true' : 'false');
9281                        });
9282                        find_tab_panels(group).forEach((panel) => {
9283                            set_panel_active(panel, panel.getAttribute('data-cs-panel') === tab_name);
9284                        });
9285                    }
9286                });
9287                root.addEventListener('change', (event) => {
9288                    let select = event.target && event.target.closest('[data-cs-select]');
9289                    if (!select || !root.contains(select)) {
9290                        return;
9291                    }
9292                    let active_name = select.value;
9293                    find_tab_panels(find_switcher_group(select)).forEach((panel) => {
9294                        set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name);
9295                    });
9296                });
9297                sync_all();
9298            },
9299            // ─── Asset placeholders ───────────────────────────────────
9300            //
9301            // The AI route plants inert `<div data-cs-asset-placeholder>`
9302            // (or `<a>`) elements in the section HTML when it needs an
9303            // asset it couldn't verify. The live (public) site renders
9304            // those placeholders as styled fallback wrappers as-is —
9305            // they're authored to look intentional. In PREVIEW mode
9306            // (admin builder) we overlay a click-to-fill control on top
9307            // of each placeholder so the admin can choose a club photo,
9308            // upload a new one, or paste a URL. On selection we mutate
9309            // the persisted HTML so the next page render shows the
9310            // attached asset.
9311            //
9312            // Two element states:
9313            //   - `data-cs-asset-placeholder="<id>"`  unfilled slot;
9314            //     gets the big full-bleed "Add image" overlay.
9315            //   - `data-cs-asset-attached="<id>"`     filled slot;
9316            //     gets the small top-right "Change" chip.
9317            //
9318            // We keep the wrapper element across the fill transition —
9319            // the wrapper preserves the section's layout box (size,
9320            // border-radius, positioning) and we just swap its inner
9321            // content for an `<img>`/`<a>`/`background-image`. That way
9322            // the section never reflows when an admin fills a slot, and
9323            // styled fallback "frames" the resolved asset.
9324            //
9325            // Sequencing: decorate runs after every preview re-render
9326            // (initialize_custom_code_interactions). The overlay DOM
9327            // lives ONLY in the live preview tree — we never persist
9328            // the overlay markup back to `custom_code`.
9329
9330            decorate_asset_placeholders() {
9331                if (!this.preview_element) {
9332                    return;
9333                }
9334                let root = this.custom_code_render_root();
9335                if (!root) {
9336                    return;
9337                }
9338                // Unfilled placeholders → big "Add image" overlay.
9339                let placeholders = root.querySelectorAll('[data-cs-asset-placeholder]');
9340                placeholders.forEach((placeholder) => {
9341                    if (placeholder.__cs_asset_decorated) {
9342                        return;
9343                    }
9344                    placeholder.__cs_asset_decorated = true;
9345                    let computed_position = window.getComputedStyle(placeholder).position;
9346                    if (computed_position === 'static') {
9347                        placeholder.style.position = 'relative';
9348                    }
9349                    placeholder.classList.add('cs-asset-placeholder-host');
9350                    placeholder.style.cursor = 'pointer';
9351
9352                    let id = placeholder.getAttribute('data-cs-asset-placeholder') || '';
9353                    let target = (placeholder.getAttribute('data-cs-asset-target') || '').toLowerCase();
9354                    if (target === 'background-image') {
9355                        placeholder.classList.add('cs-asset-background-placeholder-host');
9356                    }
9357                    let label_attr = (placeholder.getAttribute('data-cs-asset-label') || '').trim();
9358                    let label = label_attr || 'asset';
9359
9360                    if (target === 'background-image') {
9361                        let background_chip = document.createElement('button');
9362                        background_chip.type = 'button';
9363                        background_chip.className = 'cs-asset-background-placeholder-chip exclude-from-custom-button-styles';
9364                        background_chip.setAttribute('data-cs-asset-placeholder-id', id);
9365                        background_chip.setAttribute('aria-label', 'Add ' + label);
9366                        background_chip.setAttribute('title', 'Add ' + label);
9367                        background_chip.innerHTML = '<i class="fa-regular fa-image"></i>';
9368                        background_chip.addEventListener('mousedown', (event) => {
9369                            event.stopPropagation();
9370                        });
9371                        background_chip.addEventListener('click', (event) => {
9372                            event.preventDefault();
9373                            event.stopPropagation();
9374                            this.open_placeholder_menu(id, { mode: 'placeholder' });
9375                        });
9376                        let background_chip_host = placeholder;
9377                        if (placeholder.parentElement
9378                                && placeholder.parentElement !== root
9379                                && root.contains(placeholder.parentElement)) {
9380                            background_chip_host = placeholder.parentElement;
9381                            if (window.getComputedStyle(background_chip_host).position === 'static') {
9382                                background_chip_host.style.position = 'relative';
9383                            }
9384                        }
9385                        background_chip_host.appendChild(background_chip);
9386                    } else {
9387                        let overlay = document.createElement('button');
9388                        overlay.type = 'button';
9389                        overlay.className = 'cs-asset-placeholder-overlay exclude-from-custom-button-styles';
9390                        overlay.setAttribute('data-cs-asset-placeholder-id', id);
9391                        overlay.innerHTML =
9392                            '<span class="cs-asset-placeholder-icon"><i class="fa-regular fa-image"></i></span>' +
9393                            '<span class="cs-asset-placeholder-label">Add ' + this.escape_html_text(label) + '</span>';
9394                        overlay.addEventListener('mousedown', (event) => {
9395                            event.stopPropagation();
9396                        });
9397                        overlay.addEventListener('click', (event) => {
9398                            event.preventDefault();
9399                            event.stopPropagation();
9400                            this.open_placeholder_menu(id, { mode: 'placeholder' });
9401                        });
9402                        placeholder.appendChild(overlay);
9403                    }
9404
9405                    // Host-level click — make the entire placeholder area
9406                    // clickable so admins can tap anywhere inside the
9407                    // styled fallback wrapper to open the fill menu, not
9408                    // just the centered overlay button. The overlay's own
9409                    // listener stops propagation, so we only fire when
9410                    // the click originated outside it (e.g. on padding,
9411                    // background, or fallback child text/icons).
9412                    placeholder.addEventListener('click', (event) => {
9413                        if (event.target.closest('.cs-asset-placeholder-overlay')) {
9414                            return;
9415                        }
9416                        if (event.target.closest('.cs-asset-background-placeholder-chip')) {
9417                            return;
9418                        }
9419                        if (target === 'background-image' && event.target !== placeholder) {
9420                            return;
9421                        }
9422                        event.preventDefault();
9423                        event.stopPropagation();
9424                        this.open_placeholder_menu(id, { mode: 'placeholder' });
9425                    });
9426                });
9427
9428                // Filled placeholders → small "Change" chip.
9429                let attached = root.querySelectorAll('[data-cs-asset-attached]');
9430                attached.forEach((attached_el) => {
9431                    if (attached_el.__cs_asset_attached_decorated) {
9432                        return;
9433                    }
9434                    attached_el.__cs_asset_attached_decorated = true;
9435                    let computed_position = window.getComputedStyle(attached_el).position;
9436                    if (computed_position === 'static') {
9437                        attached_el.style.position = 'relative';
9438                    }
9439                    attached_el.classList.add('cs-asset-attached-host');
9440                    attached_el.style.cursor = 'pointer';
9441
9442                    let id = attached_el.getAttribute('data-cs-asset-attached') || '';
9443                    let change_button = document.createElement('button');
9444                    change_button.type = 'button';
9445                    change_button.className = 'cs-asset-change-button exclude-from-custom-button-styles';
9446                    change_button.setAttribute('data-cs-asset-attached-id', id);
9447                    change_button.innerHTML =
9448                        '<i class="fa-regular fa-image" style="margin-right:5px;"></i>' +
9449                        '<span>Change</span>';
9450                    change_button.addEventListener('mousedown', (event) => {
9451                        event.stopPropagation();
9452                    });
9453                    change_button.addEventListener('click', (event) => {
9454                        event.preventDefault();
9455                        event.stopPropagation();
9456                        this.open_placeholder_menu(id, { mode: 'attached' });
9457                    });
9458                    attached_el.appendChild(change_button);
9459
9460                    // Host-level click — clicking anywhere on the filled
9461                    // image / link area opens the same change menu the
9462                    // small "Change" hover chip uses. The chip's listener
9463                    // stops propagation, so this only fires for clicks
9464                    // outside it (e.g. directly on the image surface).
9465                    attached_el.addEventListener('click', (event) => {
9466                        if (event.target.closest('.cs-asset-change-button')) {
9467                            return;
9468                        }
9469                        event.preventDefault();
9470                        event.stopPropagation();
9471                        this.open_placeholder_menu(id, { mode: 'attached' });
9472                    });
9473                });
9474
9475                // Bare `<img>` tags that weren't emitted as a
9476                // `data-cs-asset-*` slot — for example, every image the
9477                // model dropped in during a `capture_web_reference` build
9478                // (`<img src="https://oakmont.../">` directly). Without
9479                // this pass those imgs had no change/import affordance,
9480                // so the only way to swap one out was to hand-edit the
9481                // section HTML.
9482                //
9483                // Implementation:
9484                //   - Wrap each bare img in a transient `<span
9485                //     class="cs-bare-image-host">` host purely in the
9486                //     LIVE preview DOM. The persisted custom_code never
9487                //     sees this wrapper — it's recreated every time
9488                //     `decorate_asset_placeholders` runs.
9489                //   - Append a "Change" chip into the host. The chip
9490                //     reuses `.cs-asset-change-button` so it matches
9491                //     filled placeholder slots.
9492                //   - On click, route to a bare-img menu that uses
9493                //     `src`-string replacement against the persisted
9494                //     html instead of the `data-cs-asset-attached`
9495                //     marker lookup the placeholder pipeline depends
9496                //     on. That lets us avoid touching the persisted
9497                //     HTML until the admin actually picks a different
9498                //     image — there's no surprise `unsaved_changes`
9499                //     bump just from opening a reference-built section.
9500                let bare_imgs = Array.from(root.querySelectorAll('img')).filter((img) => {
9501                    if (img.__cs_bare_image_decorated) {
9502                        return false;
9503                    }
9504                    if (img.closest('[data-cs-asset-placeholder], [data-cs-asset-attached]')) {
9505                        return false;
9506                    }
9507                    if (img.parentElement && img.parentElement.classList && img.parentElement.classList.contains('cs-bare-image-host')) {
9508                        return false;
9509                    }
9510                    let src = img.getAttribute('src') || '';
9511                    return src.trim().length > 0;
9512                });
9513                bare_imgs.forEach((img) => {
9514                    img.__cs_bare_image_decorated = true;
9515                    if (!img.parentNode) {
9516                        return;
9517                    }
9518                    let host = document.createElement('span');
9519                    host.className = 'cs-bare-image-host cs-asset-attached-host';
9520                    host.setAttribute('aria-hidden', 'true');
9521                    // line-height: 0 kills the descender gap an inline
9522                    // img would otherwise produce; max-width lets the
9523                    // img stay responsive inside flex/grid layouts.
9524                    host.style.cssText = 'position: relative; display: inline-block; line-height: 0; max-width: 100%;';
9525                    img.parentNode.insertBefore(host, img);
9526                    host.appendChild(img);
9527
9528                    let change_button = document.createElement('button');
9529                    change_button.type = 'button';
9530                    change_button.className = 'cs-asset-change-button cs-bare-image-change-button exclude-from-custom-button-styles';
9531                    change_button.innerHTML =
9532                        '<i class="fa-regular fa-image" style="margin-right:5px;"></i>' +
9533                        '<span>Change</span>';
9534                    change_button.addEventListener('mousedown', (event) => {
9535                        event.stopPropagation();
9536                    });
9537                    change_button.addEventListener('click', (event) => {
9538                        event.preventDefault();
9539                        event.stopPropagation();
9540                        this.open_bare_image_menu(img);
9541                    });
9542                    host.appendChild(change_button);
9543
9544                    // Host click → same menu, for clicks on the image
9545                    // surface itself (chip stops propagation so this
9546                    // only fires when the user clicks outside it).
9547                    host.addEventListener('click', (event) => {
9548                        if (event.target.closest('.cs-asset-change-button')) {
9549                            return;
9550                        }
9551                        event.preventDefault();
9552                        event.stopPropagation();
9553                        this.open_bare_image_menu(img);
9554                    });
9555                });
9556            },
9557            escape_html_text(text) {
9558                if (typeof text !== 'string') {
9559                    return '';
9560                }
9561                let div = document.createElement('div');
9562                div.appendChild(document.createTextNode(text));
9563                return div.innerHTML;
9564            },
9565            open_placeholder_menu(id, options_in) {
9566                let opts = options_in || {};
9567                let mode = opts.mode || 'placeholder';
9568                if (typeof handle_common !== 'function') {
9569                    // Fallback: jump straight to the image picker.
9570                    this.pick_existing_for_placeholder(id);
9571                    return;
9572                }
9573                let target_el = this.find_asset_element_by_id(id);
9574                if (!target_el) {
9575                    return;
9576                }
9577                let accepts = (target_el.getAttribute('data-cs-asset-accepts') || 'image').toLowerCase();
9578                let label_attr = (target_el.getAttribute('data-cs-asset-label') || '').trim();
9579                let label = label_attr || 'asset';
9580                // Sniff the currently-attached URL so we can offer
9581                // "Copy image to club photos" only when it actually
9582                // points somewhere outside the Clubspot CDN. The same
9583                // helper handles <img src>, <a href>, and inline
9584                // background-image: url(...) wrappers.
9585                let attached_url = mode === 'attached'
9586                    ? this.return_attached_url_for_wrapper(target_el)
9587                    : null;
9588                let attached_is_external = accepts === 'image' && this.is_external_image_url(attached_url);
9589                let options = [];
9590                // Photos picker — same webbuilder asset picker, filtered to photos.
9591                if (accepts === 'image') {
9592                    options.push({
9593                        text:    mode === 'attached' ? 'Pick a different photo' : 'Upload or choose from club photos',
9594                        subtext: 'Upload a new image or pick an existing one from this club',
9595                        fn:      () => this.pick_existing_for_placeholder(id),
9596                    });
9597                }
9598                // Documents/files picker — `webbuilder_asset_picker`
9599                // overlay, backed by `browse_webbuilder_assets`. Covers
9600                // PDFs and any public-access club document. Also offered
9601                // for image accepts because admins sometimes store hero
9602                // images in the documents folder.
9603                options.push({
9604                    text:    mode === 'attached'
9605                        ? 'Pick a different file from club documents'
9606                        : (accepts === 'pdf' ? 'Choose from club documents' : 'Choose from club documents'),
9607                    subtext: accepts === 'pdf'
9608                        ? 'Public-access PDFs in the club documents folder'
9609                        : 'Public-access files in the club documents folder',
9610                    fn:      () => this.pick_document_for_placeholder(id),
9611                });
9612                options.push({
9613                    text:    mode === 'attached' ? 'Paste a different URL' : 'Paste a URL',
9614                    subtext: accepts === 'pdf'
9615                        ? 'Paste a public PDF link (e.g. a public document on this club)'
9616                        : 'Paste a public image URL (Clubspot CDN or external host)',
9617                    fn:      () => this.paste_url_for_placeholder(id),
9618                });
9619                if (mode === 'attached' && attached_is_external) {
9620                    // Only surface this when the slot is currently
9621                    // pointing at a non-Clubspot host - importing a
9622                    // CDN URL into itself is a no-op and the server
9623                    // rejects it explicitly. Sits ABOVE "Remove" so
9624                    // it lines up with the constructive actions
9625                    // (pick, paste, copy) rather than the destructive
9626                    // one.
9627                    options.push({
9628                        text:    'Copy image to club photos',
9629                        subtext: 'Save a local copy in your photo library and use that instead of the external URL.',
9630                        fn:      () => this.run_after_placeholder_menu_close(() => this.import_external_image_for_attached(id)),
9631                    });
9632                }
9633                if (mode === 'attached') {
9634                    options.push({
9635                        text:    'Remove this image',
9636                        subtext: 'Revert this slot back to its empty placeholder.',
9637                        fn:      () => this.run_after_placeholder_menu_close(() => this.revert_attached_to_placeholder(id)),
9638                    });
9639                } else {
9640                    options.push({
9641                        text:    'Keep empty for now',
9642                        subtext: 'Hide this prompt — the slot stays available with a "Change" button if you want to fill it later.',
9643                        fn:      () => this.run_after_placeholder_menu_close(() => this.remove_placeholder(id)),
9644                    });
9645                }
9646                let params = {
9647                    title:             mode === 'attached' ? 'Change "' + label + '"' : 'Fill "' + label + '"',
9648                    subtitle:          mode === 'attached'
9649                        ? 'Pick a different photo, paste another URL, or remove the current asset.'
9650                        : 'Pick how you want to attach this asset.',
9651                    hide_on_selection: false,
9652                    options:           options,
9653                };
9654                handle_common(params);
9655            },
9656            run_after_placeholder_menu_close(fn) {
9657                store.overlays.handle_common = false;
9658                let run_next = () => {
9659                    setTimeout(fn, 0);
9660                };
9661                if (typeof this.$nextTick === 'function') {
9662                    this.$nextTick(run_next);
9663                    return;
9664                }
9665                run_next();
9666            },
9667            pick_existing_for_placeholder(id) {
9668                if (typeof webbuilder_asset_picker !== 'function') {
9669                    if (typeof displayMessage === 'function') {
9670                        displayMessage('Club photo picker is not loaded. Refresh the page and try again.', true, 5000);
9671                    }
9672                    return;
9673                }
9674                this.run_after_placeholder_menu_close(() => {
9675                    let params = {
9676                        title:             'Select club photo',
9677                        accepts:           'image',
9678                        source:            'photos',
9679                        hide_on_selection: true,
9680                        fn: (result) => {
9681                            let url = result && result.url ? result.url : null;
9682                            if (!url) {
9683                                return;
9684                            }
9685                            let attach_params = {
9686                                id:         id,
9687                                url:        url,
9688                                verified:   true,
9689                                source:     'club_photos',
9690                                visibility: 'public_web',
9691                            };
9692                            this.attach_url_to_placeholder(attach_params);
9693                        },
9694                    };
9695                    webbuilder_asset_picker(params);
9696                });
9697            },
9698            // Opens the documents picker overlay. Same deferred-tick
9699            // dance as `pick_existing_for_placeholder` so the chain of
9700            // handle_common → documents picker doesn't race.
9701            pick_document_for_placeholder(id) {
9702                if (typeof webbuilder_asset_picker !== 'function') {
9703                    if (typeof displayMessage === 'function') {
9704                        displayMessage('Documents picker is not loaded. Refresh the page and try again.', true, 5000);
9705                    }
9706                    return;
9707                }
9708                let placeholder = this.find_asset_element_by_id(id);
9709                if (!placeholder) {
9710                    return;
9711                }
9712                let accepts = (placeholder.getAttribute('data-cs-asset-accepts') || 'image').toLowerCase();
9713                this.run_after_placeholder_menu_close(() => {
9714                    let params = {
9715                        title:             accepts === 'pdf' ? 'Select a document' : 'Select a file',
9716                        accepts:           accepts,
9717                        source:            'documents',
9718                        hide_on_selection: true,
9719                        fn: (result) => {
9720                            let url = result && result.url ? result.url : null;
9721                            if (!url) {
9722                                return;
9723                            }
9724                            let attach_params = {
9725                                id:         id,
9726                                url:        url,
9727                                verified:   true,
9728                                source:     result.source || 'public_document',
9729                                visibility: result.visibility || 'public_link',
9730                            };
9731                            this.attach_url_to_placeholder(attach_params);
9732                        },
9733                    };
9734                    webbuilder_asset_picker(params);
9735                });
9736            },
9737            paste_url_for_placeholder(id) {
9738                let placeholder = this.find_placeholder_element(id);
9739                if (!placeholder) {
9740                    return;
9741                }
9742                let prompt_label = placeholder.getAttribute('data-cs-asset-label') || 'asset';
9743                let do_attach = (attach) => {
9744                    this.attach_url_to_placeholder({
9745                        id:         id,
9746                        url:        attach.url,
9747                        verified:   attach.verified,
9748                        source:     attach.source,
9749                        visibility: attach.visibility,
9750                    });
9751                };
9752                // Defer one tick so handle_common can finish dismissing
9753                // before we open the URL-entry modal — otherwise the
9754                // menu overlay stays painted on top.
9755                this.run_after_placeholder_menu_close(() => {
9756                    this.prompt_for_pasted_url({
9757                        slot_label: prompt_label,
9758                        alt:        prompt_label,
9759                        do_attach:  do_attach,
9760                    });
9761                });
9762            },
9763            // ─── Pasted-URL pipeline (shared) ──────────────────────────
9764            //
9765            // Both the placeholder-marker flow and the bare-<img> flow
9766            // funnel pasted URLs through these three helpers:
9767            //
9768            //   prompt_for_pasted_url
9769            //     → opens a Clubspot universal_edit_overlay modal for
9770            //       URL entry (replaces window.prompt, which blocks
9771            //       the event loop, ignores app styling, and on iOS
9772            //       leaves overlays painted on top of the OS dialog).
9773            //   process_pasted_url
9774            //     → verifies the URL server-side and dispatches to
9775            //       do_attach() for trusted hosts, or to
9776            //       confirm_external_url_action() for external ones.
9777            //   confirm_external_url_action
9778            //     → opens an are_you_sure modal that rolls what used
9779            //       to be two separate browser confirms (the
9780            //       "external URL — attach anyway?" confirm and the
9781            //       follow-up "save to library?" prompt) into a single
9782            //       decision: "Save to club photos" (primary) or
9783            //       "Attach as external URL" (secondary).
9784            //
9785            // `do_attach(attach)` is the only caller-specific bit —
9786            // placeholder flow calls attach_url_to_placeholder, bare-
9787            // img flow calls swap_bare_image_src. Both receive the
9788            // same { url, verified, source, visibility } payload.
9789            prompt_for_pasted_url({ slot_label, current_url, alt, do_attach }) {
9790                let label = (typeof slot_label === 'string' && slot_label.trim()) ? slot_label.trim() : 'asset';
9791                if (typeof universal_edit_overlay !== 'function') {
9792                    // Fallback to the browser prompt if the overlay
9793                    // isn't loaded for some reason — better than
9794                    // silently dropping the action.
9795                    let pasted = window.prompt('Paste a URL for "' + label + '":', current_url || '');
9796                    if (!pasted) {
9797                        return;
9798                    }
9799                    this.process_pasted_url({ pasted: pasted, alt: alt, do_attach: do_attach });
9800                    return;
9801                }
9802                universal_edit_overlay({
9803                    title:             'Paste a URL',
9804                    subtitle:          { label: 'Paste a public URL for "' + label + '". Clubspot 
9804CDN links attach directly; external URLs will prompt you to save a local copy.' },
9805                    button_label:      'Attach URL',
9806                    hide_on_selection: true,
9807                    fields: [{
9808                        key:      'url',
9809                        label:    'URL',
9810                        value:    current_url || '',
9811                        type:     'text',
9812                        required: true,
9813                    }],
9814                    fn: (params) => {
9815                        let field = (params && params.fields ? params.fields : []).find((f) => f.key === 'url');
9816                        let value = field && typeof field.value === 'string' ? field.value.trim() : '';
9817                        if (!value) {
9818                            return;
9819                        }
9820                        this.process_pasted_url({ pasted: value, alt: alt, do_attach: do_attach });
9821                    },
9822                });
9823            },
9824            process_pasted_url({ pasted, alt, do_attach }) {
9825                let trimmed = (typeof pasted === 'string') ? pasted.trim() : '';
9826                if (!trimmed) {
9827                    return;
9828                }
9829                let club_id = this.return_active_club_id();
9830                if (!club_id || typeof Parse === 'undefined') {
9831                    // Without a club_id we can't run the server-side
9832                    // verifier — fall back to attaching the URL with a
9833                    // warning so the admin isn't stuck.
9834                    if (typeof displayMessage === 'function') {
9835                        displayMessage('Could not verify the URL; attaching as-is. Review before publishing.', true, 5000);
9836                    }
9837                    do_attach({ url: trimmed, verified: false, source: 'pasted_url', visibility: 'unknown' });
9838                    return;
9839                }
9840                Parse.Cloud.run('verify_webbuilder_asset_url', {
9841                    club_id: club_id,
9842                    url:     trimmed,
9843                }, this.parse_session_options()).then((verdict) => {
9844                    if (verdict && verdict.allowed) {
9845                        do_attach({
9846                            url:        verdict.embed_url || trimmed,
9847                            verified:   true,
9848                            source:     verdict.source || 'verified_url',
9849                            visibility: verdict.visibility || 'public_web',
9850                        });
9851                        return;
9852                    }
9853                    let reason = verdict && verdict.reason ? verdict.reason : 'unverified';
9854                    // External URLs come back as `external_url_not_allowed`
9855                    // — surface as a Clubspot modal so the admin can
9856                    // choose between importing to the library or
9857                    // attaching as-is. The custom-code sanitizer still
9858                    // strips dangerous schemes at render time.
9859                    if (reason === 'external_url_not_allowed') {
9860                        this.confirm_external_url_action({
9861                            url: trimmed,
9862                            alt: alt,
9863                            on_import: () => {
9864                                return this.import_external_image_url(trimmed, alt).then((cdn_url) => {
9865                                    do_attach({
9866                                        url:        cdn_url,
9867                                        verified:   true,
9868                                        source:     'imported_external_url',
9869                                        visibility: 'public_web',
9870                                    });
9871                                });
9872                            },
9873                            on_attach_external: () => {
9874                                do_attach({ url: trimmed, verified: false, source: 'external', visibility: 'unknown' });
9875                            },
9876                        });
9877                        return;
9878                    }
9879                    if (typeof displayMessage === 'function') {
9880                        displayMessage('URL rejected: ' + reason.replace(/_/g, ' '), true, 5000);
9881                    }
9882                }).catch((error) => {
9883                    // The verifier cloud function ships in the same
9884                    // deploy as this UI, but local devs sometimes don't
9885                    // restart their primary server between code pulls.
9886                    // Treat "Invalid function" as a soft fallback: attach
9887                    // the URL with a warning so the admin isn't stuck,
9888                    // and surface the underlying error in the console.
9889                    let msg = error && error.message ? error.message : '';
9890                    if (msg.indexOf('Invalid function') !== -1) {
9891                        console.warn('[custom-code] verify_webbuilder_asset_url not deployed — attaching URL without verification.');
9892                        if (typeof displayMessage === 'function') {
9893                            displayMessage('Attached without verification (server hasn\'t reloaded the new function yet).', true, 5000);
9894                        }
9895                        do_attach({ url: trimmed, verified: false, source: 'pasted_url', visibility: 'unknown' });
9896                        return;
9897                    }
9898                    if (typeof displayMessage === 'function') {
9899                        displayMessage(msg || 'Could not verify URL.', true, 5000);
9900                    }
9901                });
9902            },
9903            confirm_external_url_action({ url, alt, on_import, on_attach_external }) {
9904                if (typeof are_you_sure !== 'function') {
9905                    if (window.confirm('This URL is hosted outside Clubspot. Save a copy to your club photo library?\n\nClick Cancel to attach the external URL as-is.\n\n' + url)) {
9906                        Promise.resolve().then(() => on_import()).catch(() => {});
9907                    } else {
9908                        on_attach_external();
9909                    }
9910                    return;
9911                }
9912                let host = '';
9913                try {
9914                    host = new URL(url).hostname || '';
9915                } catch (err) {
9916                    host = '';
9917                }
9918                let truncated = url.length > 90 ? (url.slice(0, 87) + '...') : url;
9919                let escaped = (typeof url === 'string') ? url.replace(/"/g, '&quot;') : '';
9920                are_you_sure({
9921                    title:           'External image URL',
9922                    subtext:         'This image lives on ' + (host || 'another site') + '. Save a copy to your club photo library so it stays available even if the source site changes.',
9923                    callout:         { html: '<a href="' + escaped + '" target="_blank" rel="noopener" style="word-break:break-all;">' + truncated + '</a>' },
9924                    button_label:    'Save to club photos',
9925                    warning_button:  false,
9926                    no_thanks_label: 'Attach as external URL',
9927                    fn: () => {
9928                        store.components.are_you_sure.loading = true;
9929                        Promise.resolve().then(() => on_import()).then(() => {
9930                            store.components.are_you_sure.loading = false;
9931                            store.overlays.are_you_sure = false;
9932                            if (typeof displayMessage === 'function') {
9933                                displayMessage('Image saved to your photo library.', false, 4000);
9934                            }
9935                        }).catch((error) => {
9936                            store.components.are_you_sure.loading = false;
9937                            if (typeof checkParseError === 'function') {
9938                                checkParseError(error);
9939                            }
9940                            let msg = error && error.message ? error.message : 'Image import failed.';
9941                            if (typeof displayMessage === 'function') {
9942                                displayMessage(msg, true, 6000);
9943                            }
9944                        });
9945                    },
9946                    secondary_fn: () => {
9947                        store.overlays.are_you_sure = false;
9948                        on_attach_external();
9949                    },
9950                });
9951            },
9952            // Run the `import_external_image_to_club_photos` Cloud
9953            // function and resolve with the canonical CDN URL.
9954            // Centralizes the embed_url fallback + "no URL returned"
9955            // diagnostics that used to be duplicated across both
9956            // `import_external_image_for_attached` and
9957            // `import_external_image_for_bare`.
9958            import_external_image_url(url, alt) {
9959                let club_id = this.return_active_club_id();
9960                if (!club_id || typeof Parse === 'undefined') {
9961                    return Promise.reject(new Error('Could not determine the active club. Refresh and try again.'));
9962                }
9963                return Parse.Cloud.run('import_external_image_to_club_photos', {
9964                    club_id: club_id,
9965                    url:     url,
9966                    alt:     alt || '',
9967                }, this.parse_session_options()).then((result) => {
9968                    let returned_cdn = result && result.cdn_url ? result.cdn_url : null;
9969                    let embed_fallback = result && result.result && result.result.embed_url ? result.result.embed_url : null;
9970                    let cdn_url = returned_cdn || embed_fallback;
9971                    if (!cdn_url) {
9972                        let detail = result ? (' (server returned: ' + JSON.stringify(result).slice(0, 160) + ')') : '';
9973                        throw new Error('Image import finished but no URL was returned.' + detail);
9974                    }
9975                    return cdn_url;
9976                });
9977            },
9978            // ─── Section-level "save external images" batch ───────────
9979            //
9980            // Pure CLIENT operation, mirroring upload_webbuilder_asset_photo:
9981            // discover every external image URL in the section, loop the
9982            // existing import_external_image_to_club_photos cloud function
9983            // per URL (with the admin's real session token / permissions),
9984            // then rewrite the persisted HTML to point at the new CDN
9985            // URLs. No AI service involvement.
9986            //
9987            // Parses the persisted source (not the live preview DOM) so
9988            // it sees the canonical URLs and isn't confused by transient
9989            // decoration wrappers. Returns a deduped array of external
9990            // http(s) image URLs found in <img src> and inline
9991            // `background-image: url(...)`.
9992            collect_section_external_image_urls(source_html) {
9993                return webbuilder_collect_external_image_urls(source_html);
9994            },
9995            import_all_external_images_in_section() {
9996                if (this.importing_external_images) {
9997                    return;
9998                }
9999                let urls = this.section_external_image_urls;
10000                if (!urls.length) {
10001                    if (typeof displayMessage === 'function') {
10002                        displayMessage('No external images to save in this section.', false, 4000);
10003                    }
10004                    return;
10005                }
10006                let club_id = this.return_active_club_id();
10007                if (!club_id || typeof Parse === 'undefined') {
10008                    if (typeof displayMessage === 'function') {
10009                        displayMessage('Could not determine the active club. Refresh and try again.', true, 5000);
10010                    }
10011                    return;
10012                }
10013                let count = urls.length;
10014                let run_batch = () => {
10015                    this.importing_external_images = true;
10016                    if (typeof displayMessage === 'function') {
10017                        displayMessage('Saving ' + count + ' image' + (count === 1 ? '' : 's') + ' to your photo library...', false, 4000);
10018                    }
10019                    this.run_external_image_batch(urls).then((summary) => {
10020                        this.importing_external_images = false;
10021                        this.report_external_image_batch_result(summary);
10022                    }).catch((error) => {
10023                        this.importing_external_images = false;
10024                        if (typeof checkParseError === 'function') {
10025                            checkParseError(error);
10026                        }
10027                        let msg = error && error.message ? error.message : 'Image import failed.';
10028                        if (typeof displayMessage === 'function') {
10029                            displayMessage(msg, true, 6000);
10030                        }
10031                    });
10032                };
10033                if (typeof are_you_sure === 'function') {
10034                    are_you_sure({
10035                        title:          'Save external images',
10036                        subtext:        'This section uses ' + count + ' image' + (count === 1 ? '' : 's') + ' hosted on other sites. Save '
10037                            + (count === 1 ? 'a copy' : 'copies') + ' to your club photo library and swap the section to use '
10038                            + (count === 1 ? 'it' : 'them') + '? This keeps the images available even if the source site changes.',
10039                        button_label:   'Save to club photos',
10040                        warning_button: false,
10041                        fn: () => {
10042                            store.overlays.are_you_sure = false;
10043                            run_batch();
10044                        },
10045                    });
10046                    return;
10047                }
10048                run_batch();
10049            },
10050            // Import each external URL sequentially (bounded to one
10051            // in-flight request — these are byte-level server fetches,
10052            // and a club reference page rarely has more than a handful
10053            // of images). Builds a url->cdn_url map, then rewrites the
10054            // persisted HTML once at the end so the editor only takes a
10055            // single save + re-render.
10056            run_external_image_batch(urls) {
10057                let url_map = {};
10058                let errors = [];
10059                let chain = Promise.resolve();
10060                urls.forEach((url) => {
10061                    chain = chain.then(() => {
10062                        return this.import_external_image_url(url, '').then((cdn_url) => {
10063                            url_map[url] = cdn_url;
10064                        }).catch((error) => {
10065                            errors.push({ url: url, message: error && error.message ? error.message : 'import failed' });
10066                        });
10067                    });
10068                });
10069                return chain.then(() => {
10070                    let swapped = this.rewrite_section_external_image_urls(url_map);
10071                    return {
10072                        requested: urls.length,
10073                        imported:  Object.keys(url_map).length,
10074                        errors:    errors,
10075                        swapped:   swapped,
10076                    };
10077                });
10078            },
10079            // Replace every occurrence of each imported external URL
10080            // with its new CDN URL across the persisted HTML — both
10081            // <img src> and inline background-image declarations — and
10082            // persist once. Returns the number of URLs actually swapped.
10083            rewrite_section_external_image_urls(url_map) {
10084                let keys = Object.keys(url_map || {});
10085                if (!keys.length) {
10086                    return 0;
10087                }
10088                let swapped = 0;
10089                let mutated = this.mutate_persisted_html((doc) => {
10090                    let changed = false;
10091                    let imgs = Array.from(doc.querySelectorAll('img[src]'));
10092                    for (let img of imgs) {
10093                        let src = img.getAttribute('src');
10094                        if (src && url_map[src]) {
10095                            img.setAttribute('src', url_map[src]);
10096                            changed = true;
10097                        }
10098                    }
10099                    let styled = Array.from(doc.querySelectorAll('[style*="background-image"]'));
10100                    for (let el of styled) {
10101                        let style = el.getAttribute('style') || '';
10102                        let next_style = style;
10103                        keys.forEach((old_url) => {
10104                            if (next_style.indexOf(old_url) !== -1) {
10105                                next_style = next_style.split(old_url).join(url_map[old_url]);
10106                            }
10107                        });
10108                        if (next_style !== style) {
10109                            el.setAttribute('style', next_style);
10110                            changed = true;
10111                        }
10112                    }
10113                    return changed;
10114                });
10115                if (mutated) {
10116                    this.persist_mutated_code(mutated);
10117                    swapped = keys.length;
10118                }
10119                return swapped;
10120            },
10121            report_external_image_batch_result(summary) {
10122                if (typeof displayMessage !== 'function') {
10123                    return;
10124                }
10125                let safe = summary || {};
10126                let imported = safe.imported || 0;
10127                let errors = Array.isArray(safe.errors) ? safe.errors : [];
10128                if (imported > 0 && errors.length === 0) {
10129                    displayMessage('Saved ' + imported + ' image' + (imported === 1 ? '' : 's') + ' to your photo library.', false, 4000);
10130                    return;
10131                }
10132                if (imported > 0 && errors.length > 0) {
10133                    displayMessage('Saved ' + imported + ', but ' + errors.length + ' failed: ' + (errors[0].message || 'unknown error') + '.', true, 6000);
10134                    return;
10135                }
10136                displayMessage('Could not save the external images: ' + (errors.length ? (errors[0].message || 'unknown error') : 'nothing imported') + '.', true, 6000);
10137            },
10138            return_active_club_id() {
10139                if (typeof store !== 'undefined' && store.clubObject && store.clubObject.id) {
10140                    return store.clubObject.id;
10141                }
10142                return null;
10143            },
10144            parse_session_options() {
10145                if (typeof Parse === 'undefined' || !Parse.User || !Parse.User.current()) {
10146                    return {};
10147                }
10148                let token = Parse.User.current().getSessionToken ? Parse.User.current().getSessionToken() : null;
10149                if (!token) {
10150                    return {};
10151                }
10152                return { sessionToken: token };
10153            },
10154            find_placeholder_element(id) {
10155                return this.find_asset_element_by_id(id);
10156            },
10157            // Match either `data-cs-asset-placeholder="<id>"` (unfilled)
10158            // or `data-cs-asset-attached="<id>"` (already filled) so the
10159            // action menu can re-open against the same slot regardless
10160            // of state.
10161            find_asset_element_by_id(id) {
10162                let root = this.custom_code_render_root();
10163                if (!root || !id) {
10164                    return null;
10165                }
10166                let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"');
10167                let selector =
10168                    '[data-cs-asset-placeholder="' + escaped + '"], ' +
10169                    '[data-cs-asset-attached="' + escaped + '"]';
10170                return root.querySelector(selector);
10171            },
10172            // Hostnames that mean "the file is already in the Clubspot
10173            // library." Mirrors the server-side PUBLIC_ASSET_HOSTS set
10174            // in primary/cloud/webbuilder-assets.js. Drift in this list
10175            // is non-fatal: at worst we offer the user "Copy to club
10176            // photos" on a URL that turns out to be local, and the
10177            // server rejects the import with a clear error. The set
10178            // here matters only for the UX surface (showing/hiding the
10179            // menu option).
10180            return_clubspot_asset_hosts() {
10181                return new Set(WEBBUILDER_CLUBSPOT_ASSET_HOSTS);
10182            },
10183            is_external_image_url(url) {
10184                return webbuilder_is_external_image_url(url);
10185            },
10186            // Pull the currently-attached URL out of an asset wrapper.
10187            // Handles the three shapes attach_url_to_placeholder writes:
10188            //   - <a data-cs-asset-attached href="..."> for documents.
10189            //   - <img data-cs-asset-attached src="..."> (rare; the
10190            //     wrapper itself is the <img>).
10191            //   - <div data-cs-asset-attached style="background-image:
10192            //     url(...);"> for the background-image target.
10193            //   - A wrapper <div data-cs-asset-attached> whose first
10194            //     <img> child carries the real src.
10195            //
10196            // Returns the raw URL string or null when nothing readable
10197            // is attached.
10198            return_attached_url_for_wrapper(wrapper) {
10199                if (!wrapper || wrapper.nodeType !== 1) {
10200                    return null;
10201                }
10202                let tag_name = (wrapper.tagName || '').toLowerCase();
10203                let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase();
10204                if (target === 'background-image') {
10205                    let style = wrapper.getAttribute('style') || '';
10206                    let match = style.match(/background-image\s*:\s*url\(\s*(['"]?)([^'"\)]+)\1\s*\)/i);
10207                    return match ? match[2] : null;
10208                }
10209                if (tag_name === 'a' || target === 'href') {
10210                    return wrapper.getAttribute('href') || null;
10211                }
10212                if (tag_name === 'img') {
10213                    return wrapper.getAttribute('src') || null;
10214                }
10215                let child_img = wrapper.querySelector('img[src]');
10216                return child_img ? child_img.getAttribute('src') : null;
10217            },
10218            // Single-image "Copy image to club photos" flow for an
10219            // already-attached external image. Reads the wrapper's
10220            // current URL, runs the import via the shared
10221            // `import_external_image_url` helper, and swaps the
10222            // returned CDN URL into the same wrapper. The wrapper
10223            // stays put across the swap, so layout doesn't reflow.
10224            import_external_image_for_attached(id) {
10225                let wrapper = this.find_asset_element_by_id(id);
10226                if (!wrapper) {
10227                    return;
10228                }
10229                let original_url = this.return_attached_url_for_wrapper(wrapper);
10230                if (!this.is_external_image_url(original_url)) {
10231                    if (typeof displayMessage === 'function') {
10232                        displayMessage('This image is already in your library.', false, 4000);
10233                    }
10234                    return;
10235                }
10236                let alt = wrapper.getAttribute('data-cs-asset-label')
10237                    || wrapper.getAttribute('alt')
10238                    || '';
10239                if (typeof displayMessage === 'function') {
10240                    displayMessage('Importing image to your photo library...', false, 4000);
10241                }
10242                this.import_external_image_url(original_url, alt).then((cdn_url) => {
10243                    this.attach_url_to_placeholder({
10244                        id:         id,
10245                        url:        cdn_url,
10246                        verified:   true,
10247                        source:     'imported_external_url',
10248                        visibility: 'public_web',
10249                    });
10250                    if (typeof displayMessage === 'function') {
10251                        displayMessage('Image saved to your photo library.', false, 4000);
10252                    }
10253                }).catch((error) => {
10254                    if (typeof checkParseError === 'function') {
10255                        checkParseError(error);
10256                    }
10257                    let msg = error && error.message ? error.message : 'Image import failed.';
10258                    if (typeof displayMessage === 'function') {
10259                        displayMessage(msg, true, 6000);
10260                    }
10261                });
10262            },
10263
10264            // ─── Bare <img> change flow ───────────────────────────────
10265            //
10266            // Parallel to the placeholder-marker flow above, but keyed
10267            // on the image's current `src` instead of a synthetic id.
10268            // Used by the third pass of `decorate_asset_placeholders`
10269            // that catches bare `<img>` tags from URL-reference builds.
10270            //
10271            // The same handle_common menu shape powers both — the
10272            // bare-img menu just routes its callbacks through src-based
10273            // mutation so the persisted HTML stays bare (no surprise
10274            // wrappers, no surprise unsaved_changes) until the admin
10275            // actually swaps the image.
10276
10277            open_bare_image_menu(img_el) {
10278                if (typeof handle_common !== 'function') {
10279                    return;
10280                }
10281                if (!img_el || typeof img_el.getAttribute !== 'function') {
10282                    return;
10283                }
10284                let current_src = img_el.getAttribute('src') || '';
10285                let current_alt = img_el.getAttribute('alt') || '';
10286                if (!current_src.trim()) {
10287                    return;
10288                }
10289                let is_external = this.is_external_image_url(current_src);
10290                let options = [];
10291                options.push({
10292                    text:    'Pick a different photo',
10293                    subtext: 'Upload a new image or pick an existing one from this club',
10294                    fn:      () => this.run_after_placeholder_menu_close(() => this.pick_existing_for_bare_image(img_el)),
10295                });
10296                options.push({
10297                    text:    'Choose from club documents',
10298                    subtext: 'Public-access files in the club documents folder',
10299                    fn:      () => this.run_after_placeholder_menu_close(() => this.pick_document_for_bare_image(img_el)),
10300                });
10301                options.push({
10302                    text:    'Paste a different URL',
10303                    subtext: 'Paste a public image URL (Clubspot 
10303CDN or external host)',
10304                    fn:      () => this.run_after_placeholder_menu_close(() => this.paste_url_for_bare_image(img_el)),
10305                });
10306                if (is_external) {
10307                    options.push({
10308                        text:    'Copy image to club photos',
10309                        subtext: 'Save a local copy in your photo library and use that instead of the external URL.',
10310                        fn:      () => this.run_after_placeholder_menu_close(() => this.import_external_image_for_bare(img_el)),
10311                    });
10312                }
10313                let label = (current_alt || '').trim() || 'image';
10314                handle_common({
10315                    title:             'Change "' + label + '"',
10316                    subtitle:          is_external
10317                        ? 'Pick a different photo, paste another URL, or save this external image into your library.'
10318                        : 'Pick a different photo or paste another URL.',
10319                    hide_on_selection: false,
10320                    options:           options,
10321                });
10322            },
10323            // Persist a swap from one img src to another. Matches by
10324            // exact `src` attribute value, which covers the model's
10325            // output and any direct admin edits (the codegen pipeline
10326            // doesn't normalize URL strings between turns). Also lifts
10327            // a missing `alt` from the new label so SEO/accessibility
10328            // copy survives the swap.
10329            swap_bare_image_src(old_src, new_url, new_alt) {
10330                if (typeof old_src !== 'string' || !old_src.trim()) {
10331                    return false;
10332                }
10333                if (typeof new_url !== 'string' || !new_url.trim()) {
10334                    return false;
10335                }
10336                let mutated = this.mutate_persisted_html((doc) => {
10337                    let imgs = Array.from(doc.querySelectorAll('img[src]'));
10338                    let changed = false;
10339                    for (let img of imgs) {
10340                        if (img.getAttribute('src') !== old_src) {
10341                            continue;
10342                        }
10343                        img.setAttribute('src', new_url);
10344                        if (new_alt && !(img.getAttribute('alt') || '').trim()) {
10345                            img.setAttribute('alt', new_alt);
10346                        }
10347                        changed = true;
10348                    }
10349                    return changed;
10350                });
10351                if (mutated) {
10352                    this.persist_mutated_code(mutated);
10353                    return true;
10354                }
10355                return false;
10356            },
10357            pick_existing_for_bare_image(img_el) {
10358                if (typeof webbuilder_asset_picker !== 'function') {
10359                    if (typeof displayMessage === 'function') {
10360                        displayMessage('Club photo picker is not loaded. Refresh the page and try again.', true, 5000);
10361                    }
10362                    return;
10363                }
10364                let old_src = img_el.getAttribute('src') || '';
10365                if (!old_src) {
10366                    return;
10367                }
10368                webbuilder_asset_picker({
10369                    title:             'Select club photo',
10370                    accepts:           'image',
10371                    source:            'photos',
10372                    hide_on_selection: true,
10373                    fn: (result) => {
10374                        let url = result && result.url ? result.url : null;
10375                        if (!url) {
10376                            return;
10377                        }
10378                        this.swap_bare_image_src(old_src, url, result.label || '');
10379                    },
10380                });
10381            },
10382            pick_document_for_bare_image(img_el) {
10383                if (typeof webbuilder_asset_picker !== 'function') {
10384                    if (typeof displayMessage === 'function') {
10385                        displayMessage('Documents picker is not loaded. Refresh the page and try again.', true, 5000);
10386                    }
10387                    return;
10388                }
10389                let old_src = img_el.getAttribute('src') || '';
10390                if (!old_src) {
10391                    return;
10392                }
10393                webbuilder_asset_picker({
10394                    title:             'Select a file',
10395                    accepts:           'image',
10396                    source:            'documents',
10397                    hide_on_selection: true,
10398                    fn: (result) => {
10399                        let url = result && result.url ? result.url : null;
10400                        if (!url) {
10401                            return;
10402                        }
10403                        this.swap_bare_image_src(old_src, url, result.label || '');
10404                    },
10405                });
10406            },
10407            paste_url_for_bare_image(img_el) {
10408                let old_src = img_el.getAttribute('src') || '';
10409                let label = img_el.getAttribute('alt') || 'image';
10410                if (!old_src) {
10411                    return;
10412                }
10413                let do_attach = (attach) => {
10414                    this.swap_bare_image_src(old_src, attach.url, label);
10415                };
10416                this.prompt_for_pasted_url({
10417                    slot_label: label,
10418                    alt:        label,
10419                    do_attach:  do_attach,
10420                });
10421            },
10422            // Single-image import path for bare imgs. Reads the img's
10423            // current src, runs the same Cloud function the attached-
10424            // wrapper menu uses, and swaps the src for the CDN URL on
10425            // success. The img stays exactly where it is — no wrapper
10426            // upgrade, no layout change.
10427            import_external_image_for_bare(img_el) {
10428                let original_url = img_el.getAttribute('src') || '';
10429                let alt = img_el.getAttribute('alt') || '';
10430                if (!this.is_external_image_url(original_url)) {
10431                    if (typeof displayMessage === 'function') {
10432                        displayMessage('This image is already in your library.', false, 4000);
10433                    }
10434                    return;
10435                }
10436                if (typeof displayMessage === 'function') {
10437                    displayMessage('Importing image to your photo library...', false, 4000);
10438                }
10439                this.import_external_image_url(original_url, alt).then((cdn_url) => {
10440                    this.swap_bare_image_src(original_url, cdn_url, alt);
10441                    if (typeof displayMessage === 'function') {
10442                        displayMessage('Image saved to your photo library.', false, 4000);
10443                    }
10444                }).catch((error) => {
10445                    if (typeof checkParseError === 'function') {
10446                        checkParseError(error);
10447                    }
10448                    let msg = error && error.message ? error.message : 'Image import failed.';
10449                    if (typeof displayMessage === 'function') {
10450                        displayMessage(msg, true, 6000);
10451                    }
10452                });
10453            },
10454
10455            // Rewrite the persisted custom-code string so the matching
10456            // placeholder element gains a real `src`/`href`/`background-
10457            // image`. We keep the wrapper element across the transition
10458            // — it carries the section's layout box (dimensions, border-
10459            // radius, positioning, etc) — and just rotate its children
10460            // for `target="src"`. The wrapper's `data-cs-asset-
10461            // placeholder` attribute is renamed to `data-cs-asset-
10462            // attached` so:
10463            //
10464            //   1. Subsequent decorate passes show the small "Change"
10465            //      overlay instead of the big "Add" overlay.
10466            //   2. The AI model can tell on follow-up turns that the
10467            //      slot is filled (and must not be re-placeholderized).
10468            //
10469            // We also accept being called against an already-attached
10470            // element — that's the "Change" flow swapping one URL for
10471            // another.
10472            attach_url_to_placeholder(params) {
10473                let { id, url } = params;
10474                if (!id || typeof url !== 'string' || !url.trim()) {
10475                    return;
10476                }
10477                let mutated = this.mutate_persisted_html((doc) => {
10478                    let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"');
10479                    let wrapper = doc.querySelector(
10480                        '[data-cs-asset-placeholder="' + escaped + '"], ' +
10481                        '[data-cs-asset-attached="' + escaped + '"]'
10482                    );
10483                    if (!wrapper) {
10484                        return false;
10485                    }
10486                    let tag_name = (wrapper.tagName || '').toLowerCase();
10487                    let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase();
10488                    if (!target) {
10489                        if (tag_name === 'a') {
10490                            target = 'href';
10491                        } else if (tag_name === 'img') {
10492                            target = 'src';
10493                        } else {
10494                            target = 'src';
10495                        }
10496                    }
10497                    let alt = wrapper.getAttribute('data-cs-asset-label') || wrapper.getAttribute('alt') || '';
10498
10499                    if (target === 'background-image') {
10500                        let existing_style = wrapper.getAttribute('style') || '';
10501                        // Strip any existing background-image declaration
10502                        // so the new url() wins without doubling up. Also
10503                        // wipe any inner content the placeholder used as
10504                        // a fallback label — once we've got a real image,
10505                        // the gradient/icon "Add a photo" copy would just
10506                        // clutter the live site.
10507                        let stripped = existing_style.replace(/background-image\s*:[^;]*;?/gi, '').trim();
10508                        let suffix = stripped && !/;\s*$/.test(stripped) ? '; ' : (stripped ? ' ' : '');
10509                        let bg_value = 'background-image: url("' + this.escape_css_url_segment(url) + '"); background-size: cover; background-position: center;';
10510                        wrapper.setAttribute('style', (stripped + suffix + bg_value).trim());
10511                    } else if (target === 'href' || tag_name === 'a') {
10512                        wrapper.setAttribute('href', url);
10513                        wrapper.setAttribute('target', '_blank');
10514                        wrapper.setAttribute('rel', 'noopener noreferrer');
10515                    } else if (tag_name === 'img') {
10516                        wrapper.setAttribute('src', url);
10517                        if (alt && !wrapper.getAttribute('alt')) {
10518                            wrapper.setAttribute('alt', alt);
10519                        }
10520                    } else {
10521                        // `target=src` on a wrapper element: drop any
10522                        // fallback children (icon, text label) and insert
10523                        // an <img> sized to fill the wrapper. The wrapper
10524                        // continues to carry the layout box, so the live
10525                        // site never reflows between empty/filled states.
10526                        this.empty_placeholder_inner(wrapper);
10527                        let owner = wrapper.ownerDocument || document;
10528                        let img = owner.createElement('img');
10529                        img.setAttribute('src', url);
10530                        if (alt) {
10531                            img.setAttribute('alt', alt);
10532                        }
10533                        img.setAttribute('style', 'width:100%;height:100%;object-fit:cover;display:block;border-radius:inherit;');
10534                        wrapper.appendChild(img);
10535                    }
10536                    // Rename the placeholder marker → attached marker
10537                    // (same id). Preserve everything else so the model
10538                    // still has the role/label/target context next turn.
10539                    let placeholder_id = wrapper.getAttribute('data-cs-asset-placeholder');
10540                    if (placeholder_id !== null) {
10541                        wrapper.removeAttribute('data-cs-asset-placeholder');
10542                        wrapper.setAttribute('data-cs-asset-attached', placeholder_id);
10543                    }
10544                    return true;
10545                });
10546                if (mutated) {
10547                    this.persist_mutated_code(mutated);
10548                }
10549            },
10550            // Drop any inert fallback content the placeholder shipped
10551            // with (e.g. "Add a hero photo" label, decorative icon spans).
10552            // Leaves the wrapper element itself untouched.
10553            empty_placeholder_inner(wrapper) {
10554                if (!wrapper) {
10555                    return;
10556                }
10557                while (wrapper.firstChild) {
10558                    wrapper.removeChild(wrapper.firstChild);
10559                }
10560            },
10561            // "Keep empty for now" — dismiss the prominent "Add image"
10562            // overlay but keep the wrapper marked so the admin can
10563            // still attach an asset later via the small Change button.
10564            // We rename `data-cs-asset-placeholder` → `data-cs-asset-
10565            // attached` (same id, no URL set) which:
10566            //   - swaps the decorator from big overlay → Change chip,
10567            //   - tells the AI to preserve the slot as-is on follow-up
10568            //     rewrites (don't re-placeholderize).
10569            // Permanent deletion is a code-editor operation.
10570            remove_placeholder(id) {
10571                if (!id) {
10572                    return;
10573                }
10574                let mutated = this.mutate_persisted_html((doc) => {
10575                    let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"');
10576                    let placeholder = doc.querySelector('[data-cs-asset-placeholder="' + escaped + '"]');
10577                    if (!placeholder) {
10578                        return false;
10579                    }
10580                    placeholder.removeAttribute('data-cs-asset-placeholder');
10581                    placeholder.setAttribute('data-cs-asset-attached', id);
10582                    return true;
10583                });
10584                if (mutated) {
10585                    this.persist_mutated_code(mutated);
10586                }
10587            },
10588            // "Remove this image" on an attached slot — wipe the
10589            // filled URL/contents and put the slot back into placeholder
10590            // state so the admin can re-fill from scratch. For `src`
10591            // wrappers we also need to drop the inner <img>.
10592            revert_attached_to_placeholder(id) {
10593                if (!id) {
10594                    return;
10595                }
10596                let mutated = this.mutate_persisted_html((doc) => {
10597                    let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"');
10598                    let wrapper = doc.querySelector('[data-cs-asset-attached="' + escaped + '"]');
10599                    if (!wrapper) {
10600                        return false;
10601                    }
10602                    let tag_name = (wrapper.tagName || '').toLowerCase();
10603                    let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase();
10604                    if (!target) {
10605                        if (tag_name === 'a') {
10606                            target = 'href';
10607                        } else if (tag_name === 'img') {
10608                            target = 'src';
10609                        } else {
10610                            target = 'src';
10611                        }
10612                    }
10613                    if (target === 'background-image') {
10614                        let style = wrapper.getAttribute('style') || '';
10615                        let stripped = style.replace(/background-image\s*:[^;]*;?/gi, '').trim();
10616                        if (stripped) {
10617                            wrapper.setAttribute('style', stripped);
10618                        } else {
10619                            wrapper.removeAttribute('style');
10620                        }
10621                    } else if (target === 'href' || tag_name === 'a') {
10622                        wrapper.removeAttribute('href');
10623                        wrapper.removeAttribute('target');
10624                        wrapper.removeAttribute('rel');
10625                    } else {
10626                        this.empty_placeholder_inner(wrapper);
10627                    }
10628                    let attached_id = wrapper.getAttribute('data-cs-asset-attached');
10629                    if (attached_id !== null) {
10630                        wrapper.removeAttribute('data-cs-asset-attached');
10631                        wrapper.setAttribute('data-cs-asset-placeholder', attached_id);
10632                    }
10633                    return true;
10634                });
10635                if (mutated) {
10636                    this.persist_mutated_code(mutated);
10637                }
10638            },
10639            strip_all_asset_attributes(element) {
10640                if (!element) {
10641                    return;
10642                }
10643                let attrs_to_remove = [];
10644                Array.prototype.slice.call(element.attributes || []).forEach((attr) => {
10645                    let name = String(attr.name || '').toLowerCase();
10646                    if (name.indexOf('data-cs-asset-') === 0) {
10647                        attrs_to_remove.push(attr.name);
10648                    }
10649                });
10650                attrs_to_remove.forEach((name) => element.removeAttribute(name));
10651            },
10652            escape_css_url_segment(url) {
10653                if (typeof url !== 'string') {
10654                    return '';
10655                }
10656                return url.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
10657            },
10658            // Parse the persisted custom-code string into a scratch
10659            // <template>, hand it to `mutator` for in-place changes,
10660            // and serialize back. Returns the new HTML string or null
10661            // if the mutator reported no change.
10662            mutate_persisted_html(mutator) {
10663                let source = typeof this.custom_code_value === 'string' ? this.custom_code_value : '';
10664                let template = document.createElement('template');
10665                template.innerHTML = source;
10666                let changed = mutator(template.content);
10667                if (!changed) {
10668                    return null;
10669                }
10670                return template.innerHTML;
10671            },
10672            persist_mutated_code(new_html) {
10673                if (typeof new_html !== 'string') {
10674                    return;
10675                }
10676                this.custom_code_value = new_html;
10677                if (this.cm_instance) {
10678                    this.cm_instance.setValue(new_html);
10679                }
10680                if (this.component && typeof save_changes_for_component === 'function') {
10681                    save_changes_for_component(this.component, 'custom_code', new_html);
10682                }
10683                this.mark_ai_code_revision_changed();
10684                this.$nextTick(() => this.initialize_custom_code_interactions());
10685            },
10686
10687            // ─── Editable text leaves ─────────────────────────────────
10688            //
10689            // Walk the rendered preview, find every "text leaf" element
10690            // (any element whose own children are inline-only, plus
10691            // direct text), and attach a click handler that opens an
10692            // inline edit popover. Skip:
10693            //   - asset placeholder/attached wrappers (their inner text
10694            //     is fallback content the placeholder fill flow wipes),
10695            //   - data-cs-toggle / data-cs-tab interactive triggers
10696            //     (admin uses those to test interactions in preview),
10697            //   - the AI launcher button.
10698            //
10699            // Each decorated element gets a `__cs_text_decorated` flag
10700            // so we don't double-bind across re-decorate passes within
10701            // one render, and a `cs-text-editable-host` class so CSS can
10702            // give it a hover outline + cursor:pointer affordance.
10703            decorate_editable_text_nodes() {
10704                if (!this.preview_element) {
10705                    return;
10706                }
10707                let root = this.custom_code_render_root();
10708                if (!root) {
10709                    return;
10710                }
10711                let candidates = this.collect_editable_text_leaves(root);
10712                candidates.forEach((el) => {
10713                    if (el.__cs_text_decorated) {
10714                        return;
10715                    }
10716                    el.__cs_text_decorated = true;
10717                    el.classList.add('cs-text-editable-host');
10718                    let handler = (event) => {
10719                        if (this.should_allow_text_click_through(event)) {
10720                            return;
10721                        }
10722                        event.preventDefault();
10723                        event.stopPropagation();
10724                        this.open_text_edit_popover(el);
10725                    };
10726                    el.addEventListener('click', handler);
10727                });
10728            },
10729
10730            // DFS-collect text leaves. When an element qualifies, we do
10731            // NOT recurse into it — we want the OUTERMOST text leaf so
10732            // `<h1>Hello <em>world</em></h1>` is edited as one string,
10733            // not two. We also stop recursing into media/script/svg
10734            // wrappers and into asset placeholder/attached wrappers
10735            // (those manage their own UI), since nothing inside should
10736            // be a text leaf anyway.
10737            collect_editable_text_leaves(root) {
10738                let results = [];
10739                let visit = (el) => {
10740                    if (!el || el.nodeType !== 1) {
10741                        return;
10742                    }
10743                    let tag = (el.tagName || '').toLowerCase();
10744                    if (TEXT_SKIP_TAGS.has(tag)) {
10745                        return;
10746                    }
10747                    if (this.is_preview_injected_node(el)) {
10748                        return;
10749                    }
10750                    if (el.hasAttribute && (
10751                        el.hasAttribute('data-cs-asset-placeholder') ||
10752                        el.hasAttribute('data-cs-asset-attached')
10753                    )) {
10754                        return;
10755                    }
10756                    if (this.is_editable_text_leaf(el, root)) {
10757                        results.push(el);
10758                        return;
10759                    }
10760                    let child_elements = el.children || [];
10761                    for (let child of child_elements) {
10762                        visit(child);
10763                    }
10764                };
10765                let top = root.children || [];
10766                for (let child of top) {
10767                    visit(child);
10768                }
10769                return results;
10770            },
10771
10772            is_editable_text_leaf(el, root) {
10773                if (!el || el.nodeType !== 1) {
10774                    return false;
10775                }
10776                let tag = (el.tagName || '').toLowerCase();
10777                if (TEXT_SKIP_TAGS.has(tag)) {
10778                    return false;
10779                }
10780                if (el.classList && (
10781                    el.classList.contains('cs-asset-placeholder-overlay') ||
10782                    el.classList.contains('cs-asset-change-button') ||
10783                    el.classList.contains('cs-asset-background-placeholder-chip') ||
10784                    el.classList.contains('cs-bare-image-preview-host') ||
10785                    el.classList.contains('custom-code-ai-launcher') ||
10786                    el.classList.contains('cs-text-edit-popover')
10787                )) {
10788                    return false;
10789                }
10790                // Skip declarative-interaction triggers — admins want to
10791                // click those to test the toggle/tab behavior in preview.
10792                if (el.hasAttribute && (
10793                    el.hasAttribute('data-cs-toggle') ||
10794                    el.hasAttribute('data-cs-tab')
10795                )) {
10796                    return false;
10797                }
10798                // Skip if self or ancestor is an asset placeholder/attached
10799                // wrapper — those wrappers own their fallback content.
10800                let ancestor = el;
10801                while (ancestor && ancestor !== root) {
10802                    if (ancestor.nodeType === 1 && ancestor.hasAttribute && (
10803                        ancestor.hasAttribute('data-cs-asset-placeholder') ||
10804                        ancestor.hasAttribute('data-cs-asset-attached')
10805                    )) {
10806                        return false;
10807                    }
10808                    if (this.is_preview_injected_node(ancestor)) {
10809                        return false;
10810                    }
10811                    ancestor = ancestor.parentNode;
10812                }
10813                let has_text = false;
10814                for (let child of el.childNodes) {
10815                    if (child.nodeType === 3 && child.nodeValue && child.nodeValue.trim()) {
10816                        has_text = true;
10817                        break;
10818                    }
10819                }
10820                if (!has_text) {
10821                    return false;
10822                }
10823                let element_children = el.children || [];
10824                for (let child of element_children) {
10825                    let child_tag = (child.tagName || '').toLowerCase();
10826                    if (!TEXT_INLINE_TAGS.has(child_tag)) {
10827                        return false;
10828                    }
10829                }
10830                return true;
10831            },
10832
10833            // Walk up from `element` to `root`, recording the index of
10834            // each step among its parent's child NODES (text + element).
10835            // Skip preview-only injected nodes (asset overlays) since
10836            // they don't exist in the persisted source. The same path
10837            // can then be replayed on the parsed source (skipping
10838            // 
10838<script>, which sanitize_custom_code_html removes from
10839            // the rendered preview).
10840            compute_text_element_path(element, root) {
10841                if (!element || !root || element === root) {
10842                    return null;
10843                }
10844                let path = [];
10845                let current = element;
10846                while (current && current !== root) {
10847                    let parent = current.parentNode;
10848                    if (!parent) {
10849                        return null;
10850                    }
10851                    let visible_index = -1;
10852                    let count = 0;
10853                    let found = false;
10854                    for (let child of parent.childNodes) {
10855                        if (this.is_preview_injected_node(child)) {
10856                            continue;
10857                        }
10858                        if (child === current) {
10859                            visible_index = count;
10860                            found = true;
10861                            break;
10862                        }
10863                        count++;
10864                    }
10865                    if (!found || visible_index === -1) {
10866                        return null;
10867                    }
10868                    path.unshift(visible_index);
10869                    current = parent;
10870                }
10871                if (current !== root) {
10872                    return null;
10873                }
10874                return path;
10875            },
10876
10877            is_preview_injected_node(node) {
10878                if (!node || node.nodeType !== 1) {
10879                    return false;
10880                }
10881                if (!node.classList) {
10882                    return false;
10883                }
10884                return (
10885                    node.classList.contains('cs-asset-placeholder-overlay') ||
10886                    node.classList.contains('cs-asset-change-button') ||
10887                    node.classList.contains('cs-asset-background-placeholder-chip') ||
10888                    node.classList.contains('cs-bare-image-host') ||
10889                    node.classList.contains('cs-bare-image-preview-host')
10890                );
10891            },
10892            should_allow_text_click_through(event) {
10893                if (!this._text_click_through_until || Date.now() > this._text_click_through_until) {
10894                    return false;
10895                }
10896                let target = event && event.target ? event.target : null;
10897                if (!target || typeof target.closest !== 'function') {
10898                    return false;
10899                }
10900                return !!target.closest('a, button, [role="button"], [data-cs-toggle], [data-cs-tab]');
10901            },
10902
10903            // Walk a parsed-source tree using a path captured from the
10904            // preview. Skip 
10904<script> nodes on the source side because
10905            // sanitize_custom_code_html strips them at render time —
10906            // without skipping, source/preview indices diverge any
10907            // time a section embeds a script tag.
10908            find_text_element_by_path(root, path) {
10909                if (!root || !path) {
10910                    return null;
10911                }
10912                let current = root;
10913                for (let target_index of path) {
10914                    if (!current || !current.childNodes) {
10915                        return null;
10916                    }
10917                    let visible_index = 0;
10918                    let next = null;
10919                    for (let child of current.childNodes) {
10920                        if (
10921                            child.nodeType === 1 &&
10922                            (child.tagName || '').toLowerCase() === 'script'
10923                        ) {
10924                            continue;
10925                        }
10926                        if (visible_index === target_index) {
10927                            next = child;
10928                            break;
10929                        }
10930                        visible_index++;
10931                    }
10932                    if (!next) {
10933                        return null;
10934                    }
10935                    current = next;
10936                }
10937                return current;
10938            },
10939
10940            // Inline popover: textarea + Save/Cancel, positioned near
10941            // the clicked element via fixed coords. We close on outside
10942            // click, ESC, or scroll of the preview pane (re-positioning
10943            // on scroll added complexity for little gain — admins will
10944            // re-click after scrolling).
10945            open_text_edit_popover(target_el) {
10946                this.close_text_edit_popover();
10947                if (!target_el) {
10948                    return;
10949                }
10950                let root = this.custom_code_render_root();
10951                if (!root) {
10952                    return;
10953                }
10954                let path = this.compute_text_element_path(target_el, root);
10955                if (!path) {
10956                    return;
10957                }
10958                let initial_value = this.read_element_text(target_el);
10959                let link_target = this.find_text_edit_link_target(target_el, root);
10960                let link_path = link_target ? this.compute_text_element_path(link_target, root) : null;
10961                let initial_url = link_target ? this.read_text_edit_url(link_target) : '';
10962
10963                let popover = document.createElement('div');
10964                popover.className = 'cs-text-edit-popover';
10965                popover.setAttribute('data-cs-text-popover', '1');
10966
10967                let header = document.createElement('div');
10968                header.className = 'cs-text-edit-popover-header';
10969                header.textContent = 'Edit text';
10970                popover.appendChild(header);
10971
10972                let textarea = document.createElement('textarea');
10973                textarea.className = 'cs-text-edit-textarea';
10974                textarea.value = initial_value;
10975                textarea.rows = Math.min(8, Math.max(2, initial_value.split('\n').length + 1));
10976                popover.appendChild(textarea);
10977
10978                let url_input = null;
10979                if (link_target && link_path) {
10980                    let url_wrap = document.createElement('label');
10981                    url_wrap.className = 'cs-text-edit-url-field';
10982
10983                    let url_label = document.createElement('span');
10984                    url_label.className = 'cs-text-edit-url-label';
10985                    url_label.textContent = 'URL or path';
10986                    url_wrap.appendChild(url_label);
10987
10988                    url_input = document.createElement('input');
10989                    url_input.type = 'text';
10990                    url_input.className = 'cs-text-edit-url-input';
10991                    url_input.value = initial_url;
10992                    url_input.placeholder = '';
10993                    url_wrap.appendChild(url_input);
10994
10995                    popover.appendChild(url_wrap);
10996                }
10997
10998                let footer = document.createElement('div');
10999                footer.className = 'cs-text-edit-popover-footer';
11000                let ask_btn = document.createElement('button');
11001                ask_btn.type = 'button';
11002                ask_btn.className = 'cs-text-edit-ask';
11003                ask_btn.title = 'Ask AI about this text — adds it as a reference in the chat';
11004                ask_btn.innerHTML =
11005                    '<i class="fa-regular fa-sparkles"></i>' +
11006                    '<span>Ask</span>';
11007                let footer_spacer = document.createElement('div');
11008                footer_spacer.className = 'cs-text-edit-popover-footer-spacer';
11009                let cancel_btn = document.createElement('button');
11010                cancel_btn.type = 'button';
11011                cancel_btn.className = 'cs-text-edit-cancel';
11012                cancel_btn.textContent = 'Cancel';
11013                let save_btn = document.createElement('button');
11014                save_btn.type = 'button';
11015                save_btn.className = 'cs-text-edit-save';
11016                save_btn.textContent = 'Save';
11017                footer.appendChild(ask_btn);
11018                footer.appendChild(footer_spacer);
11019                footer.appendChild(cancel_btn);
11020                footer.appendChild(save_btn);
11021                popover.appendChild(footer);
11022
11023                document.body.appendChild(popover);
11024                this.position_text_edit_popover(popover, target_el);
11025
11026                let do_save = () => {
11027                    let new_value = textarea.value;
11028                    let new_url = url_input ? url_input.value : null;
11029                    this.commit_text_edit(path, new_value, link_path, new_url);
11030                    this.close_text_edit_popover();
11031                };
11032                let do_cancel = () => {
11033                    this.close_text_edit_popover();
11034                };
11035                let do_ask = () => {
11036                    // Snapshot the textarea value (admin may have
11037                    // tweaked the text before clicking Ask) and add
11038                    // it as a chat reference. We don't persist the
11039                    // edit — the chip carries the current draft so
11040                    // the AI sees what the admin is looking at.
11041                    let snippet = textarea.value;
11042                    this.send_text_edit_to_ai_chat(target_el, snippet);
11043                    this.close_text_edit_popover();
11044                };
11045                save_btn.addEventListener('click', do_save);
11046                cancel_btn.addEventListener('click', do_cancel);
11047                ask_btn.addEventListener('click', do_ask);
11048                textarea.addEventListener('keydown', (event) => {
11049                    if (event.key === 'Escape') {
11050                        event.preventDefault();
11051                        do_cancel();
11052                        return;
11053                    }
11054                    if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
11055                        event.preventDefault();
11056                        do_save();
11057                    }
11058                });
11059                if (url_input) {
11060                    url_input.addEventListener('keydown', (event) => {
11061                        if (event.key === 'Escape') {
11062                            event.preventDefault();
11063                            do_cancel();
11064                            return;
11065                        }
11066                        if (event.key === 'Enter') {
11067                            event.preventDefault();
11068                            do_save();
11069                        }
11070                    });
11071                }
11072                // Stop propagation so our outside-click handler doesn't
11073                // see clicks landing inside the popover and dismiss it.
11074                popover.addEventListener('mousedown', (event) => {
11075                    event.stopPropagation();
11076                });
11077                popover.addEventListener('click', (event) => {
11078                    event.stopPropagation();
11079                });
11080
11081                let outside_handler = (event) => {
11082                    if (popover.contains(event.target)) {
11083                        return;
11084                    }
11085                    let target = event && event.target ? event.target : null;
11086                    if (target && root.contains(target)) {
11087                        this._text_click_through_until = Date.now() + 250;
11088                    }
11089                    do_cancel();
11090                };
11091                // Pin the popover to the target element on scroll +
11092                // resize. Capture-phase scroll listener on window
11093                // catches scroll events from any nested scroll
11094                // container (scroll doesn't bubble, but capture fires
11095                // for every ancestor). If the target gets detached
11096                // (e.g. AI re-renders the section, admin switches
11097                // tabs) we close instead of leaving an orphan popover
11098                // pointing at a stale rect.
11099                let reposition = () => {
11100                    if (!this._text_popover || this._text_popover.el !== popover) {
11101                        return;
11102                    }
11103                    if (!target_el || !target_el.isConnected) {
11104                        do_cancel();
11105                        return;
11106                    }
11107                    this.position_text_edit_popover(popover, target_el);
11108                };
11109                // Defer attach so the click that opened the popover
11110                // doesn't immediately close it. Also listen for
11111                // touchstart so taps on a touch device dismiss the
11112                // popover the same way mouse clicks do.
11113                setTimeout(() => {
11114                    document.addEventListener('mousedown',  outside_handler, true);
11115                    document.addEventListener('touchstart', outside_handler, true);
11116                    window.addEventListener('scroll', reposition, true);
11117                    window.addEventListener('resize', reposition);
11118                }, 0);
11119
11120                this._text_popover = {
11121                    el:               popover,
11122                    target_el:        target_el,
11123                    outside_handler:  outside_handler,
11124                    reposition:       reposition,
11125                };
11126
11127                // Highlight the element being edited so it's obvious
11128                // which text the popover targets.
11129                target_el.classList.add('cs-text-editable-active');
11130
11131                this.$nextTick(() => {
11132                    textarea.focus();
11133                    textarea.select();
11134                });
11135            },
11136
11137            close_text_edit_popover() {
11138                if (!this._text_popover) {
11139                    return;
11140                }
11141                let popover_state = this._text_popover;
11142                this._text_popover = null;
11143                if (popover_state.outside_handler) {
11144                    document.removeEventListener('mousedown',  popover_state.outside_handler, true);
11145                    document.removeEventListener('touchstart', popover_state.outside_handler, true);
11146                }
11147                if (popover_state.reposition) {
11148                    window.removeEventListener('scroll', popover_state.reposition, true);
11149                    window.removeEventListener('resize', popover_state.reposition);
11150                }
11151                if (popover_state.target_el && popover_state.target_el.classList) {
11152                    popover_state.target_el.classList.remove('cs-text-editable-active');
11153                }
11154                if (popover_state.el && popover_state.el.parentNode) {
11155                    popover_state.el.parentNode.removeChild(popover_state.el);
11156                }
11157            },
11158
11159            // Position the popover below the element when there's room,
11160            // otherwise above. Use viewport (fixed) coordinates so the
11161            // popover doesn't get clipped by overflow:hidden ancestors
11162            // (the preview pane itself scrolls).
11163            position_text_edit_popover(popover, target_el) {
11164                if (!popover || !target_el) {
11165                    return;
11166                }
11167                let rect = target_el.getBoundingClientRect();
11168                // Width: clamp between 240 and 420 px, max 92vw.
11169                let max_width = Math.min(window.innerWidth - 24, 420);
11170                let width = Math.max(240, Math.min(max_width, Math.round(rect.width)));
11171                popover.style.width = width + 'px';
11172
11173                // Render once so we can measure height for vertical fit.
11174                popover.style.left = '0px';
11175                popover.style.top = '0px';
11176                popover.style.visibility = 'hidden';
11177                popover.style.display = 'block';
11178
11179                let popover_rect = popover.getBoundingClientRect();
11180                let popover_height = popover_rect.height;
11181                let gap = 8;
11182                let viewport_h = window.innerHeight;
11183                let viewport_w = window.innerWidth;
11184
11185                let left = Math.round(rect.left);
11186                if (left + width + 8 > viewport_w) {
11187                    left = Math.max(8, viewport_w - width - 8);
11188                }
11189                if (left < 8) {
11190                    left = 8;
11191                }
11192
11193                let top;
11194                let space_below = viewport_h - rect.bottom;
11195                let space_above = rect.top;
11196                if (space_below >= popover_height + gap || space_below >= space_above) {
11197                    top = Math.round(rect.bottom + gap);
11198                    if (top + popover_height + 8 > viewport_h) {
11199                        top = Math.max(8, viewport_h - popover_height - 8);
11200                    }
11201                } else {
11202                    top = Math.round(rect.top - popover_height - gap);
11203                    if (top < 8) {
11204                        top = 8;
11205                    }
11206                }
11207
11208                popover.style.left = left + 'px';
11209                popover.style.top = top + 'px';
11210                popover.style.visibility = '';
11211            },
11212
11213            // Concatenate all text nodes inside the element, preserving
11214            // line breaks for `<br>` so multi-line copy round-trips
11215            // sensibly. Inline emphasis tags collapse to plain text on
11216            // save by design — admins use the Edit tab for fancy
11217            // inline markup.
11218            read_element_text(el) {
11219                if (!el) {
11220                    return '';
11221                }
11222                let parts = [];
11223                let walk = (node) => {
11224                    if (!node) {
11225                        return;
11226                    }
11227                    if (node.nodeType === 3) {
11228                        parts.push(node.nodeValue || '');
11229                        return;
11230                    }
11231                    if (node.nodeType !== 1) {
11232                        return;
11233                    }
11234                    let tag = (node.tagName || '').toLowerCase();
11235                    if (tag === 'br') {
11236                        parts.push('\n');
11237                        return;
11238                    }
11239                    if (TEXT_SKIP_TAGS.has(tag)) {
11240                        return;
11241                    }
11242                    for (let child of node.childNodes) {
11243                        walk(child);
11244                    }
11245                };
11246                for (let child of el.childNodes) {
11247                    walk(child);
11248                }
11249                return parts.join('').replace(/[ \t]+/g, ' ').replace(/\s+\n/g, '\n').replace(/\n\s+/g, '\n').trim();
11250            },
11251
11252            // "Ask" button on the text-edit popover — push a chat
11253            // composer chip referencing this element and pin the panel
11254            // open. The shared AI panel handles attachment +
11255            // formatting; we just describe the element.
11256            send_text_edit_to_ai_chat(target_el, snippet) {
11257                if (typeof ai_mcp_panel_attach_context !== 'function') {
11258                    if (typeof displayMessage === 'function') {
11259                        displayMessage('AI panel is not available right now.', true, 4000);
11260                    }
11261                    return;
11262                }
11263                if (!target_el) {
11264                    return;
11265                }
11266                let tag = (target_el.tagName || 'element').toLowerCase();
11267                let raw_text = typeof snippet === 'string' && snippet.length
11268                    ? snippet
11269                    : this.read_element_text(target_el);
11270                let display = (raw_text || '').replace(/\s+/g, ' ').trim();
11271                let label = display.length > 60 ? display.slice(0, 60).trim() + '…' : display;
11272                if (!label) {
11273                    label = '(empty)';
11274                }
11275                let id = 'ctx_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now().toString(36);
11276                ai_mcp_panel_attach_context({
11277                    component: this,
11278                    context: {
11279                        id:      id,
11280                        kind:    'preview-text',
11281                        tag:     tag,
11282                        text:    raw_text || '',
11283                        label:   label,
11284                        preview: display,
11285                    },
11286                });
11287            },
11288
11289            find_text_edit_link_target(target_el, root) {
11290                if (!target_el || !root || typeof target_el.closest !== 'function') {
11291                    return null;
11292                }
11293                let link_target = target_el.closest('a, button');
11294                if (!link_target || !root.contains(link_target)) {
11295                    return null;
11296                }
11297                if (link_target.hasAttribute && (
11298                    link_target.hasAttribute('data-cs-toggle') ||
11299                    link_target.hasAttribute('data-cs-tab')
11300                )) {
11301                    return null;
11302                }
11303                return link_target;
11304            },
11305            read_text_edit_url(link_target) {
11306                if (!link_target || !link_target.getAttribute) {
11307                    return '';
11308                }
11309                let tag = (link_target.tagName || '').toLowerCase();
11310                if (tag === 'a') {
11311                    return link_target.getAttribute('href') || '';
11312                }
11313                return link_target.getAttribute('data-cs-href') || '';
11314            },
11315            normalize_text_edit_url(value) {
11316                let url = typeof value === 'string' ? value.trim() : '';
11317                if (!url) {
11318                    return '';
11319                }
11320                let lowered = url.toLowerCase();
11321                if (lowered.indexOf('javascript:') === 0) {
11322                    return '';
11323                }
11324                return url;
11325            },
11326            apply_text_edit_url(doc, link_path, url_value) {
11327                if (!doc || !link_path) {
11328                    return false;
11329                }
11330                let target = this.find_text_element_by_path(doc, link_path);
11331                if (!target || target.nodeType !== 1) {
11332                    return false;
11333                }
11334                let url = this.normalize_text_edit_url(url_value);
11335                let tag = (target.tagName || '').toLowerCase();
11336                if (tag === 'a') {
11337                    if (url) {
11338                        target.setAttribute('href', url);
11339                    } else {
11340                        target.removeAttribute('href');
11341                    }
11342                    return true;
11343                }
11344                if (tag !== 'button') {
11345                    return false;
11346                }
11347                if (!url) {
11348                    target.removeAttribute('data-cs-href');
11349                    return true;
11350                }
11351                // A plain <button> cannot navigate without JavaScript,
11352                // and inline JS is stripped by sanitize_custom_code_html.
11353                // For CTA-like buttons, convert to an anchor while
11354                // preserving classes, children, and safe generic attrs.
11355                let owner = doc.ownerDocument || document;
11356                let anchor = owner.createElement('a');
11357                Array.prototype.slice.call(target.attributes || []).forEach((attr) => {
11358                    let name = String(attr.name || '').toLowerCase();
11359                    if (name === 'type' || name === 'disabled' || name.indexOf('form') === 0) {
11360                        return;
11361                    }
11362                    if (name.indexOf('on') === 0 || name === 'data-cs-href') {
11363                        return;
11364                    }
11365                    anchor.setAttribute(attr.name, attr.value);
11366                });
11367                anchor.setAttribute('href', url);
11368                while (target.firstChild) {
11369                    anchor.appendChild(target.firstChild);
11370                }
11371                if (target.parentNode) {
11372                    target.parentNode.replaceChild(anchor, target);
11373                }
11374                return true;
11375            },
11376
11377            // Apply the edit against the parsed source and persist.
11378            // Multi-line input becomes textNode + <br> + textNode + ...
11379            // so it round-trips to the same lines on next read.
11380            commit_text_edit(path, new_value, link_path, url_value) {
11381                if (!path) {
11382                    return;
11383                }
11384                let safe_value = typeof new_value === 'string' ? new_value : '';
11385                let mutated = this.mutate_persisted_html((doc) => {
11386                    let target = this.find_text_element_by_path(doc, path);
11387                    if (!target || target.nodeType !== 1) {
11388                        return false;
11389                    }
11390                    while (target.firstChild) {
11391                        target.removeChild(target.firstChild);
11392                    }
11393                    let lines = safe_value.split('\n');
11394                    let owner = doc.ownerDocument || document;
11395                    lines.forEach((line, index) => {
11396                        if (index > 0) {
11397                            target.appendChild(owner.createElement('br'));
11398                        }
11399                        if (line.length) {
11400                            target.appendChild(owner.createTextNode(line));
11401                        }
11402                    });
11403                    if (link_path) {
11404                        this.apply_text_edit_url(doc, link_path, url_value);
11405                    }
11406                    return true;
11407                });
11408                if (mutated !== null) {
11409                    this.persist_mutated_code(mutated);
11410                }
11411            },
11412
11413            // Called from ai-mcp-panel.html. Pushes AI-generated HTML
11414            // into CodeMirror, persists via save_changes_for_component,
11415            // and returns the prior editor value so the panel can offer
11416            // Undo for auto-applied responses.
11417            //
11418            // Streaming-aware: when we've been driving the editor from
11419            // a live stream, the canonical "previous_code" the panel
11420            // wants is the snapshot we took at begin_streaming_code,
11421            // NOT the streamed-in code currently in the buffer.
11422            apply_generated_code(code, options) {
11423                if (typeof code !== 'string') {
11424                    return null;
11425                }
11426                this.close_text_edit_popover();
11427                let opts = options || {};
11428                if (opts.manual_apply || opts.auto) {
11429                    this.clear_ai_streaming_starting_state();
11430                    this.ai_streaming = false;
11431                    this.release_ai_loading_pane_height_after_render();
11432                    if (opts.manual_apply || this.ai_streaming_status === 'streaming' || this.ai_streaming_status === 'queued') {
11433                        this.ai_streaming_status = 'done';
11434                    }
11435                    this.ai_streaming_buffer = '';
11436                    this._last_flushed_buffer_len = -1;
11437                    this.ai_streaming_child_min_heights = [];
11438                    if (this._codegen_stream_raf) {
11439                        cancelAnimationFrame(this._codegen_stream_raf);
11440                        this._codegen_stream_raf = null;
11441                    }
11442                    this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0;
11443                }
11444                let previous_code;
11445                if (this.ai_streaming_pre_code !== null && this.ai_streaming_pre_code !== undefined) {
11446                    previous_code = this.ai_streaming_pre_code;
11447                    this.ai_streaming_pre_code = null;
11448                } else {
11449                    previous_code = this.get_current_code();
11450                }
11451                let safe_code = this.sanitize_custom_code_html(code);
11452                if (this.cm_instance) {
11453                    this._suppress_change_save = true;
11454                    this.cm_instance.setValue(safe_code);
11455                    this._suppress_change_save = false;
11456                }
11457                this.custom_code_value = safe_code;
11458                this.publish_current_code_to_store();
11459                this.save_ai_applied_custom_code(safe_code);
11460                this.mark_ai_code_revision_changed();
11461                if (opts.switch_to_preview !== false) {
11462                    this.active_tab = 'preview';
11463                }
11464                this.$nextTick(() => this.initialize_custom_code_interactions());
11465                return previous_code;
11466            },
11467            save_ai_applied_custom_code(code) {
11468                if (!this.component) {
11469                    return;
11470                }
11471                let config_latest = this.component.get('config_latest') || this.component.get('config') || {};
11472                let next_config_latest = Object.assign({}, config_latest, { custom_code: code });
11473                let ai_description = this.extract_ai_description_from_code(code);
11474                if (ai_description) {
11475                    next_config_latest.ai_description = ai_description;
11476                }
11477                this.component.set('config_latest', next_config_latest);
11478                if (typeof save_changes_for_component === 'function') {
11479                    save_changes_for_component(this.component, 'custom_code', code);
11480                    return;
11481                }
11482                this.component.set('unsaved_changes', true);
11483                this.component.save().catch((error) => {
11484                    console.warn('[custom-code] AI custom code save failed:', error && error.message);
11485                });
11486            },
11487
11488            // ─── Codegen stream subscription ──────────────────────
11489            //
11490            // Subscribes to store.ai_streams for this component's key.
11491            // The component owns the editor + preview, so it's the
11492            // right place to handle code-side events directly — the
11493            // panel only cares about message text + tool calls.
11494            subscribe_to_codegen_stream() {
11495                if (typeof store === 'undefined' || !store.ai_streams) {
11496                    return;
11497                }
11498                let component_id = this.component && this.component.id;
11499                if (!component_id) {
11500                    return;
11501                }
11502                if (typeof this._codegen_stream_unsubscribe === 'function') {
11503                    try {
11504                        this._codegen_stream_unsubscribe();
11505                    } catch (e) {}
11506                    this._codegen_stream_unsubscribe = null;
11507                }
11508                let key = 'codegen:' + component_id;
11509                let me = this;
11510                this._codegen_stream_unsubscribe = store.ai_streams.subscribe(key, function (event_type, payload) {
11511                    me.handle_codegen_stream_event(event_type, payload);
11512                });
11513                // If we're (re-)subscribing into an already-terminal
11514                // entry, the synchronous `replay` event above won't
11515                // unwind any stuck loading flags on its own (replay
11516                // for status='done' is intentionally lightweight to
11517                // avoid re-touching the editor). Hard-converge here
11518                // so a stale "Generating…" label clears on the next
11519                // mount / panel-open.
11520                let entry = (store.ai_streams.get && store.ai_streams.get(key)) || null;
11521                if (entry && (entry.status === 'done' || entry.status === 'error' || entry.status === 'aborted_local')) {
11522                    let needs_clear = this.ai_streaming
11523                        || this.ai_streaming_status === 'streaming'
11524                        || this.ai_streaming_status === 'queued';
11525                    if (needs_clear) {
11526                        this.force_clear_ai_loading_state();
11527                    }
11528                }
11529            },
11530            handle_codegen_stream_event(event_type, payload) {
11531                let safe = payload || {};
11532                this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0;
11533                switch (event_type) {
11534                    case 'replay':
11535                        // Late subscriber catch-up. If a stream is
11536                        // already in flight we lock the editor + flush
11537                        // the accumulated buffer in one shot so the
11538                        // preview catches up before the next delta.
11539                        if (safe.status === 'streaming' || safe.status === 'queued') {
11540                            this.capture_ai_loading_pane_height();
11541                            this.clear_ai_streaming_starting_state();
11542                            if (safe.response_json_buffer) {
11543                                this._response_json_buffer = '';
11544                                this._response_json_html = '';
11545                                this._append_split_ready = false;
11546                                this._append_head = '';
11547                                this._append_tail = '';
11548                                this.handle_codegen_response_json_delta({ text: safe.response_json_buffer });
11549                            } else if (!this.ai_streaming && safe.code_buffer) {
11550                                this.begin_streaming_code(safe.code_buffer || '');
11551                            } else if (safe.code_buffer) {
11552                                this.ai_streaming_buffer = safe.code_buffer;
11553                                this.schedule_streaming_flush();
11554                            }
11555                            this.ai_streaming_status = safe.status;
11556                        } else if (safe.status === 'done' && safe.code_buffer) {
11557                            // Parent-agent loopback streams are passive: a new
11558                            // section may mount only after the child stream has
11559                            // already settled. Replay the final buffered HTML so
11560                            // the preview catches up even before the page refresh
11561                            // reconciles the persisted component.
11562                            this.clear_ai_streaming_starting_state();
11563                            this.force_clear_ai_loading_state();
11564                            let replay_code = safe.code_buffer || '';
11565                            if (this.cm_instance && replay_code !== this.cm_instance.getValue()) {
11566                                this._suppress_change_save = true;
11567                                this.cm_instance.setValue(replay_code);
11568                                this._suppress_change_save = false;
11569                            }
11570                            this.custom_code_value = replay_code;
11571                            this.publish_current_code_to_store();
11572                            this.$nextTick(() => this.initialize_custom_code_interactions());
11573                        } else if (safe.final && safe.status === 'done') {
11574                            // We mounted right after the standalone stream
11575                            // finished — make sure chrome is clear without
11576                            // touching the canonical editor value.
11577                            this.clear_ai_streaming_starting_state();
11578                        }
11579                        break;
11580                    case 'status':
11581                        this.clear_ai_streaming_starting_state();
11582                        if (safe.status === 'streaming' || safe.status === 'queued') {
11583                            this.capture_ai_loading_pane_height();
11584                        }
11585                        if (safe.status === 'queued') {
11586                            // Show queued chrome without engaging the
11587                            // editor lock — nothing is actually being
11588                            // generated yet.
11589                            this.ai_streaming_status = 'queued';
11590                        }
11591                        if (safe.status === 'aborted_local' && this.ai_streaming) {
11592                            this.cancel_streaming({ initiated_remotely: true });
11593                        }
11594                        break;
11595                    case 'code_delta':
11596                        if (safe.component_id && this.component && safe.component_id !== this.component.id) {
11597                            break;
11598                        }
11599                        this.clear_ai_streaming_starting_state();
11600                        if (typeof safe.text !== 'string' || !safe.text.length) {
11601                            break;
11602                        }
11603                        if (safe.atomic) {
11604                            if (!this.ai_streaming) {
11605                                this.begin_streaming_code(safe.text);
11606                            } else {
11607                                this.ai_streaming_buffer = safe.text;
11608                                this.schedule_streaming_flush();
11609                            }
11610                            break;
11611                        }
11612                        if (!this.ai_streaming) {
11613                            this.begin_streaming_code('');
11614                        }
11615                        this.append_streaming_code(safe.text);
11616                        break;
11617                    case 'response_json_delta':
11618                        this.handle_codegen_response_json_delta(safe);
11619                        break;
11620                    case 'new_section_created':
11621                        if (safe.new_component && this.component && safe.new_component.id !== this.component.id) {
11622                            this.force_clear_ai_loading_state();
11623                        }
11624                        break;
11625                    case 'agent_settle':
11626                        // Parent webbuilder-agent build finished/failed. The
11627                        // live preview already morphed in the full document via
11628                        // the forwarded deltas, so just clear the loading
11629                        // overlay WITHOUT a client save — the agent's
11630                        // authoritative refresh re-pull reconciles the canonical
11631                        // HTML (incl. post-build image-import URL swaps).
11632                        this.clear_ai_streaming_starting_state();
11633                        this.force_clear_ai_loading_state();
11634                        if (safe.error) {
11635                            this.ai_streaming_status = 'error';
11636                        }
11637                        break;
11638                    case 'stream_reset':
11639                        this.clear_ai_streaming_starting_state();
11640                        this.ai_streaming_buffer = '';
11641                        this._response_json_buffer = '';
11642                        this._response_json_html = '';
11643                        this._append_split_ready = false;
11644                        this._append_head = '';
11645                        this._append_tail = '';
11646                        if (this.ai_streaming) {
11647                            let restore_code = (this.ai_streaming_pre_code !== null && this.ai_streaming_pre_code !== undefined)
11648                                ? this.ai_streaming_pre_code
11649                                : this.get_current_code();
11650                            if (this.cm_instance) {
11651                                if (this.cm_instance.getValue() !== restore_code) {
11652                                    this._suppress_change_save = true;
11653                                    this.cm_instance.setValue(restore_code);
11654                                    this._suppress_change_save = false;
11655                                }
11656                                this.cm_instance.setOption('readOnly', false);
11657                            }
11658                            this.custom_code_value = restore_code;
11659                            this.publish_current_code_to_store();
11660                            this.ai_streaming = false;
11661                            this.ai_streaming_status = 'streaming';
11662                        }
11663                        // Wipe the morph preview too — next deltas
11664                        // re-build it from a clean slate.
11665                        let stream_root = this.$refs.custom_code_stream_preview;
11666                        if (stream_root) {
11667                            while (stream_root.firstChild) {
11668                                stream_root.removeChild(stream_root.firstChild);
11669                            }
11670                        }
11671                        break;
11672                    case 'done':
11673                        this.clear_ai_streaming_starting_state();
11674                        if (safe.edit_mode === 'new_section' || safe.edit_mode === 'new_sections') {
11675                            this.finish_streaming_code(null);
11676                            break;
11677                        }
11678                        let final_code = null;
11679                        if (typeof safe.code === 'string') {
11680                            final_code = safe.code;
11681                        } else if (safe.result && typeof safe.result.code === 'string') {
11682                            final_code = safe.result.code;
11683                        }
11684                        this.finish_streaming_code(final_code);
11685                        break;
11686                    case 'error':
11687                        this.clear_ai_streaming_starting_state();
11688                        // Leave the partial buffer in place but flip
11689                        // the overlay so the admin can dismiss.
11690                        this.ai_streaming_status = 'error';
11691                        if (this.ai_streaming) {
11692                            this.snap_streaming_done({ error: true });
11693                        }
11694                        break;
11695                    default:
11696                        break;
11697                }
11698            },
11699            handle_codegen_response_json_delta(payload) {
11700                if (!payload || typeof payload.text !== 'string') {
11701                    return;
11702                }
11703                this._response_json_buffer = (this._response_json_buffer || '') + payload.text;
11704                let tool_name = this.detect_streamed_response_action_name(this._response_json_buffer);
11705                if (tool_name === 'set_html') {
11706                    this.stream_set_html_from_buffer();
11707                    return;
11708                }
11709                if (tool_name === 'append_html') {
11710                    this.stream_append_html_from_buffer();
11711                    return;
11712                }
11713            },
11714            // Live set_html: the streamed `html` field IS the whole section, so
11715            // forward each new tail slice as a code delta.
11716            stream_set_html_from_buffer() {
11717                let extracted = this.extract_streamed_json_string_field(this._response_json_buffer, 'html');
11718                if (!extracted || !extracted.value) {
11719                    return;
11720                }
11721                let previous = this._response_json_html || '';
11722                if (extracted.value.length <= previous.length) {
11723                    return;
11724                }
11725                let delta = extracted.value.slice(previous.length);
11726                this._response_json_html = extracted.value;
11727                this.clear_ai_streaming_starting_state();
11728                if (!this.ai_streaming) {
11729                    this.begin_streaming_code('');
11730                }
11731                this.append_streaming_code(delta);
11732            },
11733            // Live append_html: the streamed `html` field is only the NEW items
11734            // (a fragment). Reconstruct the whole document each delta as
11735            // head + fragment + tail, split at the <!--cs-append-anchor--> so
11736            // the new items render in place while existing items stay stable.
11737            stream_append_html_from_buffer() {
11738                let extracted = this.extract_streamed_json_string_field(this._response_json_buffer, 'html');
11739                if (!extracted) {
11740                    return;
11741                }
11742                let fragment = extracted.value || '';
11743                if (!this._append_split_ready) {
11744                    let code = this.get_current_code() || '';
11745                    let anchor_index = code.indexOf('<!--cs-append-anchor-->');
11746                    if (anchor_index >= 0) {
11747                        this._append_head = code.slice(0, anchor_index);
11748                        this._append_tail = code.slice(anchor_index);
11749                    } else {
11750                        this._append_head = code;
11751                        this._append_tail = '';
11752                    }
11753                    this._append_split_ready = true;
11754                }
11755                this.clear_ai_streaming_starting_state();
11756                if (!this.ai_streaming) {
11757                    this.begin_streaming_code('');
11758                }
11759                // Fragment grows in the MIDDLE, so set the whole buffer (not an
11760                // append) and let the preview morph reconcile against it.
11761                this.ai_streaming_buffer = this._append_head + fragment + this._append_tail;
11762                this.schedule_streaming_flush();
11763            },
11764            detect_streamed_response_action_name(buffer) {
11765                let match = /"name"\s*:\s*"(set_html|patch_html|append_html|create_section|propose_sections)"/.exec(buffer || '');
11766                if (!match) {
11767                    return null;
11768                }
11769                return match[1];
11770            },
11771            extract_streamed_json_string_field(buffer, field_name) {
11772                if (!buffer || !field_name) {
11773                    return null;
11774                }
11775                let target = '"' + field_name + '"';
11776                let key_index = buffer.indexOf(target);
11777                if (key_index === -1) {
11778                    return null;
11779                }
11780                let colon_index = buffer.indexOf(':', key_index + target.length);
11781                if (colon_index === -1) {
11782                    return null;
11783                }
11784                let quote_index = buffer.indexOf('"', colon_index + 1);
11785                if (quote_index === -1) {
11786                    return null;
11787                }
11788                let value = '';
11789                let escaping = false;
11790                let unicode_remaining = 0;
11791                let unicode_buffer = '';
11792                for (let i = quote_index + 1; i < buffer.length; i++) {
11793                    let character = buffer.charAt(i);
11794                    if (unicode_remaining > 0) {
11795                        unicode_buffer += character;
11796                        unicode_remaining--;
11797                        if (unicode_remaining === 0) {
11798                            let code = parseInt(unicode_buffer, 16);
11799                            if (Number.isFinite(code)) {
11800                                value += String.fromCharCode(code);
11801                            }
11802                            unicode_buffer = '';
11803                        }
11804                        continue;
11805                    }
11806                    if (escaping) {
11807                        escaping = false;
11808                        if (character === 'u') {
11809                            unicode_remaining = 4;
11810                            unicode_buffer = '';
11811                        } else {
11812                            value += this.decode_streamed_json_escape(character);
11813                        }
11814                        continue;
11815                    }
11816                    if (character === '\\') {
11817                        escaping = true;
11818                        continue;
11819                    }
11820                    if (character === '"') {
11821                        return {
11822                            value:  value,
11823                            closed: true,
11824                        };
11825                    }
11826                    value += character;
11827                }
11828                return {
11829                    value:  value,
11830                    closed: false,
11831                };
11832            },
11833            decode_streamed_json_escape(character) {
11834                if (character === 'n') {
11835                    return '\n';
11836                }
11837                if (character === 'r') {
11838                    return '\r';
11839                }
11840                if (character === 't') {
11841                    return '\t';
11842                }
11843                if (character === 'b') {
11844                    return '\b';
11845                }
11846                if (character === 'f') {
11847                    return '\f';
11848                }
11849                return character;
11850            },
11851            // Lock the editor, snapshot prior code for Undo, switch to
11852            // preview tab. `initial_buffer` is the accumulated code we
11853            // missed if subscribing mid-stream — it seeds the buffer
11854            // and triggers an immediate flush.
11855            begin_streaming_code(initial_buffer) {
11856                if (this.ai_streaming) {
11857                    return;
11858                }
11859                this.capture_ai_loading_pane_height();
11860                this.clear_ai_streaming_starting_state();
11861                this.ai_streaming = true;
11862                this.ai_streaming_status = 'streaming';
11863                this.ai_streaming_pre_code = this.get_current_code();
11864                this.ai_streaming_buffer = (typeof initial_buffer === 'string') ? initial_buffer : '';
11865                if (this.cm_instance) {
11866                    this.cm_instance.setOption('readOnly', 'nocursor');
11867                }
11868                // Switch to preview so the admin sees the morph render
11869                // by default — most builds are done from preview anyway.
11870                this.active_tab = 'preview';
11871                this.close_text_edit_popover();
11872                if (this.ai_streaming_buffer) {
11873                    this.schedule_streaming_flush();
11874                }
11875            },
11876            append_streaming_code(chunk) {
11877                this.ai_streaming_buffer += chunk;
11878                this.schedule_streaming_flush();
11879            },
11880            // Coalesce many code_delta events into one DOM update per
11881            // animation frame. cheap and keeps streaming responsive
11882            // even on chatty 100-chunk-per-second responses.
11883            schedule_streaming_flush() {
11884                if (this._codegen_stream_raf) {
11885                    return;
11886                }
11887                let me = this;
11888                this._codegen_stream_raf = requestAnimationFrame(function () {
11889                    me._codegen_stream_raf = null;
11890                    me.flush_streaming_panes();
11891                });
11892            },
11893            flush_streaming_panes() {
11894                if (!this.ai_streaming) {
11895                    return;
11896                }
11897                let buffer = this.ai_streaming_buffer || '';
11898                // Cheap stable check — avoids reflushing when the buffer length hasn't changed.
11899                if (this._last_flushed_buffer_len === buffer.length) {
11900                    return;
11901                }
11902                this._last_flushed_buffer_len = buffer.length;
11903                this.flush_streaming_edit_pane(buffer);
11904                this.flush_streaming_preview_pane(buffer);
11905            },
11906            flush_streaming_edit_pane(buffer) {
11907                if (!this.cm_instance) {
11908                    return;
11909                }
11910                // setValue replaces the doc atomically. That's fine
11911                // for streaming because the doc is not user-editable
11912                // (readOnly: 'nocursor') and the buffer is always
11913                // strictly-grown — selection / scroll resets are
11914                // unavoidable but visually acceptable.
11915                let current = this.cm_instance.getValue();
11916                if (current === buffer) {
11917                    return;
11918                }
11919                this._suppress_change_save = true;
11920                this.cm_instance.setValue(buffer);
11921                this._suppress_change_save = false;
11922                this.custom_code_value = buffer;
11923                this.publish_current_code_to_store();
11924            },
11925            // Sequential morph: parse only the stable-prefix (chars up
11926            // through the last '>') of the buffer into a detached
11927            // tree, then walk top-level children in parallel against
11928            // the live preview root. Append-or-replace by index so
11929            // already-rendered sections stay stable while new ones
11930            // stream in below them.
11931            flush_streaming_preview_pane(buffer) {
11932                let root = this.$refs.custom_code_stream_preview;
11933                if (!root) {
11934                    return;
11935                }
11936                let last_gt = buffer.lastIndexOf('>');
11937                if (last_gt < 0) {
11938                    // Nothing parseable yet (we haven't even seen one
11939                    // closing bracket) — defer the flush.
11940                    return;
11941                }
11942                let stable_prefix = buffer.slice(0, last_gt + 1);
11943                // Sanitize first so the morph never plants scripts /
11944                // inline handlers into the live DOM. Same function
11945                // safe_custom_code_value uses for the static pane.
11946                let safe_html = this.sanitize_custom_code_html(stable_prefix);
11947                let template_el = document.createElement('template');
11948                template_el.innerHTML = safe_html;
11949                let new_nodes = template_el.content.childNodes;
11950                let live_nodes = root.childNodes;
11951                let i = 0;
11952                let max = new_nodes.length;
11953                // Track whether this flush appended brand-new top-level
11954                // nodes. If so the section is growing (new HTML in a
11955                // new_section / full build) and the page should follow
11956                // the bottom. If only existing nodes were morphed in
11957                // place (edit / edits flows replacing content) the
11958                // scroll position should stay put.
11959                let appended_new_node = false;
11960                let last_appended_node = null;
11961                while (i < max) {
11962                    let new_node = new_nodes[i];
11963                    let live_node = live_nodes[i];
11964                    let next_node = new_node.cloneNode(true);
11965                    this.apply_streaming_child_min_height(next_node, i);
11966                    if (!live_node) {
11967                        // Append-only path — most common during forward
11968                        // streaming. next_node is cloned because new_node lives
11969                        // in a <template> document fragment we don't
11970                        // want to consume.
11971                        root.appendChild(next_node);
11972                        appended_new_node = true;
11973                        last_appended_node = next_node;
11974                    } else {
11975                        let new_html = (next_node.outerHTML !== undefined)
11976                            ? next_node.outerHTML
11977                            : (next_node.textContent || '');
11978                        let live_html = (live_node.outerHTML !== undefined)
11979                            ? live_node.outerHTML
11980                            : (live_node.textContent || '');
11981                        if (new_html !== live_html) {
11982                            root.replaceChild(next_node, live_node);
11983                        }
11984                    }
11985                    i++;
11986                }
11987                // Trim any leftover live nodes — happens on stream_reset
11988                // (we wipe in the handler so this is rare) and on the
11989                // edge case where the model deletes trailing content.
11990                while (live_nodes.length > max) {
11991                    root.removeChild(live_nodes[live_nodes.length - 1]);
11992                }
11993                this.$nextTick(() => this.initialize_custom_code_interactions());
11994                // Forward-streaming auto-follow: new top-level nodes
11995                // were appended this frame, so the user is watching new
11996                // content land. Ask the panel to smooth-scroll the
11997                // window so the bottom of the newly-appended chunk
11998                // stays in view. Edits/replacements skip this branch
11999                // because their scroll position should be preserved.
12000                if (appended_new_node && last_appended_node) {
12001                    let panel = (typeof store !== 'undefined') ? store.ai_mcp_panel_component_instance : null;
12002                    if (panel && typeof panel.schedule_smooth_scroll_to_streaming_bottom === 'function') {
12003                        let scroll_target = (last_appended_node.nodeType === 1)
12004                            ? last_appended_node
12005                            : root;
12006                        panel.schedule_smooth_scroll_to_streaming_bottom(scroll_target);
12007                    }
12008                }
12009            },
12010            apply_streaming_child_min_height(node, index) {
12011                if (!node || node.nodeType !== 1) {
12012                    return;
12013                }
12014                let min_height = this.ai_streaming_child_min_heights[index] || 0;
12015                if (min_height <= 0) {
12016                    return;
12017                }
12018                node.style.minHeight = min_height + 'px';
12019            },
12020            // Snap streaming closed and run the canonical save. Called
12021            // from the SSE `done` event handler. Idempotent — a
12022            // duplicate `done` (e.g. from a cancel race) is safe.
12023            finish_streaming_code(final_code) {
12024                if (typeof final_code !== 'string' && !(this.ai_streaming_buffer || '').trim()) {
12025                    this.clear_ai_streaming_starting_state();
12026                    this.ai_streaming = false;
12027                    this.release_ai_loading_pane_height_after_render();
12028                    this.ai_streaming_status = 'done';
12029                    this.ai_streaming_buffer = '';
12030                    this._last_flushed_buffer_len = -1;
12031                    this.ai_streaming_child_min_heights = [];
12032                    if (this._codegen_stream_raf) {
12033                        cancelAnimationFrame(this._codegen_stream_raf);
12034                        this._codegen_stream_raf = null;
12035                    }
12036                    if (this.cm_instance) {
12037                        this.cm_instance.setOption('readOnly', false);
12038                    }
12039                    this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0;
12040                    return;
12041                }
12042                if (!this.ai_streaming && (this.ai_streaming_status !== 'done')) {
12043                    // Late `done` for a stream we never engaged with —
12044                    // usually because the panel auto-applied via the
12045                    // separate apply_generated_code path. Still settle
12046                    // local loading UI so the launcher / pending overlay
12047                    // don't get stranded in "Generating…".
12048                    this.clear_ai_streaming_starting_state();
12049                    this.ai_streaming = false;
12050                    this.release_ai_loading_pane_height_after_render();
12051                    this.ai_streaming_status = 'done';
12052                    this.ai_streaming_buffer = '';
12053                    this._last_flushed_buffer_len = -1;
12054                    this.ai_streaming_child_min_heights = [];
12055                    if (this._codegen_stream_raf) {
12056                        cancelAnimationFrame(this._codegen_stream_raf);
12057                        this._codegen_stream_raf = null;
12058                    }
12059                    return;
12060                }
12061                this.snap_streaming_done({ final_code: final_code });
12062            },
12063            snap_streaming_done(options) {
12064                let opts = options || {};
12065                let canonical = (typeof opts.final_code === 'string')
12066                    ? this.sanitize_custom_code_html(opts.final_code)
12067                    : (this.ai_streaming_buffer || '');
12068                // Editor + persistence writes can throw (CodeMirror
12069                // teardown race, save failures, etc). Wrap so the
12070                // loading-state convergence in `finally` still runs
12071                // and the launcher never gets stranded in
12072                // "Generating…".
12073                try {
12074                    if (this.cm_instance) {
12075                        if (this.cm_instance.getValue() !== canonical) {
12076                            this._suppress_change_save = true;
12077                            this.cm_instance.setValue(canonical);
12078                            this._suppress_change_save = false;
12079                        }
12080                        this.cm_instance.setOption('readOnly', false);
12081                    }
12082                    this.custom_code_value = canonical;
12083                    this.publish_current_code_to_store();
12084                    this.save_ai_applied_custom_code(canonical);
12085                    this.mark_ai_code_revision_changed();
12086                } finally {
12087                    this.ai_streaming = false;
12088                    this.release_ai_loading_pane_height_after_render();
12089                    this.ai_streaming_status = opts.canceled ? 'aborted_local' : (opts.error ? 'error' : 'done');
12090                    this.ai_streaming_buffer = '';
12091                    this._last_flushed_buffer_len = -1;
12092                    this.ai_streaming_child_min_heights = [];
12093                    if (this._codegen_stream_raf) {
12094                        cancelAnimationFrame(this._codegen_stream_raf);
12095                        this._codegen_stream_raf = null;
12096                    }
12097                    this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0;
12098                }
12099                // Defer the snapshot teardown — the chat panel's `done`
12100                // subscriber will consume it via apply_generated_code in
12101                // the same JS task. Clearing after 5s guards against
12102                // a stale-message Apply click later misreading it as
12103                // the live editor's previous_code.
12104                if (this._clear_pre_code_timer) {
12105                    clearTimeout(this._clear_pre_code_timer);
12106                }
12107                this._clear_pre_code_timer = setTimeout(() => {
12108                    this.ai_streaming_pre_code = null;
12109                    this._clear_pre_code_timer = null;
12110                }, 5000);
12111                this.$nextTick(() => this.initialize_custom_code_interactions());
12112            },
12113            // Local soft-cancel. Tells the streams manager to stop
12114            // emitting deltas to subscribers (the upstream LLM keeps
12115            // running for billing + persistence), then snaps our
12116            // local view of the editor to whatever we have so far.
12117            cancel_streaming(options) {
12118                let opts = options || {};
12119                if (!opts.initiated_remotely && typeof store !== 'undefined' && store.ai_streams) {
12120                    let component_id = this.component && this.component.id;
12121                    if (component_id) {
12122                        store.ai_streams.cancel_local('codegen:' + component_id);
12123                    }
12124                }
12125                this.snap_streaming_done({ canceled: true });
12126            },
12127            // Echo the current editor code into the shared store so
12128            // the chat panel's apply-state badge can diff against it
12129            // reactively. Setting on `store` directly is safe — the
12130            // panel uses a manual tick for reactivity anyway, and the
12131            // value is also surfaced via a computed in the panel.
12132            publish_current_code_to_store() {
12133                if (typeof store === 'undefined') {
12134                    return;
12135                }
12136                if (this.component) {
12137                    let config_latest = this.component.get('config_latest') || {};
12138                    let next_config_latest = Object.assign({}, config_latest, {
12139                        custom_code: this.custom_code_value || '',
12140                    });
12141                    let ai_description = this.extract_ai_description_from_code(this.custom_code_value || '');
12142                    if (ai_description) {
12143                        next_config_latest.ai_description = ai_description;
12144                    }
12145                    this.component.set('config_latest', next_config_latest);
12146                    // Do NOT bump store.component_updates here. That field
12147                    // drives the `last_update` prop, whose watcher calls
12148                    // back into this method — bumping it would re-trigger
12149                    // the watcher and spin a reactive feedback loop on
12150                    // every edit (runaway re-render → tab hang). The
12151                    // canonical save path (save_changes_for_component)
12152                    // owns the re-render signal; the live preview here
12153                    // re-renders off custom_code_value directly.
12154                    if (this.component.id === store.webbuilder_code_active_component_id) {
12155                        store.webbuilder_code_active_component_revision = Date.now();
12156                    }
12157                }
12158                if (typeof store.webbuilder_code_active_component_id !== 'undefined'
12159                    && this.component
12160                    && this.component.id === store.webbuilder_code_active_component_id) {
12161                    store.webbuilder_code_current_code = this.custom_code_value || '';
12162                }
12163            },
12164            // Mirror this section's active tab (edit/preview) into the shared
12165            // store so the per-section AI launcher (toolbar-option-component)
12166            // can show "Edit with AI" only while the code editor is open.
12167            publish_active_tab_to_store() {
12168                if (typeof store === 'undefined' || !this.preview_element) {
12169                    return;
12170                }
12171                let cid = (this.component && typeof this.component.get === 'function')
12172                    ? this.component.get('client_id')
12173                    : null;
12174                if (!cid) {
12175                    return;
12176                }
12177                if (!store.webbuilder_custom_code_tab_by_client_id) {
12178                    store.webbuilder_custom_code_tab_by_client_id = {};
12179                }
12180                store.webbuilder_custom_code_tab_by_client_id[cid] = this.active_tab;
12181            },
12182            extract_ai_description_from_code(code) {
12183                if (typeof code !== 'string') {
12184                    return '';
12185                }
12186                let description_regex = new RegExp("^\\s*<!--\\s*cs-ai-description:\\s*([\\s\\S]*?)-->", "i");
12187                let description_match = code.match(description_regex);
12188                if (!description_match || !description_match[1]) {
12189                    return '';
12190                }
12191                return description_match[1].replace(/\s+/g, ' ').trim().slice(0, 160);
12192            },
12193        },
12194    });
12195</script>
12195
12196
12197<style>
12198    .cs-custom-code-wrap{
12199        position: relative !important;
12200        isolation: isolate;
12201        z-index: 1;
12202        overflow: visible;
12203    }
12204    /* Overlap masks clip short/non-visual sections (script pixels, etc.). */
12205    .componentPreview > .cs-custom-code-wrap.custom-code-preview-blank{
12206        -webkit-mask-image: none !important;
12207        mask-image: none !important;
12208    }
12209    .cs-custom-code-live-host{
12210        display: block;
12211        width: 100%;
12212    }
12213    .cs-custom-code-wrap.first-page-section{
12214        isolation: auto;
12215        z-index: auto;
12216    }
12217    .cs-custom-code-wrap.active-ai-thread-target{
12218        z-index: 3;
12219    }
12220    .cs-custom-code-wrap.active-ai-thread-target::after{
12221        content: "";
12222        position: absolute;
12223        inset: 0;
12224        z-index: 120;
12225        pointer-events: none;
12226        border: 3px solid rgba(74, 86, 248, 0.62);
12227        box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.75), 0 0 0 4px rgba(74, 86, 248, 0.12);
12228    }
12229
12230    .custom-code-ai-launcher{
12231        position: absolute !important;
12232        left: auto !important;
12233        right: 125px !important;
12234        bottom: calc(20px + var(--cs-overlap-chrome-lift, 0px)) !important;
12235        z-index: 99 !important;
12236        display: inline-flex !important;
12237        align-items: center !important;
12238        justify-content: center !important;
12239        gap: 8px !important;
12240        pointer-events: auto !important;
12241        touch-action: manipulation !important;
12242        width: auto !important;
12243        min-width: 0 !important;
12244        height: auto !important;
12245        min-height: 45px !important;
12246        max-height: none !important;
12247        padding: 0 15px !important;
12248        border-radius: 6px !important;
12249        background: linear-gradient(135deg, #4a56f8 0%, #7b58ff 100%) !important;
12250        color: #fff !important;
12251        border: none !important;
12252        font-family: Roboto, Arial, Helvetica, sans-serif !important;
12253        font-size: 14px !important;
12254        font-weight: 600 !important;
12255        font-style: normal !important;
12256        line-height: 45px !important;
12257        letter-spacing: 0 !important;
12258        text-transform: none !important;
12259        white-space: nowrap !important;
12260        box-shadow: 0 12px 40px rgba(40, 40, 41, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.01) !important;
12261        transition: transform 0.12s ease, box-shadow 0.12s ease !important;
12262    }
12263    .custom-code-ai-launcher.has-active-stream{
12264        box-shadow: 0 12px 40px rgba(74, 86, 248, 0.35), 0 0 0 1px rgba(74, 86, 248, 0.25) !important;
12265    }
12266    .custom-code-ai-launcher-spinner{
12267        display: inline-block;
12268        width: 12px;
12269        height: 12px;
12270        border-radius: 999px;
12271        border: 2px solid rgba(255,255,255,0.4);
12272        border-top-color: #fff;
12273        animation: csCustomCodeStreamSpin 0.9s linear infinite;
12274    }
12275    .custom-code-welcome-generating-spinner{
12276        display: inline-block;
12277        width: 30px;
12278        height: 30px;
12279        border-radius: 999px;
12280        border: 3px solid rgba(74, 86, 248, 0.2);
12281        border-top-color: #4a56f8;
12282        animation: csCustomCodeStreamSpin 0.9s linear infinite;
12283    }
12284    @keyframes csCustomCodeStreamSpin{
12285        from { transform: rotate(0deg); }
12286        to   { transform: rotate(360deg); }
12287    }
12288
12289    .custom-code-tab-bar{
12290        position: relative;
12291        z-index: 101;
12292    }
12293    .cs-custom-code-wrap.preview-mode > .custom-code-preview-pane:not(.custom-code-welcome-pane):not(.custom-code-empty-state-pane):not(.custom-code-loading-pane){
12294        padding: 0;
12295    }
12296
12297    /* Welcome composer (Build a custom section) is builder-chrome,
12298       not user-rendered hosted-site content. Hosted sites can apply
12299       a custom theme font that would otherwise cascade in here and
12300       make the heading / textarea look unbranded. Pin everything
12301       inside this pane to the Clubspot dashboard font so the
12302       builder UI is always consistent across sites. !important
12303       because some host themes set font-family with high
12304       specificity on `body *` selectors. */
12305    .custom-code-welcome-pane,
12306    .custom-code-welcome-pane *:not(i):not([class*="fa-"]),
12307    .custom-code-welcome-pane input,
12308    .custom-code-welcome-pane textarea,
12309    .custom-code-welcome-pane button:not([class*="fa-"]){
12310        font-family: Roboto, Arial, Helvetica, sans-serif !important;
12311    }
12312    /* Re-assert the Font Awesome stack on the icon elements inside
12313       the welcome pane. The wildcard rule above used `!important`
12314       to beat hosted-site themes that set `font-family` with high
12315       specificity on `body *`, which had the side-effect of also
12316       beating Font Awesome's own selectors (.fa-regular, .fa-solid,
12317       etc.) on the icon `<i>` tags — the icon glyphs are unicode
12318       private-use codepoints, so without the FA font-family they
12319       render as empty tofu boxes. Excluding `i` / `[class*="fa-"]`
12320       from the wildcard above lets FA's own font-family win again,
12321       and this explicit fallback covers any host theme that ALSO
12322       pinned font-family on `i` or `.fa-*` with high specificity. */
12323    .custom-code-welcome-pane i[class*="fa-"]{
12324        font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important;
12325    }
12326
12327    /* Example-prompt suggestions on the welcome composer. The
12328       ai-mcp-panel.html sheet defines a heavier .ai-composer-pill
12329       look intended for the dashboard chat composer, but that
12330       file doesn't load on hosted-sites pages. Style here is
12331       intentionally low-key — dotted border + normal-weight
12332       muted text — so the suggestions read as inline hints
12333       rather than competing call-to-action buttons. Hover
12334       firms up the border + tints the surface to confirm
12335       they're clickable. */
12336    .cs-custom-code-wrap .ai-composer-pill{
12337        display: inline-flex;
12338        align-items: center;
12339        max-width: 100%;
12340        padding: 5px 11px;
12341        border: 1px dotted rgba(8, 19, 51, 0.18);
12342        border-radius: 8px;
12343        background: transparent;
12344        color: #7280a0;
12345        font-size: 12.5px;
12346        line-height: 18px;
12347        font-weight: 400;
12348        letter-spacing: 0;
12349        text-align: left;
12350        white-space: normal;
12351        transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
12352    }
12353    .cs-custom-code-wrap .ai-composer-pill:hover{
12354        border-color: rgba(74, 86, 248, 0.45);
12355        background: rgba(74, 86, 248, 0.04);
12356        color: #4a56f8;
12357    }
12358    .cs-custom-code-wrap .ai-composer-pill:focus-visible{
12359        outline: 2px solid rgba(74, 86, 248, 0.35);
12360        outline-offset: 1px;
12361    }
12362
12363    /* Send button on the welcome composer. The ai-mcp-panel.html
12364       sheet defines an .ai-submit-button rule too, but that file
12365       isn't loaded on hosted-sites pages — so we redeclare it
12366       locally to guarantee the blue pill renders here.
12367       - flex centering keeps the paper-plane glyph on the
12368         button's optical center
12369       - paper-plane has visual weight on the right tip, so a
12370         1px left nudge looks balanced */
12371    .cs-custom-code-wrap .ai-submit-button{
12372        flex: 0 0 auto;
12373        width: 36px;
12374        min-width: 36px;
12375        height: 36px;
12376        padding: 0;
12377        margin: 0;
12378        border: 0;
12379        border-radius: 999px;
12380        background: #4a56f8;
12381        color: #fff;
12382        display: inline-flex;
12383        align-items: center;
12384        justify-content: center;
12385        line-height: 1;
12386        box-shadow: 0 2px 6px rgba(74, 86, 248, 0.30);
12387        cursor: pointer;
12388        transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease;
12389    }
12390    .cs-custom-code-wrap .ai-submit-button:hover{
12391        background: #3a45e0;
12392        box-shadow: 0 4px 12px rgba(74, 86, 248, 0.40);
12393        transform: translateY(-1px);
12394    }
12395    .cs-custom-code-wrap .ai-submit-button .fa-paper-plane{
12396        font-size: 14px;
12397        line-height: 1;
12398        transform: translateX(-1px);
12399    }
12400    .cs-custom-code-wrap .ai-submit-button.inactive,
12401    .cs-custom-code-wrap .ai-submit-button[disabled]{
12402        background: #c4cbe2;
12403        color: #fff;
12404        box-shadow: none;
12405        cursor: not-allowed;
12406        transform: none;
12407    }
12408    .cs-custom-code-wrap .ai-submit-button.loading{
12409        background: #4a56f8;
12410        color: #fff;
12411        cursor: progress;
12412    }
12413
12414    /* Pre-first-delta loading pane. This is intentionally a real
12415       preview pane, not an overlay: while waiting for the first code
12416       chunk, the preview window shows only the dots. The chat panel is
12417       already open for context/progress, so avoid duplicating labels or
12418       exposing the stale welcome/current preview underneath. */
12419    .custom-code-loading-pane{
12420        display: flex;
12421        align-items: center;
12422        justify-content: center;
12423        min-height: 420px;
12424        padding: 48px 20px;
12425        background: #fbfcff;
12426    }
12427    .custom-code-loading-pane.is-overlay{
12428        position: absolute !important;
12429        inset: 0 !important;
12430        z-index: 80 !important;
12431        min-height: 0 !important;
12432        padding: 0 !important;
12433        background: rgba(251, 252, 255, 0.62) !important;
12434        backdrop-filter: blur(1px);
12435        pointer-events: none;
12436    }
12437    .custom-code-empty-state-pane{
12438        display: flex;
12439        align-items: center;
12440        justify-content: center;
12441        min-height: 420px;
12442        padding: 48px 20px;
12443        background: #fbfcff;
12444    }
12445    .custom-code-nonvisual-pane{
12446        min-height: 88px;
12447        padding: 20px 16px;
12448    }
12449    .custom-code-empty-state-copy{
12450        display: inline-flex;
12451        align-items: center;
12452        justify-content: center;
12453        flex-wrap: wrap;
12454        gap: 8px;
12455        max-width: 100%;
12456        text-align: center;
12457        color: #9aa5ba;
12458        font-family: Roboto, Arial, Helvetica, sans-serif !important;
12459        font-size: 14px;
12460        font-style: normal;
12461        line-height: 20px;
12462        font-weight: 500;
12463    }
12464    .custom-code-empty-state-copy i{
12465        font-size: 14px;
12466        color: #b0bad0;
12467    }
12468    .custom-code-pending-dots{
12469        display: inline-flex;
12470        align-items: center;
12471        gap: 8px;
12472    }
12473    .custom-code-pending-dots span{
12474        display: inline-block;
12475        width: 10px;
12476        height: 10px;
12477        border-radius: 50%;
12478        background: #4a56f8;
12479        opacity: 0.35;
12480        animation: csCustomCodePendingPulse 1.2s ease-in-out infinite both;
12481    }
12482    .custom-code-pending-dots span:nth-child(2){ animation-delay: 0.15s; }
12483    .custom-code-pending-dots span:nth-child(3){ animation-delay: 0.30s; }
12484    @keyframes csCustomCodePendingPulse{
12485        0%, 80%, 100% { transform: scale(0.6); opacity: 0.35; }
12486        40%           { transform: scale(1.0); opacity: 1.0;  }
12487    }
12488
12489    .custom-code-ai-launcher:hover{
12490        transform: translateY(-1px) !important;
12491        box-shadow: 0 3px 8px rgba(74, 86, 248, 0.34) !important;
12492    }
12493
12494    .custom-code-ai-launcher .fa-sparkles{
12495        display: inline-block !important;
12496        flex: 0 0 auto !important;
12497        font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important;
12498        font-size: 14px !important;
12499        line-height: 1 !important;
12500        margin: 0 7px 0 0 !important;
12501    }
12502
12503    .custom-code-ai-launcher-label{
12504        display: inline-block !important;
12505        flex: 0 0 auto !important;
12506        font-family: Roboto, Arial, Helvetica, sans-serif !important;
12507        font-style: normal !important;
12508        letter-spacing: 0 !important;
12509        text-transform: none !important;
12510    }
12511
12512    /*
12513       Placeholder pick/upload/paste overlay. Only rendered in PREVIEW
12514       mode by `decorate_asset_placeholders` — the live (public) site
12515       never sees this class because the overlay element is injected
12516       into the rendered preview DOM and is never persisted back to
12517       the `custom_code` source.
12518    */
12519    .cs-asset-placeholder-host{
12520        position: relative;
12521    }
12522    .custom-code-preview-pane .cs-asset-background-placeholder-host:hover{
12523        outline: 2px dashed rgba(74, 86, 248, 0.55) !important;
12524        outline-offset: -4px !important;
12525    }
12526    .custom-code-preview-pane .cs-asset-background-placeholder-chip{
12527        position: absolute !important;
12528        top: 10px !important;
12529        right: 10px !important;
12530        z-index: 10000 !important;
12531        display: inline-flex !important;
12532        align-items: center !important;
12533        justify-content: center !important;
12534        width: 34px !important;
12535        height: 34px !important;
12536        padding: 0 !important;
12537        margin: 0 !important;
12538        border: 1px solid rgba(74, 86, 248, 0.45) !important;
12539        border-radius: 999px !important;
12540        background: rgba(255, 255, 255, 0.88) !important;
12541        color: #4a56f8 !important;
12542        box-shadow: 0 6px 18px rgba(8, 19, 51, 0.14) !important;
12543        cursor: pointer !important;
12544    }
12545    .custom-code-preview-pane .cs-asset-background-placeholder-chip:hover{
12546        background: #ffffff !important;
12547        border-color: rgba(74, 86, 248, 0.85) !important;
12548    }
12549    .custom-code-preview-pane .cs-asset-placeholder-overlay{
12550        position: absolute !important;
12551        inset: 0 !important;
12552        z-index: 125 !important;
12553        display: flex !important;
12554        flex-direction: column !important;
12555        align-items: center !important;
12556        justify-content: center !important;
12557        gap: 4px !important;
12558        margin: 0 !important;
12559        padding: 12px !important;
12560        border: 2px dashed rgba(74, 86, 248, 0.55) !important;
12561        border-radius: 8px !important;
12562        background: rgba(74, 86, 248, 0.08) !important;
12563        color: #2a2f5a !important;
12564        font-family: Roboto, Arial, Helvetica, sans-serif !important;
12565        font-size: 13px !important;
12566        font-style: normal !important;
12567        font-weight: 500 !important;
12568        line-height: 1.25 !important;
12569        letter-spacing: 0 !important;
12570        text-align: center !important;
12571        white-space: normal !important;
12572        cursor: pointer !important;
12573        backdrop-filter: blur(2px) !important;
12574        transition: background 0.12s ease, border-color 0.12s ease !important;
12575    }
12576    .custom-code-preview-pane .cs-asset-placeholder-overlay:hover{
12577        background: rgba(74, 86, 248, 0.16) !important;
12578        border-color: rgba(74, 86, 248, 0.85) !important;
12579    }
12580    .custom-code-preview-pane .cs-asset-placeholder-icon{
12581        display: inline-flex !important;
12582        align-items: center !important;
12583        justify-content: center !important;
12584        width: 28px !important;
12585        height: 28px !important;
12586        flex: 0 0 28px !important;
12587        margin-bottom: 4px !important;
12588        border-radius: 50% !important;
12589        background: rgba(255, 255, 255, 0.65) !important;
12590        color: #4a56f8 !important;
12591        font-size: 14px !important;
12592        font-style: normal !important;
12593        line-height: 1 !important;
12594        letter-spacing: 0 !important;
12595    }
12596    .custom-code-preview-pane .cs-asset-placeholder-label{
12597        display: inline-block !important;
12598        padding: 4px 10px !important;
12599        border-radius: 999px !important;
12600        background: rgba(255, 255, 255, 0.92) !important;
12601        color: #2a2f5a !important;
12602        font-family: Roboto, Arial, Helvetica, sans-serif !important;
12603        font-size: 13px !important;
12604        font-style: normal !important;
12605        font-weight: 600 !important;
12606        line-height: 1.2 !important;
12607        letter-spacing: 0 !important;
12608        min-width: 118px !important;
12609        max-width: 180px !important;
12610        white-space: normal !important;
12611    }
12612
12613    /*
12614       "Change" overlay for filled placeholder slots
12615       (`[data-cs-asset-attached]`). Light-DOM fallback only — preview
12616       chrome for the isolated shadow tree is in
12617       CS_CUSTOM_CODE_PREVIEW_CHROME_CSS. Always visible, matching
12618       image-with-text / image-grid slot chips.
12619    */
12620    .cs-asset-attached-host{
12621        position: relative;
12622    }
12623    .custom-code-preview-pane .cs-asset-change-button{
12624        position: absolute !important;
12625        top: 8px !important;
12626        right: 8px !important;
12627        z-index: 10000 !important;
12628        display: inline-flex !important;
12629        align-items: center !important;
12630        justify-content: center !important;
12631        padding: 6px 10px !important;
12632        margin: 0 !important;
12633        border: none !important;
12634        border-radius: 6px !important;
12635        background: rgba(20, 24, 45, 0.82) !important;
12636        color: #fff !important;
12637        font-family: Roboto, Arial, Helvetica, sans-serif !important;
12638        font-size: 12px !important;
12639        font-weight: 600 !important;
12640        line-height: 1 !important;
12641        cursor: pointer !important;
12642        opacity: 0.92 !important;
12643        pointer-events: auto !important;
12644        box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22) !important;
12645        transition: opacity 0.12s ease, background 0.12s ease !important;
12646    }
12647    .custom-code-preview-pane .cs-asset-change-button:hover,
12648    .custom-code-preview-pane .cs-asset-change-button:focus{
12649        opacity: 1 !important;
12650        background: rgba(20, 24, 45, 0.95) !important;
12651    }
12652    .custom-code-preview-pane .cs-ai-asset-hover-highlight{
12653        outline: 2px solid rgba(74, 86, 248, 0.32) !important;
12654        outline-offset: 4px !important;
12655        box-shadow: 0 0 0 6px rgba(74, 86, 248, 0.08) !important;
12656        transition: outline-color 0.12s ease, box-shadow 0.12s ease !important;
12657    }
12658
12659    /*
12660       Click-to-edit text leaves. Only active in PREVIEW mode — the
12661       host class is added by `decorate_editable_text_nodes` after
12662       every preview re-render and never persisted to source. The
12663       hover affordance is a soft dashed outline with cursor:pointer
12664       so admins can tell the text is editable without the section
12665       feeling like a form.
12666    */
12667    .custom-code-preview-pane .cs-text-editable-host{
12668        cursor: text !important;
12669        position: relative !important;
12670        transition: outline-color 0.12s ease, background-color 0.12s ease !important;
12671        outline: 1px dashed transparent !important;
12672        outline-offset: 2px !important;
12673        border-radius: 3px !important;
12674    }
12675    .custom-code-preview-pane .cs-text-editable-host:hover{
12676        outline-color: rgba(74, 86, 248, 0.55) !important;
12677        background-color: rgba(74, 86, 248, 0.06) !important;
12678    }
12679    .custom-code-preview-pane .cs-text-editable-host.cs-text-editable-active{
12680        outline: 1px solid rgba(74, 86, 248, 0.85) !important;
12681        background-color: rgba(74, 86, 248, 0.12) !important;
12682    }
12683
12684    /*
12685       Inline text-edit popover. Lives at document.body level (fixed
12686       positioning) so it isn't clipped by overflow:hidden ancestors
12687       in the preview pane. Closed on outside click, ESC, scroll, or
12688       a Vue re-render of the preview.
12689    */
12690    .cs-text-edit-popover{
12691        position: fixed !important;
12692        z-index: 10000 !important;
12693        box-sizing: border-box !important;
12694        max-width: 92vw !important;
12695        padding: 10px !important;
12696        background: #fff !important;
12697        border: 1px solid rgba(20, 24, 45, 0.12) !important;
12698        border-radius: 8px !important;
12699        box-shadow: 0 18px 48px rgba(20, 24, 45, 0.18), 0 2px 6px rgba(20, 24, 45, 0.08) !important;
12700        font-family: inherit !important;
12701        font-size: 13px !important;
12702        color: #2a2f5a !important;
12703    }
12704    .cs-text-edit-popover-header{
12705        display: block !important;
12706        margin-bottom: 6px !important;
12707        font-size: 11px !important;
12708        font-weight: 600 !important;
12709        text-transform: uppercase !important;
12710        letter-spacing: 0.04em !important;
12711        color: #4a4f6a !important;
12712    }
12713    .cs-text-edit-popover .cs-text-edit-textarea{
12714        display: block !important;
12715        box-sizing: border-box !important;
12716        width: 100% !important;
12717        min-height: 60px !important;
12718        max-height: 240px !important;
12719        padding: 8px 10px !important;
12720        margin: 0 !important;
12721        border: 1px solid rgba(20, 24, 45, 0.18) !important;
12722        border-radius: 6px !important;
12723        background: #fff !important;
12724        color: #14182d !important;
12725        font-family: inherit !important;
12726        font-size: 13px !important;
12727        line-height: 1.4 !important;
12728        resize: vertical !important;
12729        outline: none !important;
12730        box-shadow: none !important;
12731    }
12732    .cs-text-edit-popover .cs-text-edit-textarea:focus{
12733        border-color: rgba(74, 86, 248, 0.7) !important;
12734        box-shadow: 0 0 0 3px rgba(74, 86, 248, 0.15) !important;
12735    }
12736    .cs-text-edit-popover .cs-text-edit-url-field{
12737        display: block !important;
12738        margin: 8px 0 0 0 !important;
12739    }
12740    .cs-text-edit-popover .cs-text-edit-url-label{
12741        display: block !important;
12742        margin: 0 0 4px 0 !important;
12743        color: #4a4f6a !important;
12744        font-size: 11px !important;
12745        font-weight: 600 !important;
12746        line-height: 14px !important;
12747    }
12748    .cs-text-edit-popover .cs-text-edit-url-input{
12749        display: block !important;
12750        box-sizing: border-box !important;
12751        width: 100% !important;
12752        height: 34px !important;
12753        padding: 7px 10px !important;
12754        margin: 0 !important;
12755        border: 1px solid rgba(20, 24, 45, 0.18) !important;
12756        border-radius: 6px !important;
12757        background: #fff !important;
12758        color: #14182d !important;
12759        font-family: inherit !important;
12760        font-size: 13px !important;
12761        line-height: 18px !important;
12762        outline: none !important;
12763        box-shadow: none !important;
12764    }
12765    .cs-text-edit-popover .cs-text-edit-url-input:focus{
12766        border-color: rgba(74, 86, 248, 0.7) !important;
12767        box-shadow: 0 0 0 3px rgba(74, 86, 248, 0.15) !important;
12768    }
12769    .cs-text-edit-popover-footer{
12770        display: flex !important;
12771        align-items: center !important;
12772        gap: 6px !important;
12773        margin-top: 8px !important;
12774    }
12775    .cs-text-edit-popover-footer-spacer{
12776        flex: 1 1 auto !important;
12777    }
12778    .cs-text-edit-popover-footer button{
12779        appearance: none !important;
12780        -webkit-appearance: none !important;
12781        display: inline-flex !important;
12782        align-items: center !important;
12783        justify-content: center !important;
12784        padding: 6px 12px !important;
12785        margin: 0 !important;
12786        border-radius: 6px !important;
12787        border: 1px solid transparent !important;
12788        font-family: inherit !important;
12789        font-size: 12px !important;
12790        font-weight: 600 !important;
12791        line-height: 1 !important;
12792        cursor: pointer !important;
12793        transition: background 0.12s ease, transform 0.12s ease, border-color 0.12s ease, color 0.12s ease !important;
12794    }
12795    .cs-text-edit-popover .cs-text-edit-ask{
12796        padding: 6px 8px !important;
12797        background: transparent !important;
12798        border-color: transparent !important;
12799        color: #4a56f8 !important;
12800        gap: 6px !important;
12801    }
12802    .cs-text-edit-popover .cs-text-edit-ask:hover{
12803        background: rgba(74, 86, 248, 0.08) !important;
12804        color: #3b46d6 !important;
12805    }
12806    .cs-text-edit-popover .cs-text-edit-ask .fa-sparkles{
12807        font-size: 12px !important;
12808    }
12809    .cs-text-edit-popover .cs-text-edit-cancel{
12810        background: #fff !important;
12811        border-radius: 999px !important;
12812        border-color: rgba(20, 24, 45, 0.18) !important;
12813        color: #4a4f6a !important;
12814    }
12815    .cs-text-edit-popover .cs-text-edit-cancel:hover{
12816        background: rgba(20, 24, 45, 0.04) !important;
12817    }
12818    .cs-text-edit-popover .cs-text-edit-save{
12819        background: linear-gradient(135deg, #4a56f8 0%, #7b58ff 100%) !important;
12820        border-radius: 999px !important;
12821        color: #fff !important;
12822        border-color: transparent !important;
12823    }
12824    .cs-text-edit-popover .cs-text-edit-save:hover{
12825        transform: translateY(-1px) !important;
12826    }
12827</style>
12828    
12828<script type="text/html" id="template-text">
12829    <div 
12830        v-memo="[last_update, last_element_update]"
12831        :class="[{'hidden': (column_index + 1) > number_of_columns }, 'text-column_' + column_index]"
12832        class="cs-text_text-wrap-column cs-element-stack" 
12833        :data-column="column_index"
12834    >
12835        <template
12836            v-for="(element, element_index) in nice_elements"
12837            :key="element.client_id || element_index"
12838        >
12839            <template v-if="preview_element">
12840                <preview-element-component
12841                    :element="element"
12842                    :element_type="element.type"
12843                    :element_content="element.content"
12844                    :element_column_count="element.column_count"
12845                    :element_column_gap="element.column_gap"
12846                    :column_index="column_index"
12847                    :element_index="element_index"
12848                    :componentObject="component"
12849                ></preview-element-component>
12850            </template>
12851            <template v-if="!preview_element">
12852                <element-component
12853                    :element_type="element.type"
12854                    :element_content="element.content"
12855                    :element_column_count="element.column_count"
12856                    :element_column_gap="element.column_gap"
12857                    :diff_changed="element._wb_diff_changed === true"
12858                    :element_index="element_index"
12859                    :section_element_animation="section_element_animation"
12860                ></element-component>
12861            </template>
12862        </template>
12863        <!-- handle case where there are no elements (admin only) -->
12864        <template v-if="preview_element && elements && elements.length == 0">
12865            <div class="no-elements-wrap">
12866                <div 
12867                    class="tapped-insert-new-element-relative" 
12868                    @click="tapped_add_element( $event )"
12869                >+</div>
12870            </div>
12871        </template>
12872    </div>
12873</script>
12873
12874
12875<script>
12876    app_content.component('text-component', {
12877        template: '#template-text',
12878        data() {
12879            return {}
12880        },
12881        props: {
12882            number_of_columns: [String, Number],
12883            preview_element: Boolean,
12884            component: Object,
12885            last_update: Number, //last component update
12886            last_element_update: Number, //last element update
12887            elements: Array,
12888            column_prop: String,
12889            column_index: Number,
12890            section_element_animation: {
12891                type: String,
12892                default: '',
12893            },
12894        },
12895        methods:{
12896            tapped_add_element(){
12897                var params = {};
12898                params["component"] = this.component;
12899                params["insert_after"] = 0;
12900                params["column_index"] = this.column_index;
12901                handle_add_element( params );
12902            }
12903        },
12904        computed: {
12905            nice_elements(){
12906                var nice_elements = [];
12907
12908                if (this.elements){
12909                    nice_elements = this.elements;
12910                } else if (this.column_prop && typeof this.column_prop === 'string'){
12911                    nice_elements = JSON.parse(unescape(decodeURIComponent(this.column_prop)));
12912                } 
12913                return nice_elements;
12914            }
12915        },
12916    });
12917</script>
12917    
12917<script type="text/html" id="template-image-with-text">
12918    <div ref="animationTarget" class="cs-image-with-text flexWrap relative delay5s" :class="[class_string, { 'scroll-fade': !is_admin && !tab_element }, { 'view_hidden': ! is_admin && ( !formatting_obj.animation || formatting_obj.animation !== 'none' ) }, { 'background': image_mat === 'hide' }]" :style="style_string">
12919        <template v-if="!loading && left_right_orientation === 'right'">
12920            <!-- text first (image on the right) -->
12921            <div class="cs-image-with-text_text-wrap flexGrowOne">
12922                <div :style="element_style_string">
12923                    <div v-memo="[last_update, last_element_update]" class="element-wrapper cs-element-stack">
12924                        <template
12925                            v-for="(element, element_index) in elements_obj"
12926                            :key="element.client_id || element_index"
12927                        >
12928                            <template v-if="preview_element">
12929                                <preview-element-component
12930                                    :element="element"
12931                                    :element_type="element.type"
12932                                    :element_content="element.content"
12933                                    :element_column_count="element.column_count"
12934                                    :element_column_gap="element.column_gap"
12935                                    :element_index="element_index"
12936                                    :componentObject="component"
12937                                ></preview-element-component>
12938                            </template>
12939                            <template v-if="!preview_element">
12940                                <element-component
12941                                    :element_type="element.type"
12942                                    :element_content="element.content"
12943                                    :element_column_count="element.column_count"
12944                                    :element_column_gap="element.column_gap"
12945                                    :diff_changed="element._wb_diff_changed === true"
12946                                    :element_index="element_index"
12947                                    :section_element_animation="section_element_animation"
12948                                ></element-component>
12949                            </template>
12950                        </template>
12951                        <!-- handle case where there are no elements (admin only) -->
12952                        <template v-if="preview_element && elements_obj && elements_obj.length == 0">
12953                            <div class="no-elements-wrap">
12954                                <div 
12955                                    class="tapped-insert-new-element-relative" 
12956                                    @click="tapped_add_element( $event )"
12957                                >+</div>
12958                            </div>
12959                        </template>
12960                    </div>
12961                </div>
12962            </div>
12963            <div class="cs-image-with-text_image-wrap flexGrowOne">
12964                <div class="cs-image-with-text_image-wrap-inner">
12965                    <div :style="check_full_background()" class="cs-image-with-text_image-slider-outer">
12966                        <div 
12967                            v-if="formatting_obj.background_size !== 'full'" 
12968                            :class="['cs-image-with-text-image-mat', { background: image_mat === 'show' }]"
12969                        ></div>
12970                        <image-slider-component
12971                            :client_id="client_id"
12972                            :formatting="formatting"
12973                            :images_string="images_string"
12974                            :preview_element="preview_element"
12975                            :component="component"
12976                            :last_update="last_update"
12977                        ></image-slider-component>
12978                    </div>
12979                </div>
12980            </div>
12981        </template>
12982        <template v-if="!loading && left_right_orientation === 'left'">
12983            <!-- image first (text on the right) -->
12984            <div class="cs-image-with-text_image-wrap flexGrowOne">
12985                <div class="cs-image-with-text_image-wrap-inner">
12986                    <div :style="check_full_background()" class="cs-image-with-text_image-slider-outer">
12987                        <div 
12988                            v-if="formatting_obj.background_size !== 'full'" 
12989                            :class="['cs-image-with-text-image-mat', { background: image_mat === 'show' }]"
12990                        ></div>
12991                        <image-slider-component
12992                            :client_id="client_id"
12993                            :formatting="formatting"
12994                            :images_string="images_string"
12995                            :preview_element="preview_element"
12996                            :component="component"
12997                            :last_update="last_update"
12998                        ></image-slider-component>
12999                    </div>
13000                </div>
13001            </div>
13002            <div class="cs-image-with-text_text-wrap flexGrowOne">
13003                <div :style="element_style_string">
13004                    <div 
13005                        v-memo="[last_update, last_element_update]" 
13006                        class="element-wrapper cs-element-stack"
13007                    >
13008                        <template
13009                            v-for="(element, element_index) in elements_obj"
13010                            :key="element.client_id || element_index"
13011                        >
13012                            <template v-if="preview_element">
13013                                <preview-element-component
13014                                    :element="element"
13015                                    :element_type="element.type"
13016                                    :element_content="element.content"
13017                                    :element_column_count="element.column_count"
13018                                    :element_column_gap="element.column_gap"
13019                                    :element_index="element_index"
13020                                    :componentObject="component"
13021                                ></preview-element-component>
13022                            </template>
13023                            <template v-if="!preview_element">
13024                                <element-component
13025                                    :element_type="element.type"
13026                                    :element_content="element.content"
13027                                    :element_column_count="element.column_count"
13028                                    :element_column_gap="element.column_gap"
13029                                    :diff_changed="element._wb_diff_changed === true"
13030                                    :element_index="element_index"
13031                                    :section_element_animation="section_element_animation"
13032                                ></element-component>
13033                            </template>
13034                        </template>
13035                        <!-- handle case where there are no elements (admin only) -->
13036                        <template v-if="preview_element && elements_obj && elements_obj.length == 0">
13037                            <div class="no-elements-wrap">
13038                                <div 
13039                                    class="tapped-insert-new-element-relative" 
13040                                    @click="tapped_add_element( $event )"
13041                                >+</div>
13042                            </div>
13043                        </template>
13044                    </div>
13045                </div>
13046            </div>
13047        </template>
13048    </div>
13049</script>
13049
13050
13051<style>
13052    .delay5s{
13053        animation-delay: 0.5s;
13054    }
13055    
13056    .view_hidden{
13057        opacity: 0;
13058    }
13059
13060    @keyframes fade-left-right {
13061        0% {
13062            opacity: 0;
13063            transform: translateX(-100%) scaleX(0.5);
13064        }
13065        100% {
13066            opacity: 1;
13067            transform: translateX(0) scaleX(1);
13068        }
13069    }
13070
13071    @keyframes fade-right-left {
13072        0% {
13073            opacity: 0;
13074            transform: translateX(100%) scaleX(0.5);
13075        }
13076        100% {
13077            opacity: 1;
13078            transform: translateX(0) scaleX(1);
13079        }
13080    }
13081
13082    .fade-left-right {
13083        animation: fade-left-right 1s ease-in-out forwards;
13084    }
13085
13086    .fade-right-left {
13087        animation: fade-right-left 1s ease-in-out backwards;
13088    }
13089</style>
13090
13091<script>
13092    app_content.component('image-with-text-component', {
13093        template: '#template-image-with-text',
13094        data() {
13095            return {
13096                is_admin: false,
13097                loading: true,
13098            }
13099        },
13100        props: {
13101            class_string: String,
13102            style_string: String,
13103            element_style_string: String,
13104            elements_string: [String, Object],
13105            left_right_orientation: String,
13106            image_mat: String,
13107            //image slider
13108            client_id: String,
13109            formatting: String,
13110            images_string: String,
13111            //admin
13112            preview_element: Boolean,
13113            component: Object,
13114            last_update: Number, //last component update
13115            last_element_update: Number, //last element update
13116            tab_element: Boolean,
13117        },
13118        mounted() {
13119            this.loading = false;
13120            this.is_admin = window.location.href.indexOf('website-builder') !== -1;
13121        },
13122        updated(){
13123            this.createIntersectionObserver();
13124        },
13125        methods: {
13126            check_full_background(){
13127                if ( this.formatting_obj && this.formatting_obj.background_size === 'full' ){
13128                    return {
13129                        top: 0,
13130                        bottom: 0,
13131                        left: 0,
13132                        right: 0,
13133                    }
13134                }
13135            },
13136            tapped_add_element(){
13137                var params = {};
13138                params["component"] = this.component;
13139                params["insert_after"] = 0;
13140                params["column_index"] = 0;
13141                handle_add_element( params );
13142            },
13143            createIntersectionObserver(){
13144                const options = {
13145                    root: null, 
13146                    rootMargin: '0px',
13147                    threshold: 0.5 //trigger animation when at least 50% of the target is visible
13148                };
13149                const observer = new IntersectionObserver(this.handleIntersection, options);
13150                //observe the target element
13151                observer.observe(this.$refs.animationTarget);
13152            },
13153            handleIntersection(entries, observer) {
13154                entries.forEach(entry => {
13155                    //check if target is intersecting
13156                    if (entry.isIntersecting) {
13157                        //apply animation class
13158                        entry.target.classList.add(this.formatting_obj.animation);
13159                        entry.target.classList.remove('view_hidden');
13160                        //stop observing to prevent re-triggering
13161                        observer.unobserve(entry.target);
13162                    }
13163                });
13164            },
13165        },
13166        computed: {
13167            elements_obj(){
13168                if (this.elements_string && typeof this.elements_string === 'string'){
13169                    return JSON.parse(unescape(decodeURIComponent(this.elements_string)));
13170                } else if (this.elements_string){
13171                    return this.elements_string;
13172                } else {
13173                    return {};
13174                }
13175            },
13176            formatting_obj(){
13177                if (this.formatting){
13178                    return JSON.parse(this.formatting);
13179                } else {
13180                    return {};
13181                }
13182            },
13183            section_element_animation(){
13184                let component_animation = resolve_web_builder_component_animation(this.formatting_obj);
13185                return section_element_animation_token(component_animation);
13186            },
13187        },
13188    });
13189</script>
13189    
13189<script type="text/html" id="template-image-slider">
13190    <div class="cs-image-slider-wrap background">
13191        <div 
13192            class="cs-image-slider"
13193            :class="[ 'cs-image-slider-' + filtered_images_length, 'cs-image-slider_' + client_id ]"
13194            :data-client-id="client_id" 
13195            data-interval="8000" 
13196            data-slider-index="0"
13197        >
13198            <!-- now add the images -->
13199            <template v-if="!loading && filtered_images_length > 0" v-for="(image, index) in filtered_images">
13200                <template v-if="image && typeof image !== 'object'">
13201                    <template v-if="image && image.indexOf('video') >= 1">
13202                        <div
13203                            class="cs-image-slider-card"
13204                            :class="[
13205                                'cs-image-slider-card-' + index,
13206                                { 'active': index === display_index },
13207                                { 'cs-image-slot-attached-host': preview_element && !is_hero_banner }
13208                            ]"
13209                            style="display:flex;align-items:center;justify-content:center;"
13210                            @click.stop="preview_element && !is_hero_banner && change_image_slot(index)"
13211                        >
13212                            <video class="cs-background-video" autoplay playsinline loop muted :src="image" style="display:initial;"></video>
13213                            <button
13214                                v-if="preview_element && !is_hero_banner"
13215                                type="button"
13216                                class="cs-image-slot-change-chip exclude-from-custom-button-styles"
13217                                @click.stop="change_image_slot(index)"
13218                            ><i class="fa-regular fa-image"></i><span>Change</span></button>
13219                        </div>
13220                    </template>
13221                    <template v-if="image && typeof image === 'string'">
13222                        <div
13223                            class="cs-image-slider-card"
13224                            :class="[
13225                                'cs-image-slider-card-' + index,
13226                                { 'active': index === display_index },
13227                                { 'cs-image-slot-attached-host': preview_element && !is_hero_banner }
13228                            ]"
13229                            @click.stop="preview_element && !is_hero_banner && change_image_slot(index)"
13230                        >
13231                            <div class="cs-background-image-photo" :style="`background-image: url(${image})`"></div>
13232                            <button
13233                                v-if="preview_element && !is_hero_banner"
13234                                type="button"
13235                                class="cs-image-slot-change-chip exclude-from-custom-button-styles"
13236                                @click.stop="change_image_slot(index)"
13237                            ><i class="fa-regular fa-image"></i><span>Change</span></button>
13238                        </div>
13239                    </template>
13240                </template>
13241                <template v-if="is_image_with_text && !image">
13242                    <div 
13243                        class="cs-image-slider-card"
13244                        :class="[
13245                            'cs-image-slider-card-' + index,
13246                            { 'active': index === display_index },
13247                            { 'cs-image-slot-placeholder-host': preview_element }
13248                        ]"
13249                        @click.stop="fill_image_slot(index)"
13250                    >
13251                        <div v-if="preview_element" class="cs-image-slot-placeholder">
13252                            <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span>
13253                            <span class="cs-image-slot-placeholder-label">Add image</span>
13254                        </div>
13255                        <div v-else class="cs-background-image-photo default-image-icon" style="background-size:30%!important;"></div>
13256                    </div>
13257                </template>
13258            </template>
13259            <template v-for="(layer, layer_index) in overlay_layers" :key="'overlay-layer-' + layer_index + '-' + (layer.class_string || layer.kind || '')">
13260                <div
13261                    class="cs-image-overlay"
13262                    :class="(layer.class_string || '').trim().split(/\s+/).filter(
13262Boolean)"
13263                    :style="layer.style_obj"
13264                ></div>
13265            </template>
13266        </div>
13267    </div>
13268</script>
13268
13269
13270<style>
13271    /* Preview-only "Change" chip for hero-banner / image-with-text slots. */
13272    .cs-image-slot-attached-host {
13273        cursor: pointer;
13274    }
13275    .cs-image-slot-change-chip {
13276        position: absolute;
13277        top: 12px;
13278        right: 12px;
13279        z-index: 2000;
13280        display: inline-flex;
13281        align-items: center;
13282        gap: 5px;
13283        padding: 6px 10px;
13284        border: none;
13285        border-radius: 6px;
13286        background: rgba(20, 24, 45, 0.82);
13287        color: #fff;
13288        font-family: 'Roboto', Arial, Helvetica, sans-serif;
13289        font-size: 12px;
13290        font-weight: 600;
13291        font-style: normal;
13292        line-height: 1;
13293        letter-spacing: 0;
13294        text-transform: none;
13295        cursor: pointer;
13296        opacity: 0.92;
13297        pointer-events: auto;
13298        box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22);
13299        transition: opacity 0.12s ease, background 0.12s ease;
13300    }
13301    .cs-image-slot-attached-host:hover .cs-image-slot-change-chip,
13302    .cs-image-slot-change-chip:hover,
13303    .cs-image-slot-change-chip:focus {
13304        opacity: 1;
13305        background: rgba(20, 24, 45, 0.95);
13306    }
13307</style>
13308
13309<script>    
13310    /* ----- COMPONENT ----- */
13311    app_content.component('image-slider-component', {
13312        template: '#template-image-slider',
13313        data() {
13314            return {
13315                loading: true,
13316                display_index: 0,
13317            }
13318        },
13319        props: {
13320            client_id: String,
13321            formatting: String,
13322            images_string: String,
13323            //for preview element
13324            preview_element: {
13325                type: Boolean,
13326                default: false,
13327            },
13328            component: {
13329                type: Object,
13330                default: null,
13331            },
13332            last_update: {
13333                type: Number,
13334                default: null,
13335            },
13336        },
13337        mounted() {
13338            this.loading = false;
13339            this.sync_display_index_with_config_and_images();
13340        },
13341        methods: {
13342            parsed_images(){
13343                if( !this.images_string ){
13344                    return [];
13345                }
13346                try {
13347                    let parsed = JSON.parse(this.images_string);
13348                    return Array.isArray(parsed) ? parsed : [];
13349                } catch (error) {
13350                    console.error('Error parsing image slider images: ', error);
13351                    return [];
13352                }
13353            },
13354            // image-with-text keeps an empty slot in preview for the Add-image card.
13355            // Hero has no image-layer fill — empty state is the section color_scheme.
13356            preview_images_with_slots(){
13357                let parsed = this.parsed_images();
13358                if( !this.preview_element || (!this.is_hero_banner && !this.is_image_with_text) ){
13359                    return parsed;
13360                }
13361                if( this.is_hero_banner ){
13362                    return parsed.filter((image) => {
13363                        if( image ){
13364                            return true;
13365                        }
13366                        return false;
13367                    });
13368                }
13369                if( !parsed.length ){
13370                    return [null];
13371                }
13372                return parsed;
13373            },
13374            return_first_filled_index( images ){
13375                if( !Array.isArray(images) || images.length === 0 ){
13376                    return 0;
13377                }
13378                for( let i = 0; i < images.length; i++ ){
13379                    if( images[i] ){
13380                        return i;
13381                    }
13382                }
13383                return 0;
13384            },
13385            sync_display_index_with_config_and_images(){
13386                let config_latest = this.component ? (this.component.get('config_latest') || {}) : {};
13387                let saved_display_index = Number(config_latest.display_index);
13388                let has_saved_display_index = config_latest.display_index === 0 || !isNaN(saved_display_index);
13389                let fallback_index = this.display_index;
13390                if( this.preview_element && this.is_image_with_text ){
13391                    fallback_index = this.return_first_filled_index(this.parsed_images());
13392                }
13393                let target_index = has_saved_display_index ? saved_display_index : fallback_index;
13394                let max_index = this.filtered_images_length > 0 ? this.filtered_images_length - 1 : 0;
13395                this.display_index = Math.min(Math.max(target_index, 0), max_index);
13396            },
13397            // Write `value` (a URL string or null) into slot `index`, preserving
13398            // the fixed 5-slot string|null shape the settings panel expects.
13399            write_image_slot( index, value ){
13400                let component = this.component;
13401                if( !component ){
13402                    return;
13403                }
13404                let current = (component.get('config_latest') && component.get('config_latest').images) || [];
13405                let next = [];
13406                for( let i = 0; i <= 4; i++ ){
13407                    if( i === index ){
13408                        next.push( value );
13409                    } else if( current[i] ){
13410                        next.push( current[i] );
13411                    } else {
13412                        next.push( null );
13413                    }
13414                }
13415                this.display_index = index;
13416                save_changes_for_component( component, 'images', next );
13417                save_changes_for_component( component, 'display_index', index );
13418            },
13419            // Empty-slot picker for image-with-text / hero-banner.
13420            fill_image_slot( index ){
13421                if( !this.preview_element ){
13422                    return;
13423                }
13424                if( typeof webbuilder_open_image_slot_picker !== 'function' ){
13425                    if( typeof displayMessage === 'function' ){
13426                        displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000);
13427                    }
13428                    return;
13429                }
13430                if( !this.component ){
13431                    return;
13432                }
13433                webbuilder_open_image_slot_picker({
13434                    label: 'image',
13435                    mode: 'placeholder',
13436                    on_pick: ( url ) => {
13437                        if( !url ){
13438                            return;
13439                        }
13440                        this.write_image_slot( index, url );
13441                    },
13442                });
13443            },
13444            // Filled-slot "Change" action — swap or remove the image in slot
13445            // `index` via the shared picker (pick/paste/copy-external/remove),
13446            // matching custom-code's attached-asset menu.
13447            change_image_slot( index ){
13448                if( !this.preview_element ){
13449                    return;
13450                }
13451                if( typeof webbuilder_open_image_slot_picker !== 'function' ){
13452                    if( typeof displayMessage === 'function' ){
13453                        displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000);
13454                    }
13455                    return;
13456                }
13457                let component = this.component;
13458                if( !component ){
13459                    return;
13460                }
13461                let current = (component.get('config_latest') && component.get('config_latest').images) || [];
13462                let current_url = typeof current[index] === 'string' ? current[index] : '';
13463                webbuilder_open_image_slot_picker({
13464                    label: 'image',
13465                    mode: 'attached',
13466                    current_url: current_url,
13467                    on_pick: ( url ) => {
13468                        if( !url ){
13469                            return;
13470                        }
13471                        this.write_image_slot( index, url );
13472                    },
13473                    on_remove: () => {
13474                        this.write_image_slot( index, null );
13475                    },
13476                });
13477            },
13478        },
13479        created(){
13480            setInterval(() => {
13481                if (!this.preview_element){
13482                    if (this.display_index < this.filtered_images_length - 1){
13483                        this.display_index++;
13484                    } else {
13485                        this.display_index = 0;
13486                    }
13487                }
13488            }, 8000);
13489        },
13490        computed: {
13491            component_type(){
13492                if( this.component && typeof this.component.get === 'function' ){
13493                    return this.component.get('type');
13494                }
13495                return null;
13496            },
13497            is_hero_banner(){
13498                return this.component_type === 'hero-banner';
13499            },
13500            is_image_with_text(){
13501                return this.component_type === 'image-with-text';
13502            },
13503            formatting_obj(){
13504                if (this.formatting){
13505                    return JSON.parse(this.formatting);
13506                }
13507            },
13508            overlay_layers(){
13509                let formatting = this.formatting_obj || {};
13510                if( typeof build_web_builder_overlay_layers === 'function' ){
13511                    return build_web_builder_overlay_layers(formatting).layers || [];
13512                }
13513                if( typeof build_web_builder_overlay_attrs === 'function' ){
13514                    let attrs = build_web_builder_overlay_attrs(formatting);
13515                    return attrs.has_overlay ? [{ class_string: attrs.class_string, style_obj: attrs.style_obj }] : [];
13516                }
13517                let opacity = Number(formatting.overlay_opacity) || 0;
13518                if( opacity > 0 ){
13519                    return [{ class_string: '', style_obj: { 'opacity': opacity / 100 } }];
13520                }
13521                return [];
13522            },
13523            filtered_images(){
13524                if (!this.preview_element){
13525                    return this.parsed_images().filter(image => {
13526                        if ( image !== null ){
13527                            return image;
13528                        }
13529                    });
13530                } else {
13531                    return this.preview_images_with_slots();
13532                }
13533                return [];
13534            },
13535            filtered_images_length(){
13536                if (!this.preview_element){
13537                    return this.parsed_images().filter(image => {
13538                        if ( image !== null ){
13539                            return image;
13540                        }
13541                    }).length;
13542                } else {
13543                    return this.preview_images_with_slots().length;
13544                }
13545                return 0;
13546            },
13547        },
13548        watch: {
13549            last_update(newVal, oldVal){
13550                if (newVal !== oldVal){
13551                    this.sync_display_index_with_config_and_images();
13552                }
13553            },
13554        }
13555    });
13556</script>
13556    
13556<script type="text/html" id="template-hero-banner">
13557    <div class="cs-background-image background relative" :class="[class_string, { 'scroll-fade': !is_admin && !tab_element }]" :style="style_string">
13558        <image-slider-component
13559            :client_id="client_id"
13560            :formatting="formatting"
13561            :images_string="images_string"
13562            :preview_element="preview_element"
13563            :component="component"
13564            :last_update="last_update"
13565        ></image-slider-component>
13566        <div 
13567            class="element-wrapper" 
13568            :class="'element-wrapper_' + component_type" 
13569            :style="element_style_string"
13570        >
13571            <div v-memo="[last_update, last_element_update]" class="cs-element-stack">
13572                <!-- loop through elements -->
13573                <template
13574                    v-for="(element, element_index) in elements_obj"
13575                    :key="element.client_id || element_index"
13576                >
13577                    <template v-if="preview_element">
13578                        <preview-element-component
13579                            :element="element"
13580                            :element_type="element.type"
13581                            :element_content="element.content"
13582                            :element_column_count="element.column_count"
13583                            :element_column_gap="element.column_gap"
13584                            :element_index="element_index"
13585                            :componentObject="component"
13586                        ></preview-element-component>
13587                    </template>
13588                    <template v-if="!preview_element">
13589                        <element-component
13590                            :element_type="element.type"
13591                            :element_content="element.content"
13592                            :element_column_count="element.column_count"
13593                            :element_column_gap="element.column_gap"
13594                            :diff_changed="element._wb_diff_changed === true"
13595                            :element_index="element_index"
13596                            :section_element_animation="section_element_animation"
13597                        ></element-component>
13598                    </template>
13599                </template>
13600                <!-- handle case where there are no elements (admin only) -->
13601                <template v-if="preview_element && elements_obj.length == 0">
13602                    <div class="no-elements-wrap">
13603                        <div 
13604                            class="tapped-insert-new-element-relative" 
13605                            @click="tapped_add_element( $event )"
13606                        >+</div>
13607                    </div>
13608                </template>
13609            </div>
13610        </div>
13611    </div>
13612</script>
13612
13613
13614<script>
13615    app_content.component('hero-banner-component', {
13616        template: '#template-hero-banner',
13617        data() {
13618            return {
13619                is_admin: false,
13620                loading: true,
13621            }
13622        },
13623        props: {
13624            class_string: String,
13625            style_string: String,
13626            element_style_string: String,
13627            component_type: String,
13628            elements_string: [String, Object],
13629            //image slider
13630            client_id: String,
13631            formatting: String,
13632            images_string: String,
13633            //admin
13634            preview_element: Boolean,
13635            component: Object,
13636            last_update: Number, //last component update
13637            last_element_update: Number, //last element update
13638            tab_element: Boolean,
13639        },
13640        mounted() {
13641            this.loading = false;
13642            this.is_admin = window.location.href.indexOf('website-builder') !== -1;
13643        },
13644        methods:{
13645            tapped_add_element(){
13646                var params = {};
13647                params["component"] = this.component;
13648                params["insert_after"] = 0;
13649                params["column_index"] = 0;
13650                handle_add_element( params );
13651            },
13652            parsed_hero_images(){
13653                if( !this.images_string ){
13654                    return [];
13655                }
13656                try {
13657                    let parsed = JSON.parse(this.images_string);
13658                    return Array.isArray(parsed) ? parsed : [];
13659                } catch (error) {
13660                    console.error('Error parsing hero banner images: ', error);
13661                    return [];
13662                }
13663            },
13664            // Prefer the first filled slot so multi-image heroes edit the visible one.
13665            hero_image_slot_index(){
13666                let images = this.parsed_hero_images();
13667                for( let i = 0; i < images.length; i++ ){
13668                    if( images[i] ){
13669                        return i;
13670                    }
13671                }
13672                return 0;
13673            },
13674            write_hero_image_slot( index, value ){
13675                let component = this.component;
13676                if( !component ){
13677                    return;
13678                }
13679                let current = (component.get('config_latest') && component.get('config_latest').images) || [];
13680                let next = [];
13681                for( let i = 0; i <= 4; i++ ){
13682                    if( i === index ){
13683                        next.push( value );
13684                    } else if( current[i] ){
13685                        next.push( current[i] );
13686                    } else {
13687                        next.push( null );
13688                    }
13689                }
13690                save_changes_for_component( component, 'images', next );
13691            },
13692            tapped_change_hero_image(){
13693                if( !this.preview_element ){
13694                    return;
13695                }
13696                if( typeof webbuilder_open_image_slot_picker !== 'function' ){
13697                    if( typeof displayMessage === 'function' ){
13698                        displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000);
13699                    }
13700                    return;
13701                }
13702                if( !this.component ){
13703                    return;
13704                }
13705                let index = this.hero_image_slot_index();
13706                let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || [];
13707                let current_url = typeof current[index] === 'string' ? current[index] : '';
13708                if( current_url ){
13709                    webbuilder_open_image_slot_picker({
13710                        label: 'image',
13711                        mode: 'attached',
13712                        current_url: current_url,
13713                        on_pick: ( url ) => {
13714                            if( !url ){
13715                                return;
13716                            }
13717                            this.write_hero_image_slot( index, url );
13718                        },
13719                        on_remove: () => {
13720                            this.write_hero_image_slot( index, null );
13721                        },
13722                    });
13723                    return;
13724                }
13725                webbuilder_open_image_slot_picker({
13726                    label: 'image',
13727                    mode: 'placeholder',
13728                    on_pick: ( url ) => {
13729                        if( !url ){
13730                            return;
13731                        }
13732                        this.write_hero_image_slot( index, url );
13733                    },
13734                });
13735            },
13736        },
13737        computed: {
13738            elements_obj(){
13739                if (this.elements_string && typeof this.elements_string === 'string'){
13740                    return JSON.parse(unescape(decodeURIComponent(this.elements_string)))
13741                } else {
13742                    return this.elements_string
13743                }
13744                return [];
13745            },
13746            formatting_obj(){
13747                if( this.formatting && typeof this.formatting === 'string' ){
13748                    try {
13749                        return JSON.parse(this.formatting);
13750                    } catch (error) {
13751                        return {};
13752                    }
13753                }
13754                if( this.formatting && typeof this.formatting === 'object' ){
13755                    return this.formatting;
13756                }
13757                return {};
13758            },
13759            section_element_animation(){
13760                let component_animation = resolve_web_builder_component_animation(this.formatting_obj);
13761                return section_element_animation_token(component_animation);
13762            },
13763        },
13764    });
13765</script>
13765    
13765<script type="text/html" id="template-element">
13766    <template v-if="element_type === 'accordion_header'">
13767        <div
13768            class="cs-accordion-header"
13769            :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]"
13770            :style="element_anim_inline_style"
13771        >
13772            <div class="cs-accordion-header-toggle-wrap flexWrap">
13773                <div class="cs-accordion-header-toggle">+</div>
13774            </div>
13775            <span v-html="element_content"></span>
13776        </div>
13777    </template>
13778    <template v-if="element_type === 'accordion_body'">
13779        <div 
13780            class="cs-accordion-body"
13781            :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]"
13782            v-html="element_content"
13783            :style="element_body_inline_style"
13784        ></div>
13785    </template>
13786    <template v-else-if="element_type !== 'accordion_header' && element_type !== 'accordion_body'">
13787        <div
13788            :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]"
13789            :style="element_body_inline_style"
13790        >
13791            <span v-html="element_content"></span>
13792        </div>
13793    </template>
13794</script>
13794
13795
13796<script>
13797    app_content.component('element-component', {
13798        template: '#template-element',
13799        data() {
13800            return {
13801                loading: true,
13802            }
13803        },
13804        props: {
13805            element_type: String,
13806            element_content: String,
13807            element_column_count: Number,
13808            element_column_gap: Number,
13809            diff_changed: Boolean,
13810            element_index: {
13811                type: [Number, String],
13812                default: 0,
13813            },
13814            section_element_animation: {
13815                type: String,
13816                default: '',
13817            },
13818        },
13819        mounted() {
13820            this.loading = false;
13821        },
13822        computed: {
13823            uses_section_element_animation(){
13824                return this.section_element_animation === 'up'
13825                    || this.section_element_animation === 'fade_up'
13826                    || this.section_element_animation === 'fade_in';
13827            },
13828            element_anim_class(){
13829                if( this.uses_section_element_animation ){
13830                    return 'cs-el-anim-target';
13831                }
13832                return '';
13833            },
13834            el_index_style_fragment(){
13835                if( this.uses_section_element_animation ){
13836                    return '--el-index:' + ( Number(this.element_index) || 0 ) + ';';
13837                }
13838                return '';
13839            },
13840            element_anim_inline_style(){
13841                return this.el_index_style_fragment;
13842            },
13843            element_body_inline_style(){
13844                return 'column-count: ' + this.element_column_count + ' !important; column-gap: ' + this.element_column_gap + 'em;' + this.el_index_style_fragment;
13845            },
13846        },
13847    });
13848</script>
13848
13849    
13849<script type="text/html" id="template-unlayer-component">    
13850    <template v-if="preview_element">
13851        <div class="cs-unlayer-component-wrap unlayer-content" :class="class_string">
13852            <!-- hover button to open unlayer builder -->
13853            <div class="darken-overlay easeFast">
13854               <div class="hover-toolbar">          
13855                   <div class="hover-button flexNoWrap centeredBlock pointer" @click="openUnlayerBuilder()">
13856                       <div class="hover-button-text">
13857                           Update <i class="fa-regular fa-pen-to-square" style="padding-left:3px;"></i>
13858                       </div>
13859                   </div>
13860               </div>
13861           </div>
13862            <!-- if there is content -->
13863            <div v-if="!loading && unlayerHtml" class="relative" >
13864                <div 
13865                    :id="'freeform_id_' + component.id"  
13866                    v-html="return_html_with_css()"
13867                ></div>
13868            </div>
13869            <!-- if there is no content -->
13870            <div v-else-if="!loading">
13871                <div class="addFreeformContentZone" @click="openUnlayerBuilder()">
13872                    <div 
13873                        class="quick-action-wrap noMaxWidth centeredBlock"
13874                    >
13875                        <div class="quick-action-icon-wrap">
13876                            <i class="fa-light fa-scribble"></i>
13877                        </div>
13878                        <p class="quick-action-title" style="font-family:roboto;">Freeform section</p>
13879                    </div>
13880                </div>
13881            </div>
13882        </div>
13883    </template>
13884    <template v-if="!preview_element">
13885        <div class="cs-unlayer-component-wrap">
13886            <div class="relative"></div>
13887            <div v-if="!loading" v-html="return_html_with_css()"></div>
13888        </div>
13889    </template>
13890</script>
13890
13891
13892<script>
13893
13894    function createDebounce() {
13895        let timeout = null;
13896        return function (fnc, delayMs) {
13897            clearTimeout(timeout);
13898            timeout = setTimeout(() => {
13899                fnc();
13900            }, delayMs || 800);
13901        };
13902    }
13903    app_content.component('unlayer-component', {
13904        template: '#template-unlayer-component',
13905        data() {
13906            return {
13907                unlayerHtml: null,
13908                loading: true,
13909                debounce: createDebounce(),
13910                template: null,
13911                unlayer: '',
13912            }
13913        },
13914        props: {
13915            unlayer_chunks: Object,
13916            class_string: String,
13917            preview_element: {
13918                type: Boolean,
13919                default: false,
13920            },
13921            component: {
13922                type: Object,
13923                default: null,
13924            },
13925        },
13926        mounted() {
13927            if(!this.unlayer_chunks){       
13928                // find template for component id
13929                this.return_unlayer_data()
13930            }
13931            this.loading = false;
13932        },
13933        methods: {
13934            return_unlayer_data(){
13935                let config_latest = this.component.get('config_latest') || {};
13936                if(!config_latest.unlayer_component || !config_latest.unlayer_component.chunks){
13937                    return;
13938                }
13939                this.unlayerHtml = config_latest.unlayer_component.chunks.body || '';
13940                this.unlayerHtml = this.unlayerHtml.replace('style="min-height: 100vh;', 'style="min-height: auto;');
13941            },
13942            openUnlayerBuilder(){
13943                let jsonContent;
13944                let config_latest = this.component.get('config_latest') || {};
13945                if(config_latest.unlayer_component){
13946                    jsonContent = config_latest.unlayer_component.design;
13947                }
13948                let params = {
13949                    jsonContent: jsonContent,
13950                    fn: this.saveUnlayerContent
13951                }
13952                launch_unlayer_builder(params)
13953            },
13954            saveUnlayerContent(content){
13955                let promise = Promise.resolve().then(() => {
13956                    let htmlContent = content && content.chunks ? (content.chunks.body || '') : '';
13957                    htmlContent = htmlContent.replace('style="min-height: 100vh;', 'style="min-height: auto;');
13958                    this.unlayerHtml = htmlContent;
13959                    save_changes_for_component( this.component, 'unlayer_component', content );
13960                })
13961                return promise;
13962            }, 
13963            return_html_with_css(){
13964                let data;
13965                if(this.preview_element){
13966                    let config_latest = this.component.get('config_latest') || {};
13967                    if(!config_latest.unlayer_component || !config_latest.unlayer_component.chunks){
13968                        return '';
13969                    }
13970                    data = config_latest.unlayer_component.chunks;
13971                }else{
13972                    data = this.unlayer_chunks;
13973                }
13974                if(!data){
13975                    return '';
13976                }
13977                let css = data.css || '';
13978                let body = data.body || '';
13979                let html_with_css = juice.inlineContent(body, css);
13980                html_with_css = html_with_css.replace('100vh', 'auto')
13981                return html_with_css
13982            }        
13983        },
13984        computed: {
13985              
13986        }
13987    });
13988</script>
13988
13989
13990<style scoped>
13991
13992    @media screen and (max-width: 768px) {
13993        .u-row:not(.no-stack) .u-col:not(:only-child) {
13994            flex: 0 0 50% !important;
13995            max-width: 50% !important;
13996        }
13997    }
13998    @media screen and (max-width: 480px) {
13999        .u-col {
14000            flex: 0 0 100% !important; /* One column per row */
14001            max-width: 100% !important;
14002        }
14003    }
14004
14005</style>    
14005<script type="text/html" id="template-form">
14006    <div class="form-wrapper centeredBlock centeredText">
14007        <template v-if="!loading && is_admin">
14008            <div
14009                v-if="!form_id"
14010                @click="link_form()"
14011                class="noObjectsMSG centeredBlock centeredText"
14012                style="padding: 5em;"
14013            >
14014                <p style="cursor: pointer;"><i class="fa-light fa-list-dropdown smallMarginRight"></i>Click to link a form</p>
14015            </div>
14016        </template>
14017        <template v-if="!loading">
14018            <div 
14019                v-if="form_id && json_form" 
14020                id="form-template"
14021                class="cs-min-section-width"
14022                :style="{ 'max-width': formatting_obj.section_width + '%' }"
14023            >
14024                <div
14025                    class="form-color-theme"
14026                    :style="{ 'background-color': color_theme }">
14027                </div>
14028                <div class="form-header-wrapper">
14029                    <!-- name -->
14030                    <div v-if="json_form && json_form.name" class="form-name flex-row-start">
14031                        <h3 class="default-font-css" style="font-size: 32px; padding: 5px; text-align: left;">{{ json_form.name }}</h3>
14032                    </div>
14033                    <!-- description -->
14034                    <div v-if="json_form && json_form.description" class="form-description flex-row-start tinyMarginTop">
14035                        <p class="default-font-css" style="padding: 5px; text-align: left;">{{ json_form.description }}</p>
14036                    </div>
14037                    <!-- disabled notice -->
14038                    <div v-if="form_disabled" class="form-description flex-row-start tinyMarginTop">
14039                        <p class="extra-copy-v2" style="padding: 5px; text-align: left;">This form is currently disabled and not accepting responses.</p>
14040                    </div>
14041                    <template v-if="!valid_membership">
14042                        <div
14043                            @click="tapped_log_in()"
14044                            class="noObjectsMSG leftText"
14045                            style="padding: 5px; min-width: fit-content;"
14046                        >
14047                            <p style="cursor: pointer;"><i class="fa-light fa-circle-exclamation smallMarginRight"></i>This form is restricted to members only. Please <a :href="login_url">click here to log in</a> and view this form.</p>
14048                        </div>
14049                    </template>
14050                </div>
14051
14052                <template v-if="valid_membership && !pending_redirect_url">
14053
14054                    <!-- default fields -->
14055                    <template v-if="collect_default_fields && selected_fields.length > 0">
14056                        <default-field-component-wb
14057                            :form="json_form"
14058                            :is_admin="is_admin"
14059                            :default_fields="selected_fields"
14060                            :rerender_key="rerender_key"
14061                        ></default-field-component-wb>
14062                    </template>
14063                    <!-- custom fields -->
14064                    <template v-if="json_form.custom_fields" v-for="(custom_field, index) in custom_fields">
14065                        <custom-field-component-wb
14066                            :id="custom_field.id"
14067                            :form="json_form"
14068                            :is_admin="is_admin"
14069                            :custom_field="custom_field"
14070                            :last_update="last_update"
14071                            ref="custom_field_components"
14072                            @field-updated="handle_field_update"
14073                        ></custom-field-component-wb>
14074                    </template>
14075                    <!-- submit -->
14076                    <div style="width: 100%; display: flex; justify-content: space-between; align-items: center;" class="tinyMarginTop">
14077                        <button 
14078                            v-if="!loading_submission"
14079                            @click="tapped_submit_form()"
14080                            :style="{ 'background-color': button_disabled ? 'var(--custom-field-grey-2)' : color_theme }"
14081                            :class="[ 'default-font-css', 'submit-button', { 'inactive': button_disabled } ]"
14082                            :disabled="is_admin"
14083                        >Submit</button>
14084                        <i 
14085                            v-if="loading_submission"
14086                            class="fa-sharp-duotone fa-solid fa-spinner-third fa-spin smallMarginLeft"
14087                            :style="{ 'color': color_theme, 'font-size': '1.2em', 'animation-duration': '1.2s' }"
14088                        ></i>
14089                        <p 
14090                            @click="!loading_submission && tapped_clear_form()"
14091                            :style="{ 'color': loading_submission ?  'var(--custom-field-grey-2)': color_theme }"
14092                            style="font-size: 14px; font-weight: 600; cursor: pointer;"
14093                        >Clear form</p>
14094                    </div>
14095                </template>
14096                <template v-if="valid_membership && pending_redirect_url">
14097                    <div class="centeredText" style="padding: 2em 5px;">
14098                        <i
14099                            class="fa-solid fa-circle-check solid-circle-check"
14100                            style="font-size: 48px;"
14101                        ></i>
14102                        <h3 class="default-font-css smallMarginTop">Response submitted</h3>
14103                        <p class="extra-copy-v2 tinyMarginTop">Redirecting...</p>
14104                    </div>
14105                </template>
14106            </div>
14107            <div
14108                v-if="form_id && !json_form" 
14109                class="noObjectsMSG centeredBlock centeredText"
14110                style="padding: 5em;"
14111            >
14112                <p style="cursor: pointer;"><i class="fa-light fa-list-dropdown smallMarginRight"></i>No form found.</p>
14113            </div>
14114        </template>
14115    </div>
14116</script>
14116
14117
14118<style>
14119   
14120    #form-template{
14121        display: flex;
14122        flex-direction: column;
14123        align-items: center;
14124        justify-content: flex-start;
14125        margin: 0.5em 0.5em 1em 0.5em;
14126        padding: 0 0 1.5em 0;
14127        width: 100%;
14128        max-width: 700px; 
14129        height: 100%;
14130    }
14131    #form-template .form-color-theme{
14132        color: #fff;
14133        height: 10px;
14134        border-top-left-radius: 8px;
14135        border-top-right-radius: 8px;
14136        width: 100%;
14137        padding: 0 1.5em 0 1.5em;
14138        box-sizing: border-box;
14139    }
14140    #form-template .default-font-css{
14141        font-family: Roboto, Arial, Helvetica, sans-serif;
14142        font-size: 16px;
14143        font-weight: 400;
14144    }
14145    #form-template .flex-row-start{
14146        display: flex;
14147        flex-direction: row;
14148        align-items: center;
14149        justify-content: flex-start;
14150    }
14151    #form-template .submit-button{
14152        color: #fff; 
14153        border-radius: 6px; 
14154        font-size: 14px; 
14155        padding: 8px 24px; 
14156        cursor: pointer;
14157        border: none;
14158    }
14159    #form-template .submit-button.inactive{
14160        color: #fff; 
14161        border-radius: 6px; 
14162        font-size: 14px; 
14163        padding: 8px 24px; 
14164        cursor: auto;
14165        border: none;
14166    }
14167    .form-wrapper{
14168        display: flex;
14169        align-items: center;
14170        justify-content: center;
14171        height: fit-content;
14172        width: 100%;
14173    }    
14174    .form-header-wrapper{
14175        border-width: 0px 1px 1px 1px;
14176        border-color: rgba(195, 195, 195, 0.7);
14177        border-style: solid;
14178        width: 100%;
14179        border-bottom-left-radius: 8px;
14180        border-bottom-right-radius: 8px;
14181        padding: 1.45em;
14182        box-sizing: border-box;
14183    }
14184    .form-name{
14185        color: rgb(32, 33, 36);
14186        box-sizing: border-box;
14187        font-weight: 400;
14188        font-size: 32px;
14189        width: 100%;
14190        min-height: 21px;
14191        border-bottom: 1.5px solid transparent;
14192        transition: border-bottom 0.1s;
14193    }
14194    .form-description{
14195        color: rgb(32, 33, 36);
14196        box-sizing: border-box;
14197        font-weight: 400;
14198        font-size: 14px;
14199        width: 100%;
14200        line-height: 1.5;
14201        letter-spacing: 0;
14202        min-height: 21px;
14203        border-bottom: 1.5px solid transparent;
14204        transition: border-bottom 0.1s;
14205    }
14206    .form-name.input-is-focused, .form-description.input-is-focused{
14207        border-bottom: 1.5px solid rgba(195, 195, 195, 0.7);
14208    }
14209</style>
14210
14211<script>
14212    app_content.component("form-component", {
14213        template: "#template-form",
14214        data() {
14215            return {
14216                global: store,
14217                loading: false,
14218                loading_submission: false,
14219                json_form: null,
14220                invalid_membership: false,
14221                selected_fields: [
14222                    {
14223                        label: 'First name',
14224                        value: 'firstName',
14225                        selected: false,
14226                        input_value: null,
14227                    },
14228                    {
14229                        label: 'Last name',
14230                        value: 'lastName',
14231                        selected: false,
14232                        input_value: null,
14233                    },
14234                    {
14235                        label: 'Email',
14236                        value: 'email',
14237                        selected: false,
14238                        input_value: null,
14239                    },
14240                    {
14241                        label: 'Mobile',
14242                        value: 'mobile',
14243                        selected: false,
14244                        input_value: null,
14245                    },
14246                ], 
14247                login_url: null,
14248                pending_redirect_url: null,
14249                redirect_timeout: null,
14250            };
14251
14252        },
14253        props: {
14254            client_id: String,
14255            is_admin: Boolean,
14256            component: Object,
14257            form: Object,
14258            formatting: String,
14259            last_update: String,
14260        },
14261        mounted() {
14262            this.load_forms( this.client_id );
14263        },
14264        beforeUnmount() {
14265            if (this.redirect_timeout) {
14266                clearTimeout(this.redirect_timeout);
14267            }
14268        },
14269        methods: {
14270            load_forms( client_id, overrides = {} ){
14271                this.loading = true;
14272                var promise = Parse.Promise.as();
14273                var thisPromise = promise.then(() => {
14274                    var params = {};
14275                    params["client_id"] = client_id;
14276                    params["form_id"] = this.form_id;
14277                    params["overrides"] = overrides;
14278                    params["request_url"] = window.location.href;
14279                    return Parse.Cloud.run('retrieve_data_for_component', params);
14280                }).then(retrieved_form => {
14281                    this.json_form = retrieved_form;
14282                    if(this.json_form.members_only){
14283                        //check if theres a logged in member and redirect them if needed
14284                        return this.check_valid_membership().then(is_member => {
14285                            if(is_member){
14286                                this.invalid_membership = false;
14287                                this.set_default_fields();
14288                                if ( this.is_admin ){
14289                                    return Parse.Promise.as();
14290                                }
14291                                return this.prefill_member_fields();
14292                            }
14293                        }).catch(error => {
14294                            if(error === "invalid_membership"){
14295                                this.invalid_membership = true;
14296                                this.loading = false;
14297                                this.set_login_url();
14298                                // return the error
14299                                return Parse.Promise.error("invalid_membership");
14300                            }
14301                        });
14302                    }else{
14303                        this.set_default_fields();
14304                        return Parse.Promise.as();
14305                    }
14306                    
14307                }).then(() => {
14308                    this.loading = false;
14309                }, error => {
14310                    checkParseError(error);
14311                    this.loading = false;
14312                });
14313                return thisPromise;
14314            },
14315            prefill_member_fields(){
14316                let query = new Parse.Query(Parse.User);
14317                query.include('memberObject');
14318                return query.get(Parse.User.current().id).then(fullUser => {
14319                    let memberObject = fullUser.get('memberObject');
14320                    if ( !memberObject ){
14321                        return Promise.resolve();
14322                    }
14323                    let member_data = {
14324                        firstName: memberObject.get('firstName') || null,
14325                        lastName: memberObject.get('lastName') || null,
14326                        email: memberObject.get('email') || null,
14327                        mobile: memberObject.get('mobile') || null,
14328                    };
14329                    this.selected_fields.flat().forEach(field => {
14330                        if ( member_data[field.value] ){
14331                            field.input_value = member_data[field.value];
14332                        }
14333                    });
14334                    return Promise.resolve();
14335                });
14336            },
14337            check_valid_membership(){
14338                var club_id = $('body').data('clubId');
14339                var user = Parse.User.current();
14340
14341                var memberObject;
14342                var promise = Parse.Promise.as();
14343                var thisPromise = promise.then(() => {
14344                    if( user && user.get('memberObject') ){
14345                        //ok, get the member and membership
14346                        var query = new Parse.Query("members");
14347                        query.select('membershipObject.status');
14348                        query.select('membershipObject.memberNumber');
14349                        query.select('membershipObject.tierObject');
14350                        query.select('clubObject.web_settings.custom_domain');
14351                        query.include('membershipObject');
14352                        query.include('clubObject.web_settings');
14353                        return query.get( user.get('memberObject').id );
14354                    }else{
14355                        //redirect to login
14356                        return Promise.reject("invalid_membership");
14357                    }
14358                }).then(( retrievedMember )=>{
14359                    if( retrievedMember && retrievedMember.get('clubObject')?.id == club_id ){
14360                        //ok, they're legit
14361                        memberObject = retrievedMember;
14362                    }
14363
14364                    if( ! memberObject ){
14365                        //redirect to login
14366                        return Promise.reject("invalid_membership");
14367                    }
14368                    //check membership
14369                    let membershipObject = memberObject.get('membershipObject');
14370                    if( ! membershipObject ){
14371                        return Promise.reject("invalid_membership");
14372                    }
14373                    if( membershipObject.get('status') == "canceled" ){
14374                        return Promise.reject("invalid_membership");
14375                    }   
14376                    var valid_membership = true;
14377                    //ok, they're a legit member
14378                    return Parse.Promise.as(valid_membership);
14379                });
14380                return thisPromise;
14381            },
14382            set_login_url(){
14383                const clubId = $('body').data('clubId'); // or wherever you're storing it
14384                const query = new Parse.Query("clubs");
14385                query.include("web_settings");
14386                query.get(clubId).then(club => {
14387                    const webSettings = club.get('web_settings');
14388                    const customDomain = webSettings?.get('custom_domain');
14389                    const currentUrl = window.location.href;
14390
14391                    let redirectBase = customDomain
14392                        ? `https://${customDomain}`
14393                        : "https://theclubspot.com";
14394
14395                    const loginUrl = `${redirectBase}/login?next=${encodeURIComponent(currentUrl)}&msg=${encodeURIComponent("Please log in to view and complete this form.")}`;
14396                    this.login_url = loginUrl;
14397                });
14398
14399
14400            },
14401            tapped_clear_form(){
14402                if ( this.collect_default_fields && this.selected_fields.length > 0 ){
14403                    this.selected_fields.flat().forEach(default_field => {
14404                        default_field.input_value = null;
14405                    })
14406                }
14407                if ( this.custom_fields && this.$refs.custom_field_components ){
14408                    this.$refs.custom_field_components.forEach(ref => {
14409                        ref.clear_field();
14410                    });
14411                }
14412            },
14413            maybe_redirect_after_submit(redirect_url){
14414                if (!redirect_url) {
14415                    return false;
14416                }
14417                this.pending_redirect_url = redirect_url;
14418                this.redirect_timeout = setTimeout(() => {
14419                    window.location.href = redirect_url;
14420                }, 2000);
14421                return true;
14422            },
14423            tapped_submit_form(){
14424                if ( this.form_disabled ){
14425                    return displayMessage("This form is no longer accepting submissions.", true, 4000);
14426                }
14427                if ( this.button_disabled ){
14428                    return displayMessage("Required fields are missing.", true, 4000);
14429                }
14430                let club_id = $('body').data('clubId');
14431                if ( !club_id || !this.form_id ){
14432                    return displayMessage("There was an issue submitting your form. Please try again, or contact support if the problem persists.", true, 4000);
14433                }
14434                this.loading_submission = true;
14435                if ( this.custom_fields && this.custom_fields.length > 0 ){
14436                    for ( const custom_field of this.custom_fields ){
14437                        if ( custom_field.type === 'number' ){
14438                            if ( custom_field.input_value !== null && custom_field.input_value !== undefined && custom_field.input_value !== '' ){
14439                                if ( isNaN(Number(custom_field.input_value)) ){
14440                                    this.loading_submission = false;
14441                                    displayMessage("Please enter a valid number.", true, 4000);
14442                                    return;
14443                                }
14444                            }
14445                        }
14446                    }
14447                }
14448                let club_timezone = store.clubObject && store.clubObject.get ? (store.clubObject.get('timezone') || 'UTC') : 'UTC';
14449                return this.prepare_custom_fields_for_submit(this.custom_fields || []).then(preparedCustomFields => {
14450                    let params = {
14451                        club_id: club_id,
14452                        form_id: this.form_id,
14453                        selected_fields: this.selected_fields,
14454                        collect_default_fields: this.collect_default_fields,
14455                        custom_fields: preparedCustomFields,
14456                        club_timezone: club_timezone
14457                    };
14458                    return Parse.Cloud.run('submit_form_response', params);
14459                }).then((result) => {
14460                    this.loading_submission = false;
14461                    if (this.maybe_redirect_after_submit(result && result.redirect_url)) {
14462                        return;
14463                    }
14464                    displayMessage('Your response has been successfully submitted', false, 6000);
14465                    this.tapped_clear_form();
14466                }).catch(error => {
14467                    this.loading_submission = false;
14468                    checkParseError(error);
14469                    displayMessage(error.message || error, true, 4000);
14470                });
14471            },
14472            prepare_custom_fields_for_submit(custom_fields){
14473                let promise = Promise.resolve();
14474                let thisPromise = promise.then(() => {
14475                    if( !custom_fields || custom_fields.length === 0 ){
14476                        return Promise.resolve([]);
14477                    }
14478                    let preparedCustomFields = [];
14479                    let promises = [];
14480                    for (let index = 0; index < custom_fields.length; index++) {
14481                        let methodToRun = (customField, index) => {
14482                            let innerPromise = promise.then(() => {
14483                                let preparedCustomField = {
14484                                    id: customField.id,
14485                                    name: customField.name,
14486                                    type: customField.type,
14487                                    required: customField.required,
14488                                    input_value: customField.input_value,
14489                                    custom_field_options: customField.custom_field_options || [],
14490                                    order: index,
14491                                };
14492                                if( customField.type !== 'file_upload' ){
14493                                    preparedCustomFields.push(preparedCustomField);
14494                                    return Promise.resolve();
14495                                }
14496                                preparedCustomField.input_value = '';
14497                                if( customField.document_id ){
14498                                    preparedCustomField.document_id = customField.document_id;
14499                                    preparedCustomFields.push(preparedCustomField);
14500                                    return Promise.resolve();
14501                                }
14502                                let uploadFile = this.return_upload_file_from_custom_field(customField);
14503                                if( !uploadFile || !uploadFile.name ){
14504                                    preparedCustomFields.push(preparedCustomField);
14505                                    return Promise.resolve();
14506                                }
14507                                let uploadParams = {
14508                                    file: uploadFile,
14509                                    fileName: uploadFile.name
14510                                };
14511                                return upload_private_file_v2(uploadParams).then(uploadResult => {
14512                                    preparedCustomField.uploaded_file_url = uploadResult.url;
14513                                    preparedCustomField.uploaded_file_key = uploadResult.file_key;
14514                                    preparedCustomField.uploaded_file_name = uploadFile.name;
14515                                    preparedCustomFields.push(preparedCustomField);
14516                                    return Promise.resolve();
14517                                });
14518                            });
14519                            promises.push(innerPromise);
14520                        };
14521                        methodToRun(custom_fields[index], index);
14522                    }
14523                    return Promise.all(promises).then(() => {
14524                        return Promise.resolve(preparedCustomFields);
14525                    });
14526                });
14527                return thisPromise;
14528            },
14529            return_upload_file_from_custom_field(customField){
14530                if( !customField ){
14531                    return null;
14532                }
14533                if( customField.input_value && customField.input_value.name ){
14534                    return customField.input_value;
14535                }
14536                if( Array.isArray(customField.input_value) && customField.input_value.length > 0 ){
14537                    let firstFile = customField.input_value[0];
14538                    if( firstFile && firstFile.name ){
14539                        return firstFile;
14540                    }
14541                }
14542                if( customField.input_value && customField.input_value.file && customField.input_value.file.name ){
14543                    return customField.input_value.file;
14544                }
14545                if( customField.input_value && customField.input_value.value && customField.input_value.value.name ){
14546                    return customField.input_value.value;
14547                }
14548                return null;
14549            },
14550            returnIsVideo(filename) {
14551                var ext = this.getExtension(filename);
14552                switch (ext.toLowerCase()) {
14553                    case 'm4v':
14554                    case 'avi':
14555                    case 'mpg':
14556                    case 'mp4':
14557                    case 'mp3':
14558                    case 'mov':
14559                    // etc
14560                    return true;
14561                }
14562                return false;
14563            },
14564            getExtension(filename) {
14565                var parts = filename.split('.');
14566                return parts[parts.length - 1];
14567            },
14568            load_clean_image( file, type, newFileName ){
14569                return new Promise((resolve, reject) => {
14570                    setTimeout(() => {
14571                        loadImage(file, canvas => {
14572                            if (canvas) {
14573                                this.clean_image_callback(canvas, type, newFileName, file)
14574                                .then(url => resolve(url))
14575                                .catch(err => reject(err));
14576                            } else {
14577                                reject(new Error('Failed to load image'));
14578                            }
14579                        },
14580                        { orientation: 1, canvas: true }
14581                        );
14582                    }, 500);
14583                });
14584            },
14585            clean_image_callback( canvas, type, newFileName, file ){
14586                return new Promise((resolve, reject) => {
14587                    if (canvas) {
14588                        var url = canvas.toDataURL(type);
14589                        canvas.toBlob(blob => {
14590                            uploadFileToAWSV2( blob, newFileName )
14591                            .then(uploadUrl => resolve(uploadUrl))
14592                            .catch(err => reject(err));
14593                        }, type);
14594                    } else {
14595                        reject(new Error('canvas is null'));
14596                    }
14597                });
14598            },
14599            link_form(){
14600                store.components.link_form.component = this.component;
14601                store.overlays.link_form = true;
14602            },
14603            set_default_fields(){
14604                if ( this.json_form && this.default_fields.length > 0 ){
14605                    this.selected_fields = this.selected_fields.map(default_field => {
14606                        if ( this.default_fields.indexOf(default_field.value ) !== -1 ){
14607                            return {
14608                                ...default_field,
14609                                selected: true,
14610                            }
14611                        } else {
14612                            return {
14613                                ...default_field,
14614                                selected: false,
14615                            }
14616                        }
14617                    });
14618                }
14619                if ( this.default_configuration === 'list' ){
14620                    //list
14621                    this.selected_fields = this.selected_fields.filter(field => field.selected);
14622                } else if ( this.default_configuration === 'grid' ){
14623                    //grid
14624                    this.selected_fields = this.selected_fields.filter(field => field.selected).reduce((acc, field) => {
14625                        if ( field.value === 'firstName' || field.value === 'lastName' ){
14626                            acc[0].push(field);
14627                        } else {
14628                            acc[1].push(field);
14629                        }
14630                        return acc;
14631                    }, [[], []])
14632                }
14633            },
14634            handle_field_update({ id, value }){
14635                const field = this.custom_fields.find(f => f.id === id);
14636                if (field) {
14637                    field.input_value = value;
14638                }
14639            },
14640        },
14641        computed: {
14642            form_id(){
14643                if ( typeof( this.form ) === 'string' ){
14644                    return JSON.parse(this.form).id;
14645                }
14646                return this.form.id || null;
14647            },
14648            color_theme(){
14649                if ( this.json_form && this.json_form.color_theme ){
14650                    return this.json_form.color_theme;
14651                }
14652                return '#5280ff';
14653            },
14654            custom_fields(){
14655                if ( this.json_form && this.json_form.custom_fields ){
14656                    return this.json_form.custom_fields;
14657                }
14658                return [];
14659            },
14660            default_fields(){
14661                if ( this.json_form && this.json_form.default_fields  ){
14662                    return this.json_form.default_fields;
14663                }
14664                return [];
14665            },
14666            collect_default_fields(){
14667                if ( this.json_form && this.json_form.collect_default_fields  ){
14668                    return this.json_form.collect_default_fields;
14669                }
14670                return false;
14671            },
14672            default_configuration(){
14673                if ( this.json_form && this.json_form.default_configuration  ){
14674                    return this.json_form.default_configuration;
14675                }
14676                return 'list';
14677            },
14678            missing_default_field(){
14679                if ( this.json_form && this.collect_default_fields && this.selected_fields.length > 0 ){
14680                    let default_values = this.default_configuration === 'list' 
14681                        ? this.selected_fields.map(field => field.input_value) 
14682                        : this.selected_fields.flatMap(arr => arr.map(field => field.input_value));
14683                    return default_values.includes(null) || default_values.includes('');
14684                }
14685                return false;
14686            },
14687            missing_custom_field(){
14688                if ( this.json_form && this.custom_fields ){
14689                    for ( let i = 0; i < this.custom_fields.length; i++ ){
14690                        let custom_field = this.custom_fields[i];
14691                        let option_values = custom_field.custom_field_options && custom_field.custom_field_options.length > 0 
14692                            ? custom_field.custom_field_options.map(option => option.selected)
14693                            : [];
14694                        if ( custom_field.required && ( !custom_field.input_value && option_values.indexOf(true) === -1 ) ){
14695                            return true;
14696                        }
14697                    }
14698                    return false;
14699                }
14700            },
14701            form_disabled(){
14702                if ( this.json_form && this.json_form.disabled ){
14703                    return true;
14704                }
14705                return false;
14706            },
14707            button_disabled(){
14708                if ( this.is_admin ){
14709                    return true;
14710                }else{
14711                    return this.form_disabled || this.missing_custom_field || this.missing_default_field;
14712                }
14713            },
14714            formatting_obj(){
14715                if ( this.formatting ){
14716                    return JSON.parse(this.formatting);
14717                }
14718                return;
14719            },
14720            valid_membership(){
14721                if(this.json_form && this.json_form.members_only){
14722                    return !this.invalid_membership;
14723                }
14724                //not members only, doesn't matter
14725                return true;
14726            },
14727        },
14728        watch: {
14729            form_id(newVal, oldVal){
14730                if (newVal !== oldVal){
14731                    this.load_forms( this.client_id );
14732                }
14733            }
14734        }
14735    })
14736</script>
14736    
14736<script type="text/html" id="template-tab-bar">
14737    <template v-if="is_web_builder()">
14738        <template v-if="config_latest()['tabs']?.length > 0">
14739            <div
14740                class="tab-bar-wrapper"
14741                :style="{
14742                    gap: (config_latest().formatting.section_width || 50) + 'px',
14743                    height: (config_latest().formatting.section_height || 50) + 'px',
14744                }"
14745            >
14746                <div
14747                    v-for="tab in config_latest()['tabs']"
14748                    :key="tab.id"
14749                    style="width: 200px; display: flex; align-items: center; justify-content: center;"
14750                >
14751                    <div
14752                        @click="selected_tab_id = tab.id"
14753                        style="width: 100%; text-align: center;"
14754                        class="tab-bar-label"
14755                        :class="{ 'active': selected_tab_id === tab.id }"
14756                    >
14757                        <span>
14758                            <preview-element-component
14759                                v-if="tab.elements"
14760                                :element="tab.elements"
14761                                :element_type="tab.elements.type"
14762                                :element_content="tab.elements.content"
14763                                :componentObject="component"
14764                                :disable_remove="true"
14765                                :disable_column="true"
14766                                :disable_new_element="true"
14767                            ></preview-element-component>
14768                        </span>
14769                    </div>
14770                </div>
14771            </div>
14772            <web-builder-components
14773                v-if="selected_tab_id"
14774                :component="return_page_component(selected_tab_id)"
14775                :component_index="0"
14776                :preview_element="true"
14777            ></web-builder-components>
14778        </template>
14779        <template v-else>
14780            <div
14781                class="noObjectsMSG centeredBlock centeredText"
14782                style="padding: 5em;"
14783            >
14784                <p style="cursor: pointer;"><i class="fa-light fa-table-columns smallMarginRight"></i>Use the left side menu to start adding tabs</p>
14785            </div>
14786        </template>
14787    </template>
14788    <template v-if="!is_web_builder() && !loading">
14789        <template v-if="hosted_config()['tabs']?.length > 0">
14790            <div
14791                class="tab-bar-wrapper"
14792                :style="{
14793                    gap: (hosted_config().formatting.section_width || 50) + 'px',
14794                    height: (hosted_config().formatting.section_height || 50) + 'px',
14795                }"
14796            >
14797                <div
14798                    v-for="tab in hosted_config()['tabs']"
14799                    :key="tab.id"
14800                    style="width: 200px; display: flex; align-items: center; justify-content: center;"
14801                >
14802                    <div
14803                        @click="selected_tab_id = tab.id"
14804                        style="width: 100%; text-align: center;"
14805                        class="tab-bar-label"
14806                        :class="{ 'active': selected_tab_id === tab.id }"
14807                    >
14808                        <element-component
14809                            v-if="tab.elements"
14810                            :element="tab.elements"
14811                            :element_type="tab.elements.type"
14812                            :element_content="tab.elements.content"
14813                            :componentObject="component"
14814                            :disable_remove="true"
14815                            :disable_column="true"
14816                            :disable_new_element="true"
14817                        ></element-component>
14818                    </div>
14819                </div>
14820            </div>
14821            <web-builder-components
14822                v-if="selected_tab_id"
14823                :component="return_hosted_page_component(selected_tab_id)"
14824                :component_index="0"
14825                :tab_element="true"
14826                :preview_element="false"
14827            >
14827</web-builder-components>
14828        </template>
14829    </template>
14830</script>
14830
14831
14832<style>
14833    .tab-bar-wrapper{
14834        display: flex;
14835        flex-direction: row;
14836        align-items: center;
14837        justify-content: center;
14838        border-top: 1.5px solid rgb(204, 203, 203);
14839        border-bottom: 1.5px solid rgb(204, 203, 203);
14840        box-sizing: border-box;
14841    }
14842    .tab-bar-label span {
14843        position: relative;
14844        display: inline-block;
14845        padding-bottom: 4px;
14846        border-bottom: none !important;
14847        text-decoration: none !important;
14848    }
14849    .tab-bar-label span::after {
14850        content: "";
14851        position: absolute;
14852        bottom: 0;
14853        left: 50%;
14854        width: 0;
14855        height: 1px;
14856        background-color: #000;
14857        transform: translateX(-50%);
14858        transition: width 0.3s ease;
14859    }
14860    .tab-bar-label.active span::after {
14861        width: 100%;
14862    }
14863</style>
14864
14865<script>    
14866    app_content.component('tab-bar-component', {
14867        template: '#template-tab-bar',
14868        data() {
14869            return {
14870                loading: true,
14871                selected_tab_id: null,
14872                hosted_tab_component: null,
14873                hosted_page_components: [],
14874            }
14875        },
14876        props: {
14877            client_id: String,
14878            component: {
14879                type: Object,
14880                default: null,
14881            },
14882            last_update: Number,
14883        },
14884        mounted() {
14885            if(this.is_web_builder()){
14886                this.selected_tab_id = this.config_latest()?.tabs?.[0]?.id || null;
14887            }else{
14888                this.load_tab_bar( this.client_id );
14889            }
14890        },
14891        methods: {
14892            load_tab_bar( client_id, overrides = {} ){
14893                this.loading = true;
14894                var promise = Parse.Promise.as();
14895                var thisPromise = promise.then(() => {
14896                    var params = {};
14897                    params["client_id"] = client_id;
14898                    params["overrides"] = overrides;
14899                    params["request_url"] = window.location.href;
14900                    return Parse.Cloud.run('retrieve_data_for_component', params);
14901                }).then(response => {
14902                    this.hosted_tab_component = response.tab_component || null;
14903                    this.hosted_page_components = response.page_components || [];
14904                    this.selected_tab_id = this.hosted_config()?.tabs?.[0]?.id || null;
14905                }).catch(error => {
14906                    checkParseError(error);
14907                }).finally(() => {
14908                    this.loading = false;
14909                });
14910                return thisPromise;
14911            },
14912            return_page_component(id){
14913                return store.page_components_latest.find(c => c.id === id)
14914            },
14915            return_hosted_page_component(id){
14916                return this.hosted_page_components.find(c => c.id === id)
14917            },
14918            config_latest(){
14919                if(this.component && this.component.get('config_latest')){
14920                    return this.component.get('config_latest');
14921                }
14922            },
14923            hosted_config(){
14924                if(this.hosted_tab_component && this.hosted_tab_component.get('config')){
14925                    return this.hosted_tab_component.get('config');
14926                }
14927            },
14928            is_web_builder(){
14929                return window.location.href.includes('website-builder');
14930            },
14931        },
14932        watch: {
14933            last_update(newVal) {
14934                if (this.is_web_builder()) {
14935                    this.selected_tab_id = this.config_latest()?.tabs?.[0]?.id || null;
14936                }
14937            }
14938        }
14939    });
14940</script>
14940    
14940<script type="text/html" id="template-web-builder-components">
14941    <div
14942        :id="'componentPreview_' + component.get('client_id')"
14943        class="componentPreview"
14944        :class="{'hidePreview': tab_element}"
14945        :data-client-id="component.get('client_id')"
14946        :style="return_preview_style(component)"
14947    >
14948        <div
14949            v-if="component.get('visibility') === 'members_only'"
14950            class="event-page-callout"
14951            :class="{ 'no-hover': !can_open_visibility_settings }"
14952            style="width:fit-content;font-size:12px;position: absolute;z-index:100;display:flex;align-items:center;justify-content:center;"
14953            :title="can_open_visibility_settings ? 'Edit visibility' : null"
14954            @click.stop="open_members_only_visibility_settings"
14955        >
14956            <i class="fa-solid fa-user-shield"></i>
14957            <p class="tinyMarginLeft" style="font-family:'Roboto';font-size:12px;">{{ (component.get('allowed_categories') && component.get('allowed_categories').length > 0) ? 'Members only (categories)' : 'Members only' }}</p>
14958        </div>
14959        <template v-if="component.get('type') === 'iframe'">
14960            <iframe-component
14961                :class_string="return_attributes(component, 'classString')"
14962                :style_string="return_attributes(component, 'style_string')"
14963                :iframe_src="return_attributes(component, 'config_latest').iframe_src"
14964                :component="component"
14965                :formatting_string="return_attributes(component, 'formatting')"
14966                :last_update="return_last_update(component)"
14967            ></iframe-component>
14968            <toolbar-option-component
14969                @click.stop
14970                :component="component"
14971                :is_first="component_index === 0"
14972                :disable_options="disable_options(component)"
14973            ></toolbar-option-component>
14974        </template>
14975        <template v-if="component.get('type') === 'camera-feed'">
14976            <camera-feed-component
14977                :class_string="return_attributes(component, 'classString')"
14978                :style_string="return_attributes(component, 'style_string')"
14979                :client_id="return_attributes(component, 'client_id')"
14980                :camera_feed_id="return_attributes(component, 'config_latest').camera_feed_id"
14981                :refresh_interval_ms="return_attributes(component, 'config_latest').refresh_interval_ms"
14982                :component="component"
14983                :formatting_string="return_attributes(component, 'formatting')"
14984                :last_update="return_last_update(component)"
14985            ></camera-feed-component>
14986            <toolbar-option-component
14987                @click.stop
14988                :component="component"
14989                :is_first="component_index === 0"
14990                :disable_options="disable_options(component)"
14991            ></toolbar-option-component>
14992        </template>
14993        <template v-if="component.get('type') === 'blog-list'">
14994            <blog-list-component
14995                :client_id="return_attributes(component, 'client_id')"
14996                :class_string="return_attributes(component, 'classString')"
14997                :style_string="return_attributes(component, 'style_string')"
14998                :element_style_string="return_attributes(component, 'element_style')"
14999                :formatting="return_attributes(component, 'formatting')"
15000                :component_type="return_attributes(component, 'component_type')"
15001                :is_larchmont="global.is_larchmont"
15002                :component="component"
15003                :last_update="return_last_update(component)"
15004            ></blog-list-component>
15005            <toolbar-option-component
15006                @click.stop
15007                :component="component"
15008                :is_first="component_index === 0"
15009                :disable_options="disable_options(component)"
15010            ></toolbar-option-component>
15011        </template> 
15012        <template v-if="component.get('type') === 'calendar'">
15013            <membership-calendar-component
15014                :class_string="return_attributes(component, 'classString')"
15015                :client_id="return_attributes(component, 'client_id')"
15016                :style_string="return_attributes(component, 'style_string')"
15017                :component="component" 
15018                :last_update="return_last_update(component)"
15019            ></membership-calendar-component>
15020            <toolbar-option-component
15021                @click.stop
15022                :component="component"
15023                :is_first="component_index === 0"
15024                :disable_options="disable_options(component)"
15025            ></toolbar-option-component>
15026        </template>
15027        <template v-if="component.get('type') === 'membership-directory'">
15028            <membership-directory-component
15029                :formatting="JSON.parse(return_attributes(component, 'formatting'))"
15030                :class_string="return_attributes(component, 'classString')"
15031                :client_id="return_attributes(component, 'client_id')"
15032                :style_string="return_attributes(component, 'style_string')"
15033                :element_style_string="return_attributes(component, 'element_style')"
15034                :component_type="return_attributes(component, 'component_type')"
15035                :last_update="return_last_update(component)"
15036            ></membership-directory-component>
15037            <toolbar-option-component
15038                @click.stop
15039                :component="component"
15040                :is_first="component_index === 0"
15041                :disable_options="disable_options(component)"
15042            ></toolbar-option-component>
15043        </template>
15044        <template v-if="component.get('type') === 'event-list'">
15045            <event-list-component
15046                :class_string="return_attributes(component, 'classString')"
15047                :style_string="return_attributes(component, 'style_string')"
15048                :element_style_string="return_attributes(component, 'element_style')"
15049                :formatting="return_attributes(component, 'formatting')"
15050                :filter_types="return_attributes(component, 'filter_types')"
15051                :filter_tag_ids="return_attributes(component, 'filter_tag_ids')"
15052                :event_dates="return_attributes(component, 'event_dates')"
15053                :component_type="return_attributes(component, 'component_type')"
15054                :client_id="return_attributes(component, 'client_id')"
15055                :component="component"
15056                :last_update="return_last_update(component)"
15057            ></event-list-component>
15058            <toolbar-option-component
15059                @click.stop
15060                :component="component"
15061                :is_first="component_index === 0"
15062                :disable_options="disable_options(component)"
15063            ></toolbar-option-component>
15064        </template>
15065        <template v-if="component.get('type') === 'image-grid'">
15066            <image-grid-component
15067                :client_id="return_attributes(component, 'client_id')"
15068                :class_string="return_attributes(component, 'classString')"
15069                :image_grid_layout="return_attributes(component, 'image_grid_layout')"
15070                :images_per_row="return_attributes(component, 'images_per_row')"
15071                :caption_size="return_attributes(component, 'caption_size')"
15072                :subtext_size="return_attributes(component, 'subtext_size')"
15073                :link_label_size="return_attributes(component, 'link_label_size')"
15074                :text_position="return_attributes(component, 'text_position')"
15075                :caption_font_weight="return_attributes(component, 'caption_font_weight')"
15076                :image_grid_opacity="return_attributes(component, 'image_grid_overlay_opacity')"
15077                :image_grid_border_radius="return_attributes(component, 'image_grid_border_radius')"
15078                :formatting="return_attributes(component, 'formatting')"
15079                :is_admin="preview_element"
15080                :data_source_type="return_attributes(component, 'data_source')['type']"
15081                :images_arr="return_attributes(component, 'images')"
15082                :images_string="JSON.stringify(return_attributes(component, 'images'))"
15083                :component="component"
15084                :last_update="return_last_update(component)"
15085            ></image-grid-component>
15086            <toolbar-option-component
15087                @click.stop
15088                :component="component"
15089                :is_first="component_index === 0"
15090                :disable_options="disable_options(component)"
15091            ></toolbar-option-component>
15092        </template>
15093        <template v-if="component.get('type') === 'photo-gallery'">
15094            <photo-gallery-component
15095                :client_id="return_attributes(component, 'client_id')"
15096                :component="component"
15097                :photo_album="return_attributes(component, 'photo_album')"
15098                :show_caption="return_attributes(component, 'show_caption')"
15099                :caption_size="return_attributes(component, 'caption_size')"
15100                :caption_font_weight="return_attributes(component, 'caption_font_weight')"
15101                :photos_per_row="return_attributes(component, 'photos_per_row')"
15102                :is_admin="preview_element"
15103                :last_update="return_last_update(component)"
15104            ></photo-gallery-component>
15105            <toolbar-option-component
15106                @click.stop
15107                :component="component"
15108                :is_first="component_index === 0"
15109                :disable_options="disable_options(component)"
15110            ></toolbar-option-component>
15111        </template>
15112        <template v-if="component.get('type') === 'form'">
15113            <form-component
15114                :client_id="return_attributes(component, 'client_id')"
15115                :component="component"
15116                :form="return_attributes(component, 'form')"
15117                :formatting="return_attributes(component, 'formatting')"
15118                :is_admin="preview_element"
15119                :last_update="return_last_update(component)"
15120            ></form-component>
15121            <toolbar-option-component
15122                @click.stop
15123                :component="component"
15124                :is_first="component_index === 0"
15125                :disable_options="disable_options(component)"
15126            ></toolbar-option-component>
15127        </template>
15128        <template v-if="component.get('type') === 'image-with-text'">
15129            <image-with-text-component
15130                :client_id="return_attributes(component, 'client_id')"
15131                :formatting="return_attributes(component, 'formatting')"
15132                :images_string="JSON.stringify(return_attributes(component, 'images'))"
15133                :class_string="return_attributes(component, 'classString')"
15134                :style_string="return_attributes(component, 'style_string')"
15135                :element_style_string="return_attributes(component, 'element_style')"	
15136                :image_mat="return_attributes(component, 'image_mat')"
15137                :elements_string="return_attributes(component, 'elements')"
15138                :left_right_orientation="return_attributes(component, 'left_right_orientation')" 
15139                :preview_element="preview_element"
15140                :component="component"
15141                :last_update="return_last_update(component)"
15142                :last_element_update="return_last_element_update( component )"
15143                :tab_element="tab_element"
15144            ></image-with-text-component>
15145            <toolbar-option-component
15146                @click.stop
15147                :component="component"
15148                :is_first="component_index === 0"
15149                :disable_options="disable_options(component)"
15150            ></toolbar-option-component>
15151        </template>
15152        <template v-if="component.get('type') === 'text' || component.get('type') === 'table'">
15153            <div 
15154                class="cs-text background flexWrap relative" 
15155                :class="return_attributes(component, 'classString')" 
15156                :style="return_attributes(component, 'style_string')"
15157            >
15158                <div 
15159                    v-if="return_attributes(component, 'columns').length > 0" 
15160                    class="element-wrapper flexNoWrap flexWrapOnMobile" 
15161                    :class="'element-wrapper_' + return_attributes(component, 'component_type')"
15162                    :style="return_attributes(component, 'element_style')"
15163                >
15164                    <template
15165                        v-for="(elements, column_index) in return_attributes(component, 'columns')"
15166                        :key="column_index"
15167                    >
15168                        <text-component
15169                            :number_of_columns="return_attributes(component, 'number_of_columns')"	
15170                            :preview_element="preview_element"
15171                            :component="component"
15172                            :last_update="return_last_update(component)"
15173                            :last_element_update="return_last_element_update( component )"
15174                            :elements="elements"
15175                            :column_index="column_index"
15176                            :section_element_animation="return_attributes(component, 'section_element_animation')"
15177                        ></text-component>	
15178                    </template>
15179                </div>
15180            </div>
15181            <toolbar-option-component
15182                @click.stop
15183                :component="component"
15184                :is_first="component_index === 0"
15185                :disable_options="disable_options(component)"
15186            ></toolbar-option-component>
15187        </template>
15188        <template v-if="component.get('type') === 'hero-banner'">
15189            <hero-banner-component
15190                ref="heroBanner"
15191                :client_id="return_attributes(component, 'client_id')"
15192                :formatting="return_attributes(component, 'formatting')"
15193                :class_string="return_attributes(component, 'classString')"
15194                :style_string="return_attributes(component, 'style_string')"
15195                :element_style_string="return_attributes(component, 'element_style')"
15196                :component_type="return_attributes(component, 'component_type')"
15197                :elements_string="return_attributes(component, 'elements')"
15198                :images_string="JSON.stringify(return_attributes(component, 'images'))"
15199                :preview_element="preview_element"
15200                :component="component"
15201                :last_update="return_last_update(component)"
15202                :last_element_update="return_last_element_update( component )"
15203                :tab_element="tab_element"
15204            ></hero-banner-component>
15205            <button
15206                v-if="preview_element"
15207                type="button"
15208                class="cs-image-slot-change-chip cs-hero-image-change-chip exclude-from-custom-button-styles"
15209                @click.stop="tapped_change_hero_image()"
15210            ><i class="fa-regular fa-image"></i><span>Change</span></button>
15211            <toolbar-option-component
15212                @click.stop
15213                :component="component"
15214                :is_first="component_index === 0"
15215                :disable_options="disable_options(component)"
15216            ></toolbar-option-component>
15217        </template>
15218        <template v-if="component.get('type') === 'custom-code'">
15219            <custom-code-component	
15220                :class_string="return_attributes(component, 'classString')"
15221                :preview_element="preview_element"
15222                :is_first="is_first_visible_component(component)"
15223                :component="component"
15224                :last_update="return_last_update(component)"
15225            ></custom-code-component>
15226            <toolbar-option-component
15227                @click.stop
15228                :component="component"
15229                :is_first="component_index === 0"
15230                :disable_options="disable_options(component)"
15231            ></toolbar-option-component>
15232        </template>
15233        <template v-if="component.get('type') === 'unlayer-component'">
15234            <unlayer-component
15235                :class_string="return_attributes(component, 'classString')"
15236                :preview_element="preview_element"
15237                :component="component"
15238                :last_update="return_last_update(component)"
15239            ></unlayer-component>
15240            <toolbar-option-component
15241                @click.stop
15242                :component="component"
15243                :is_first="component_index === 0"
15244                :disable_options="disable_options(component)"
15245            ></toolbar-option-component>
15246        </template>
15247        <!-- <template v-if="component.get('type') === 'tab-bar'">
15248            <tab-bar-component
15249                :client_id="return_attributes(component, 'client_id')"
15250                :preview_element="preview_element"
15251                :component="component"
15252                :last_update="return_last_update(component)"
15253            ></tab-bar-component>
15254            <toolbar-option-component
15255                @click.stop
15256                :component="component"
15257                :is_first="component_index === 0"
15258                :disable_options="disable_options(component)"
15259            ></toolbar-option-component>
15260        </template> -->
15261    </div>
15262</script>
15262
15263
15264<style>
15265    /* Hero Change chip: sibling of the section under .componentPreview so
15266       overlap masks / fades can't cover it. */
15267    .componentPreview > .cs-hero-image-change-chip {
15268        position: absolute;
15269        top: auto;
15270        right: auto;
15271        bottom: calc(12px + var(--cs-overlap-chrome-lift, 0px));
15272        left: 50%;
15273        transform: translateX(-50%);
15274        z-index: 3000;
15275        display: inline-flex;
15276        align-items: center;
15277        gap: 5px;
15278        padding: 6px 10px;
15279        border: none;
15280        border-radius: 6px;
15281        background: rgba(20, 24, 45, 0.82);
15282        color: #fff;
15283        font-family: 'Roboto', Arial, Helvetica, sans-serif;
15284        font-size: 12px;
15285        font-weight: 600;
15286        font-style: normal;
15287        line-height: 1;
15288        letter-spacing: 0;
15289        text-transform: none;
15290        cursor: pointer;
15291        opacity: 0;
15292        pointer-events: none;
15293        box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22);
15294        transition: opacity 0.12s ease, background 0.12s ease;
15295    }
15296    .componentPreview:hover > .cs-hero-image-change-chip,
15297    .componentPreview > .cs-hero-image-change-chip:focus {
15298        opacity: 0.92;
15299        pointer-events: auto;
15300    }
15301    .componentPreview:hover > .cs-hero-image-change-chip:hover,
15302    .componentPreview > .cs-hero-image-change-chip:focus {
15303        opacity: 1;
15304        background: rgba(20, 24, 45, 0.95);
15305    }
15306    .componentPreview.settings-active > .cs-hero-image-change-chip {
15307        opacity: 0 !important;
15308        visibility: hidden !important;
15309        pointer-events: none !important;
15310    }
15311</style>
15312
15313<script>    
15314    app_content.component('web-builder-components', {
15315        template: '#template-web-builder-components',
15316        data() {
15317            return {
15318                global: store,
15319                is_admin: false,
15320            }
15321        },
15322        props: {
15323            component: Object,
15324            component_index: Number,
15325            tab_element: Boolean,
15326            preview_element: Boolean,
15327            last_update: Number,
15328        },
15329        methods: {
15330            component_is_linked_to_tab_bar( componentObject ){
15331                if (!componentObject || !Array.isArray(store.page_components_latest)) {
15332                    return false;
15333                }
15334                return store.page_components_latest.some(c => {
15335                    if (!c || !c.get || c.get('type') !== 'tab-bar') {
15336                        return false;
15337                    }
15338                    let tabs = (c.get('config_latest') && c.get('config_latest').tabs) || [];
15339                    return tabs.some(tab => {
15340                        return tab && tab.id === componentObject.id;
15341                    });
15342                });
15343            },
15344            is_first_visible_component( componentObject ){
15345                if (!componentObject || !Array.isArray(store.page_components_latest)) {
15346                    return this.component_index === 0;
15347                }
15348                let first_visible = store.page_components_latest.find(c => {
15349                    return c && !this.component_is_linked_to_tab_bar(c);
15350                });
15351                return !!(first_visible && first_visible.id === componentObject.id);
15352            },
15353            disable_options( componentObject ){
15354                return this.component_is_linked_to_tab_bar(componentObject);
15355            },
15356            open_members_only_visibility_settings(){
15357                if( typeof window.wb_open_component_visibility_settings !== 'function' ){
15358                    return;
15359                }
15360                window.wb_open_component_visibility_settings(this.component);
15361            },
15362            tapped_change_hero_image(){
15363                let hero = this.$refs.heroBanner;
15364                if( !hero || typeof hero.tapped_change_hero_image !== 'function' ){
15365                    return;
15366                }
15367                hero.tapped_change_hero_image();
15368            },
15369            return_last_element_update( componentObject ){
15370                return store.element_updates ? store.element_updates[ componentObject.id ] : 0;
15371            },
15372            return_last_update(componentObject){
15373                return store.component_updates ? store.component_updates[ componentObject.id ] : 0;
15374            },
15375            return_preview_style(componentObject){
15376                let type = componentObject.get('type');
15377                if ( type === 'hero-banner' || type === 'image-with-text' ){
15378                    return this.return_attributes(componentObject, 'background_image_style');
15379                }
15380            },
15381            return_attributes(componentObject, attribute){
15382                let attributes = {};
15383                if(componentObject){
15384                    var type = componentObject.get('type');
15385                    var config_latest = componentObject.get('config_latest') || returnDefaultConfigForComponent( type );
15386                    var images = config_latest.images;
15387                    if(!images){
15388                        // images = [];
15389                        images = [null, null, null, null, null]
15390                    }
15391                    var formatting = config_latest.formatting;
15392                    if( ! formatting ){
15393                        formatting = returnDefaultFormattingForComponent( type );
15394                    }
15395                    var color_scheme = config_latest.color_scheme;
15396                    if( !color_scheme ){
15397                        let default_config = returnDefaultConfigForComponent( type );
15398                        color_scheme = (default_config && default_config.color_scheme) ? default_config.color_scheme : 'white';
15399                    }
15400                    if (store.is_larchmont && store.pageObject.id === 'QAcpSXHP5G'){
15401                        color_scheme = 'larchmont_secondary';
15402                    }
15403                    //start the class string up here
15404                    var classString = '';
15405                    classString += ' color_scheme_' + color_scheme;
15406                    //now generate markup
15407                    var background_image_style = "";
15408                    var style_string = "";
15409                    if( formatting.section_height ){
15410                        // Browser vh is the outer window; the canvas is #previewZoneHeight.
15411                        // Use cqh (preview container) so heights match iframe/live viewport,
15412                        // including absolute/sticky header overlay. Keep vh as fallback.
15413                        let section_vh = ( formatting.section_height ).toString();
15414                        style_string += "min-height:" + section_vh + "vh;";
15415                        style_string += "min-height:calc(var(--wb-preview-vh, 1vh) * " + section_vh + ");";
15416                        style_string += "min-height:" + section_vh + "cqh;";
15417                    }
15418                    if( formatting.align_items
15419                        && ( typeof is_web_builder_element_stack_component !== 'function'
15420                            || is_web_builder_element_stack_component( type ) ) ){
15421                        style_string += "align-items:" + formatting.align_items + ";" ;
15422                    }
15423                    if ( formatting.background_size === 'inset' ){
15424                        background_image_style += "padding: 30px;" ;
15425                    } else if ( formatting.background_size === 'cover' ){
15426                        background_image_style += "padding: 0px;" ;
15427                    }
15428                    //element wrapper
15429                    var element_style = "";
15430                    //extra_columns
15431                    var extra_columns = config_latest.extra_columns;
15432                    if( extra_columns ){
15433                        for (var c = extra_columns.length - 1; c >= 0; c--) {
15434                            classString += ' show_' + extra_columns[c];
15435                        }   
15436                    }
15437                    //images per row (image grid)
15438                    var images_per_row = config_latest.images_per_row;
15439                    if( ! images_per_row ){
15440                        images_per_row = 3;
15441                    }
15442                    //photos per row (photo gallery)
15443                    var photos_per_row = config_latest.photos_per_row;
15444                    if( ! photos_per_row ){
15445                        photos_per_row = 4;
15446                    }
15447                    //left/right orientation (image with text)
15448                    var left_right_orientation = config_latest.left_r
15448ight_orientation;
15449                    if( ! left_right_orientation ){
15450                        left_right_orientation = "left";
15451                    }
15452                    classString += " orientation-" + left_right_orientation;
15453                    //number of columns (text)
15454                    var number_of_columns = config_latest.number_of_columns;
15455                    if( ! number_of_columns ){
15456                        number_of_columns = 3;
15457                    }
15458                    classString += " number_of_columns-" + number_of_columns;
15459
15460                    // Opt-in refinement tokens (padding, fade edges, scroll/element
15461                    // anim, max-width cap). Absent keys leave classString/styles unchanged.
15462                    let refinement_result = apply_web_builder_refinement_formatting({
15463                        formatting: formatting,
15464                        class_string: classString,
15465                        element_style: element_style,
15466                        activate_immediately: ( typeof window !== 'undefined' && window.location.href.indexOf('website-builder') !== -1 ),
15467                        is_first_section: this.is_first_visible_component(componentObject),
15468                    });
15469                    classString = refinement_result.class_string;
15470                    element_style = refinement_result.element_style;
15471
15472                    //overlay opacity
15473                    var opacity = 0;
15474                    if( formatting.overlay_opacity ){
15475                        opacity = formatting.overlay_opacity;
15476                    }
15477                    //photo album id
15478                    var photo_album = config_latest.photo_album;
15479                    if( ! photo_album ){
15480                        photo_album = {
15481                            id: null,
15482                            name: null,
15483                        };
15484                    }
15485                    //form id
15486                    var form = config_latest.form;
15487                    if( ! form ){
15488                        form = {
15489                            id: null,
15490                            name: null,
15491                        };
15492                    }
15493                    //image with text
15494                    var image_mat = config_latest.image_mat ? config_latest.image_mat : 'show';
15495                    //filter types (event list)
15496                    var filter_types = config_latest.filter_types ? JSON.stringify(config_latest.filter_types) : null;
15497                    //filter tag IDs (event list)
15498                    var filter_tag_ids = config_latest.filter_tag_ids ? JSON.stringify(config_latest.filter_tag_ids) : null;
15499                    //event_dates (event list)
15500                    var event_dates = config_latest.event_dates ? config_latest.event_dates : 'upcoming';
15501                    //caption style
15502                    if( formatting.caption_style ){
15503                        classString += " caption_style-" + formatting.caption_style;
15504                    }
15505                    //caption alignment
15506                    if( formatting.caption_align && ( formatting.caption_align == "top" || formatting.caption_align == "bottom" ||  formatting.caption_align == "under" ) ){
15507                        classString += ' ' + formatting.caption_align + '-align-image-caption';
15508                    }
15509                    var show_caption = formatting.show_caption;
15510                    if( ! show_caption ){
15511                        show_caption = false;
15512                    }
15513                    var caption_size = formatting.caption_size;
15514                    if( ! caption_size ){
15515                        caption_size = "24";
15516                    }
15517                    var subtext_size = formatting.subtext_size;
15518                    if( ! subtext_size ){
15519                        subtext_size = "24";
15520                    }
15521                    var link_label_size = formatting.link_label_size;
15522                    if( ! link_label_size ){
15523                        link_label_size = "24";
15524                    }
15525                    var text_position = formatting.text_position;
15526                    if( ! text_position ){
15527                        text_position = "center";
15528                    }
15529                    var caption_font_weight = formatting.caption_font_weight;
15530                    if( ! caption_font_weight ){
15531                        caption_font_weight = "400";
15532                    }
15533                    var image_grid_overlay_opacity = formatting.image_grid_overlay_opacity;
15534                    if( ! image_grid_overlay_opacity ){
15535                        image_grid_overlay_opacity = "40";
15536                    }
15537                    var image_grid_border_radius = formatting.image_grid_border_radius;
15538                    if ( ! image_grid_border_radius ){
15539                        image_grid_border_radius = "0"
15540                    }
15541                    var image_grid_layout = formatting.layout;
15542                    if ( ! image_grid_layout ){
15543                        image_grid_layout = "grid"
15544                    }
15545                    //add all variables to attribute object
15546                    attributes['client_id'] = componentObject.get('client_id');
15547                    attributes['component_type'] = componentObject.get('type');
15548                    if( componentObject.get('config') && componentObject.get('config').event_types ){
15549                        attributes['event_types'] = componentObject.get('config').event_types;
15550                    }
15551                    // attributes['images'] = componentObject.get('images');
15552                    attributes['type'] = type;
15553                    attributes['config_latest'] = config_latest;
15554                    attributes['data_source'] = config_latest['data_source'];
15555                    attributes['images'] = images;
15556                    attributes['formatting'] = JSON.stringify(formatting);
15557                    attributes['color_scheme'] = color_scheme;
15558                    attributes['classString'] = classString;
15559                    attributes['style_string'] = style_string;
15560                    attributes['elements'] = config_latest['elements'];
15561                    attributes['element_style'] = element_style;
15562                    attributes['opacity'] = opacity;
15563                    attributes['extra_columns'] = extra_columns;
15564                    attributes['images_per_row'] = images_per_row;
15565                    attributes['photos_per_row'] = photos_per_row;
15566                    attributes['left_right_orientation'] = left_right_orientation;
15567                    attributes['columns'] = config_latest.columns;
15568                    attributes['number_of_columns'] = number_of_columns;
15569                    attributes['show_caption'] = show_caption;
15570                    attributes['caption_size'] = caption_size;
15571                    attributes['subtext_size'] = subtext_size;
15572                    attributes['link_label_size'] = link_label_size;
15573                    attributes['text_position'] = text_position;
15574                    attributes['caption_font_weight'] = caption_font_weight;
15575                    attributes['image_grid_overlay_opacity'] = image_grid_overlay_opacity;
15576                    attributes['image_grid_border_radius'] = image_grid_border_radius;
15577                    attributes['image_grid_layout'] = image_grid_layout;
15578                    attributes['background_image_style'] = background_image_style;
15579                    attributes['photo_album'] = photo_album;
15580                    attributes['form'] = form;
15581                    attributes['filter_types'] = filter_types;
15582                    attributes['filter_tag_ids'] = filter_tag_ids;
15583                    attributes['event_dates'] = event_dates;
15584                    attributes['image_mat'] = image_mat;
15585                    let section_element_animation = refinement_result.section_element_animation || '';
15586                    attributes['section_element_animation'] = section_element_animation;
15587                    //return requested attribute
15588                    return attributes[attribute];
15589                }
15590            },
15591        },
15592        computed: {
15593            can_open_visibility_settings(){
15594                return typeof window.wb_open_component_visibility_settings === 'function';
15595            },
15596        },
15597    });
15598</script>
15598
15599    <!-- overlays  -->
15600    
15600<script type="text/html" id="template-view-all-events">
15601    <bottom-sheet-overlay
15602        id="overlay_view_all_events" 
15603        @dismiss_overlay="dismiss_overlay()"
15604    >
15605        <template #header>
15606            <div class="sticky-wrap overflowHidden">
15607                <div class="posScrollbar table-tab-wrap noPaddingBottom">
15608                    <div class="flexNoWrap table-tab-inner">
15609                        <div class="table-tab-option" :class="{'active': date_filter === 'all'}" @click="handleDatesFilter('all')" style="margin-left:22px;">All</div>
15610                        <div class="table-tab-option noMarginLeft" :class="{'active': date_filter === 'upcoming'}" @click="handleDatesFilter('upcoming')">Upcoming</div>
15611                        <div class="table-tab-option noMarginLeft" :class="{'active': date_filter === 'past'}" @click="handleDatesFilter('past')">Past</div>
15612                    </div>
15613                </div>
15614            </div>
15615            <div class="searchBarWrap inline-search relative noMargin overflowHidden">
15616                <div class="inputWrap searchBar searchBar_event-list flexGrowOne boxShadowBottom maxFourHundo">
15617                    <input 
15618                        class="easeFast" 
15619                        placeholder="Search events..."
15620                        v-model="search"
15621                    >
15622                </div>
15623            </div>
15624        </template>
15625
15626        <!-- body content -->
15627        <template #body>
15628            <div 
15629                class="event-list-outer-contain lightBorderTop scrollable" 
15630                :class="class_string" 
15631                :style="style_string"
15632            >
15633                <div 
15634                    class="event-list-wrap centeredBlock centeredText event-list-inner-contain" 
15635                >
15636                    <div class="fff noPadding overflowHidden">
15637                        <div v-if="!loading && sorted_event_data.length >= 1" class="tableInsert hundop flexWrapEvent">
15638                            <template 
15639                                v-for="(event_data, index) in sorted_event_data" 
15640                                :key="event_key(event_data)"
15641                            >
15642                                <p 
15643                                    v-if="is_first_visible_event_of_month(event_data)"
15644                                    class="cs-section-header"
15645                                >{{ month_string( event_data ) }}</p>
15646                                <event-list-event-component
15647                                    :event_data="event_data"
15648                                    :event_props="return_event_props( event_data )"
15649                                    @tapped-event-list-event="tapped_event_list_event"
15650                                ></event-list-event-component>
15651                            </template>
15652                        </div>
15653                        <no-objects-found 
15654                            v-if="!loading && sorted_event_data.length == 0"
15655                            subtext="No events found"
15656                            icon_class="fa-light fa-calendar-days"
15657                        ></no-objects-found>
15658                    </div>
15659                    <div v-if="loading" class="cardBodySpinnerWrap active relative">
15660                        <div class="cardBodySpinner centeredBlock"></div>
15661                    </div>
15662                </div>
15663            </div>
15664        </template>
15665    
15666    </bottom-sheet-overlay>
15667</script>
vendor: 1 bytes, line 15667
15667
15668<script>
15669
15670    function view_all_events( params ){
15671        store.components.view_all_events.event_data = params.event_data;
15672        store.components.view_all_events.class_string = params.class_string;
15673        store.components.view_all_events.style_string = params.style_string;
15674        store.components.view_all_events.element_style_string = params.element_style_string;
15675        store.components.view_all_events.props_generator = params.props_generator;
15676        store.overlays.view_all_events = true;
15677    }
15678    //global store
15679    store.components["view_all_events"] = {
15680        event_data: [],
15681        date_filter: 'upcoming',
15682        search: '',
15683        loading: false,
15684        sortObject: {
15685            order: 'ascending',
15686            key: 'startDate'
15687        },
15688        //passed props
15689        class_string: '',
15690        style_string: '',
15691        element_style_string: '',
15692        props_generator: null, //function to generate props for the individual events
15693    };
15694
15695    //define the component
15696    app_overlays.component("view-all-events-overlay", {
15697        template: "#template-view-all-events",
15698        data() {
15699            return store.components.view_all_events
15700        },
15701        mounted(){
15702            
15703        },
15704        methods: {
15705            month_string( event_data ){
15706                let start_date = new Date(event_data.ts);
15707                return format_utc_date_v2({
15708                    "utcDate": start_date,
15709                    "timezone": store.timezone,
15710                    "formatString": "MMMM YYYY"
15711                });
15712            },
15713            event_key( event_data ){
15714                return event_data.event.id + '_' + event_data.ts;
15715            },
15716            is_first_visible_event_of_month( event_data ){
15717                return this.first_visible_events.includes( this.event_key(event_data) );
15718            },
15719            return_event_props( event_data ){
15720                return this.props_generator( event_data );
15721            },
15722            sortEventList( event_data, sortObject ){
15723                event_data = [... event_data];
15724                if( ! sortObject ){
15725                    sortObject = {}
15726                }
15727                if( ! sortObject.key ){
15728                    sortObject.key = "startDate";
15729                }
15730                if( ! sortObject.order ){
15731                    sortObject.order = "ascending"
15732                }
15733                if( sortObject.key == "name" ){
15734                    if( sortObject.order == "ascending" ){
15735                        return event_data.sort( this.sortByEventName_ascending );
15736                    }else{
15737                        return event_data.sort( this.sortByEventName_descending );
15738                    }
15739                }else if( sortObject.key == "startDate" ){
15740                    if( sortObject.order == "ascending" ){
15741                        return event_data.sort( this.sortByStartDate_event_ascending );
15742                    }else{
15743                        return event_data.sort( this.sortByStartDate_event_descending );
15744                    }
15745                }else if( sortObject.key == "endDate" ){
15746                    if( sortObject.order == "ascending" ){
15747                        return event_data.sort( this.sortByEndDate_event_ascending );
15748                    }else{
15749                        return event_data.sort( this.sortByEndDate_event_descending );
15750                    }
15751                }
15752                return event_data;
15753            },
15754            sortByEventName_ascending( a, b ){
15755                let event_a = a.event;
15756                let event_b = b.event;
15757                var aName = '';
15758                if( event_a.get('name') ){
15759                    aName += event_a.get('name').trim().toLowerCase();
15760                }
15761                var bName = '';
15762                if( event_b.get('name') ){
15763                    bName += event_b.get('name').trim().toLowerCase();
15764                }
15765                if(aName < bName) { return -1; }
15766                if(aName > bName) { return 1; }
15767                //they had the same name
15768                return 0;
15769            },
15770            sortByEventName_descending( a, b ){
15771                let event_a = a.event;
15772                let event_b = b.event;
15773                var aName = '';
15774                if( event_a.get('name') ){
15775                    aName += event_a.get('name').trim().toLowerCase();
15776                }
15777                var bName = '';
15778                if( event_b.get('name') ){
15779                    bName += event_b.get('name').trim().toLowerCase();
15780                }
15781                if(aName > bName) { return -1; }
15782                if(aName < bName) { return 1; }
15783                //they had the same name
15784                return 0;
15785            },
15786            sortByStartDate_event_ascending( a, b ){
15787                let a_ts = a.ts;
15788                let b_ts = b.ts;
15789                if(a_ts < b_ts) { return -1; }
15790                if(a_ts > b_ts) { return 1; }
15791                return 0;
15792            },
15793            sortByStartDate_event_descending( a, b ){
15794                let a_ts = a.ts;
15795                let b_ts = b.ts;
15796                if(a_ts > b_ts) { return -1; }
15797                if(a_ts < b_ts) { return 1; }
15798                return 0;
15799            },
15800            sortByEndDate_event_ascending( a, b ){
15801                let a_end_ts = a.end_ts;
15802                let b_end_ts = b.end_ts;
15803                if(a_end_ts < b_end_ts) { return -1; }
15804                if(a_end_ts > b_end_ts) { return 1; }
15805                return 0;
15806            },
15807            sortByEndDate_event_descending( a, b ){
15808                let a_end_ts = a.end_ts;
15809                let b_end_ts = b.end_ts;
15810                if(a_end_ts < b_end_ts) { return 1; }
15811                if(a_end_ts > b_end_ts) { return -1; }
15812                return 0;
15813            },
15814            tapped_event_list_event( event_data ){
15815                let event = event_data.event;
15816                if(event && event.className == 'events') {
15817                    $('#overlay_event-description-popup h3').text(event.get('name'));
15818                    let start_date = this.start_date( event_data );
15819                    let nice_date = format_utc_date_v2({
15820                        "utcDate": start_date,
15821                        "timezone": store.timezone,
15822                        "formatString": "MMM D, YYYY"
15823                    });
15824                    $('#overlay_event-description-popup .metric_start_date').text( nice_date );
15825                    if( this.show_time( event ) ){
15826                        let event_time = format_utc_date_v2({
15827                            "utcDate": start_date,
15828                            "timezone": store.timezone,
15829                            "formatString": "h:mm A"
15830                        });
15831                        $('#overlay_event-description-popup .metric_start_date_time').text( event_time ).removeClass('incomplete');
15832                    }else{
15833                        $('#overlay_event-description-popup .metric_start_date_time').text('Not provided').addClass('incomplete');
15834                    }
15835                    if(event.get('description')) {
15836                        $('#overlay_event-description-popup .metric_description').html(event.get('description')).removeClass('incomplete');
15837                    } else {
15838                        $('#overlay_event-description-popup .metric_description').text('Not provided').addClass('incomplete');
15839                    }
15840                    fadeIn('#overlay_event-description-popup');
15841                }
15842            },
15843            start_date( event_data ){
15844                if( event_data.ts ){
15845                    return new Date(event_data.ts);
15846                }
15847                return null;
15848            },
15849            show_time( event ){
15850                if(
15851                    event.get('calendarOnly') && event.get('all_day')
15852                ){
15853                    return false;
15854                }else if(
15855                    event.className == "camps" ||
15856                    event.className == "regattas" ||
15857                    event.className == "subevents"
15858                ){
15859                    return false;
15860                }
15861                return true;
15862            },
15863            handleDatesFilter( option ){
15864                this.date_filter = option;
15865                // handle sortObject["order"] here instead of in loadEventList post-retrieval for better UX
15866                if ( option === 'upcoming' ){
15867                    this.sortObject["order"] = "ascending";
15868                } else {
15869                    this.sortObject["order"] = "descending";
15870                }
15871            },
15872            dismiss_overlay(){
15873                store.overlays.view_all_events = false;
15874            }
15875        },
15876        computed: {
15877            first_visible_events(){
15878                let first_visible_events = [];
15879                let handled_keys = [];
15880                //loop through the event_data
15881                this.sorted_event_data.forEach(event_data => {
15882                    let start_date = new Date(event_data.ts);
15883                    let start_components = return_date_components_v2({
15884                        "date": start_date,
15885                        "timezone": store.timezone
15886                    });
15887                    let key = start_components.year + '_' + start_components.month;
15888                    if( ! handled_keys.includes(key) ){
15889                        first_visible_events.push( this.event_key(event_data) );
15890                        handled_keys.push(key);
15891                    }
15892                });
15893                return first_visible_events;
15894            },
15895            pre_filtered_event_data(){
15896                //filter by date first
15897                let pre_filtered_event_data = [...this.event_data];
15898                var timestamp = new Date().getTime();
15899                if ( this.date_filter !== 'all' ){
15900                    pre_filtered_event_data = pre_filtered_event_data.filter(event_data => {
15901                        let start = event_data.ts;
15902                        let end = event_data.end_ts;
15903                        if (this.date_filter === 'past'){
15904                            if( start < timestamp && ( end < timestamp || ! end || end == "undefined" ) ){
15905                                return true;
15906                            }
15907                        } else if (this.date_filter === 'upcoming'){
15908                            if ( start > timestamp || end > timestamp ){
15909                                return true;
15910                            }
15911                            // Single-day events: ts and end_ts are both midnight, so they get filtered out
15912                            // when the event is today. Include if the event's date is today or later.
15913                            if ( start === end || !end ){
15914                                let startOfEventDay = new Date(start);
15915                                startOfEventDay.setUTCHours(0, 0, 0, 0);
15916                                let startOfToday = new Date();
15917                                startOfToday.setUTCHours(0, 0, 0, 0);
15918                                if ( startOfEventDay.getTime() >= startOfToday.getTime() ){
15919                                    return true;
15920                                }
15921                            }
15922                        }
15923                        return false;
15924                    })
15925                }
15926                return pre_filtered_event_data;
15927            },
15928            filtered_event_data(){
15929                let filtered_event_data = this.pre_filtered_event_data;
15930                //filter by search
15931                if (this.search.length > 0){
15932                    filtered_event_data = filtered_event_data.filter(event_data => {
15933                        let name = event_data.event.get('name').toLowerCase();
15934                        if ( name.includes(this.search.toLowerCase()) ){
15935                            return event_data;
15936                        }
15937                    })
15938                }
15939                // handle tab selection using filteredEvents 
15940                var yesterday = new Date();
15941                yesterday.setDate( yesterday.getDate() - 1 );
15942                var timestamp = yesterday.getTime();
15943
15944                //filter by tag
15945                if( this.selected_event_tag_id ){
15946                    filtered_event_data = filtered_event_data.filter(event_data => {
15947                        let event_tag_ids = event_data.event_tag_ids;
15948                        return event_tag_ids && event_tag_ids.includes(this.selected_event_tag_id);
15949                    })
15950                }
15951                //return them
15952                return filtered_event_data;
15953            },
15954            sorted_event_data(){
15955                return this.sortEventList( this.filtered_event_data, this.sortObject );
15956            },
15957        }
15958    });
15959
15960</script>
15960    
15960<script type="text/html" id="template-view-photo-carousel">
15961    <div 
15962        id="overlay_view-photo-carousel"
15963        class="carousel-v-overlay"
15964        :class="{ 'carousel-v-overlay-blur': should_blur_background }"
15965        @touchstart="touched_overlay()"
15966        @mousedown="touched_overlay()"
15967        @click.self="clicked_overlay()"
15968    >
15969        <div 
15970            class="carousel-container"
15971            @click.self="clicked_overlay()"
15972        >
15973            <div class="actions-container" @click.stop>
15974                <div
15975                    class="event-page-callout close-action-container"
15976                    :class="{ 'carousel-action-icon-only': use_icon_only_actions }"
15977                    style="color: #fff;"
15978                    @click.stop="dismiss_overlay()"
15979                >
15980                    <i class="fa-regular fa-xmark" style="color: #fff;"></i>
15981                    <template v-if="!use_icon_only_actions">Close</template>
15982                </div>
15983                <div
15984                    v-if="can_show_download"
15985                    class="event-page-callout"
15986                    :class="{ 'carousel-action-icon-only': use_icon_only_actions }"
15987                    style="color: #fff;"
15988                    @click.stop="tapped_download()"
15989                >
15990                    <template v-if="!downloading">
15991                        <i class="fa-regular fa-arrow-down-to-line" style="color: #fff;"></i>
15992                        <template v-if="!use_icon_only_actions">Download</template>
15993                    </template>
15994                    <template v-else>
15995                        <i class="fa-regular fa-check" style="color: #fff;"></i>
15996                        <template v-if="!use_icon_only_actions">Downloaded</template>
15997                    </template>
15998                </div>
15999                <div
16000                    v-if="can_show_share"
16001                    class="event-page-callout"
16002                    :class="{ 'carousel-action-icon-only': use_icon_only_actions }"
16003                    style="color: #fff;"
16004                    @click.stop="tapped_share()"
16005                >
16006                    <template v-if="!sharing">
16007                        <i class="fa-regular fa-arrow-up-from-bracket" style="color: #fff;"></i>
16008                        <template v-if="!use_icon_only_actions">Share</template>
16009                    </template>
16010                    <template v-else>
16011                        <i class="fa-regular fa-check" style="color: #fff;"></i>
16012                        <template v-if="!use_icon_only_actions">Link copied</template>
16013                    </template>
16014                </div>
16015            </div>
16016            <i v-if="!hide_nav_arrows" class="fa-light fa-chevron-left nav-arrow" @click.stop="prev()"></i>
16017            <div v-if="current_photo" class="carousel-photo-container" @click="clicked_overlay()">
16018                <div class="carousel-photo-frame" @click.stop>
16019                    <video 
16020                        v-if="returnIsVideo(current_photo.file_name) && current_photo.url"
16021                        class="carousel-photo" 
16022                        :class="{ 'is-loaded': detail_photo_loaded }"
16023                        autoplay muted loop playsinline
16024                        :key="'video-' + current_index"
16025                        @loadeddata="detail_photo_loaded = true"
16026                    >
16027                        <source :src="current_photo.url" type="video/mp4">
16028                    </video>
16029                    <img
16030                        v-else-if="current_photo.url"
16031                        class="carousel-photo"
16032                        :class="{ 'is-loaded': detail_photo_loaded }"
16033                        :src="current_photo.url"
16034                        :key="'photo-' + current_index"
16035                        alt=""
16036                        ref="detail_photo"
16037                        @load="detail_photo_loaded = true"
16038                    >
16039                    <template v-if="show_caption">
16040                        <p class="carousel-caption" v-if="current_photo.caption" :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }">{{ current_photo.caption }}</p>
16041                    </template>
16042                </div>
16043            </div>
16044            <div v-else class="carousel-photo-container" @click="clicked_overlay()">
16045                <p class="carousel-caption">No media found</p>
16046            </div>
16047            <i v-if="!hide_nav_arrows" class="fa-light fa-chevron-right nav-arrow" @click.stop="next()"></i>
16048        </div>
16049    </div>
16050</script>
16050
16051
16052<style>
16053    .carousel-v-overlay{
16054        display: flex;
16055        align-items: center;
16056        justify-content: center;
16057        cursor: auto;
16058        background-color: transparent;
16059        position: fixed;
16060        z-index: 1000 !important;
16061        left: 0px;
16062        right: 0px;
16063        top: 0px;
16064        bottom: 0px;
16065        width: 100vw;
16066        height: 100vh;
16067    }
16068    .carousel-v-overlay::before{
16069        content: '';
16070        position: absolute;
16071        inset: 0;
16072        z-index: 0;
16073        pointer-events: none;
16074        background-color: rgba(8,8,19,0.64);
16075        opacity: 0;
16076        animation: carousel-overlay-fade 0.3s ease forwards;
16077    }
16078    .carousel-v-overlay-blur::before{
16079        backdrop-filter: blur(6px);
16080    }
16081    @keyframes carousel-overlay-fade {
16082        0% {
16083            opacity: 0;
16084        }
16085        100% {
16086            opacity: 1;
16087        }
16088    }
16089    @keyframes carousel-chrome-fade {
16090        0% {
16091            opacity: 0;
16092        }
16093        100% {
16094            opacity: 1;
16095        }
16096    }
16097    @keyframes carousel-photo-scale {
16098        0% {
16099            transform: scale(0.8);
16100        }
16101        100% {
16102            transform: scale(1);
16103        }
16104    }
16105    .carousel-container{
16106        display: flex;
16107        justify-content: space-between;
16108        align-items: center;
16109        height: 100vh;
16110        width: 98vw;
16111        position: relative;
16112        z-index: 1;
16113        overflow: hidden;
16114        cursor: auto;
16115        background-color: rgba(0, 0, 0, 0);
16116    }
16117    .carousel-container > .nav-arrow {
16118        font-size: 28px;
16119        padding: 1em;
16120        color: #fff;
16121        z-index: 1;
16122        opacity: 0;
16123        animation: carousel-nav-fade 0.3s ease forwards;
16124        transition: opacity 0.3s ease;
16125        cursor: pointer;
16126    }
16127    @keyframes carousel-nav-fade {
16128        0% {
16129            opacity: 0;
16130        }
16131        100% {
16132            opacity: 0.6;
16133        }
16134    }
16135    .carousel-container > .nav-arrow:hover {
16136        opacity: 1;
16137    }
16138    .carousel-photo-container {
16139        display: flex;
16140        flex-direction: column;
16141        justify-content: center;
16142        align-items: center;
16143        flex: 1 1 auto;
16144        width: auto;
16145        height: 70%;
16146        min-width: 0;
16147        min-height: 200px;
16148        cursor: pointer;
16149    }
16150    .carousel-photo-frame {
16151        display: flex;
16152        flex-direction: column;
16153        justify-content: center;
16154        align-items: center;
16155        max-width: 100%;
16156        max-height: 100%;
16157        cursor: auto;
16158        animation: carousel-photo-scale 0.3s ease forwards;
16159    }
16160    .carousel-photo {
16161        width: auto;
16162        height: auto;
16163        max-width: 100%;
16164        max-height: 70vh;
16165        object-fit: contain;
16166        background-size: contain;
16167        background-repeat: no-repeat;
16168        background-position: center;
16169        opacity: 0;
16170        transition: opacity 0.35s ease;
16171    }
16172    .carousel-photo.is-loaded {
16173        opacity: 1;
16174    }
16175    .carousel-caption{
16176        margin: 10px 0 0 0;
16177        text-transform: uppercase;
16178        letter-spacing: 2px;
16179        color: #fff;
16180        padding: 5px;
16181        border-radius: 2px;
16182        background-color: rgba(0, 0 ,0, 0.1);
16183    }
16184    .actions-container{
16185        position: fixed;
16186        top: 9px;
16187        right: 9px;
16188        display: flex;
16189        align-items: center;
16190        justify-content: center;
16191        opacity: 0;
16192        animation: carousel-chrome-fade 0.3s ease forwards;
16193    }
16194    .actions-container > .event-page-callout.close-action-container{
16195        position: fixed;
16196        top: 9px;
16197        left: 9px;
16198    }
16199    .actions-container > .event-page-callout{
16200        padding-left: 10px;
16201        padding-right: 10px;
16202        border-radius: 5px;
16203        border: 1px solid #e4ebf1;
16204        font-size: 13px;
16205        margin: 6px;
16206        color: #6a7482;
16207        padding-bottom: 8px;
16208        padding-top: 8px;
16209        cursor: pointer;
16210    }
16211    .actions-container > .event-page-callout i{
16212        padding-right:3px;
16213    }
16214    .actions-container > .event-page-callout:hover{
16215        color: #081333;
16216    }
16217    .actions-container > .event-page-callout.carousel-action-icon-only{
16218        border: none;
16219        background: transparent;
16220        padding: 6px;
16221        margin: 4px;
16222        min-width: auto;
16223    }
16224    .actions-container > .event-page-callout.carousel-action-icon-only i{
16225        padding-right: 0;
16226        font-size: 28px;
16227    }
16228</style>
16229
16230<script>
16231
16232    function downloadImage( url, filename ) {
16233        return fetch(url, { mode: 'cors' })
16234            .then(response => {
16235                if (!response.ok) {
16236                    throw new Error(`Network response was not ok: ${response.statusText}`);
16237                }
16238                return response.blob();
16239            })
16240            .then(blob => {
16241                const url = window.URL.createObjectURL(blob);
16242                const a = document.createElement('a');
16243                a.style.display = 'none';
16244                a.href = url;
16245                a.download = filename;
16246                // Append the anchor to the body
16247                document.body.appendChild(a);
16248                a.click();
16249                // Remove the anchor from the body
16250                document.body.removeChild(a);
16251                window.URL.revokeObjectURL(url);
16252            })
16253            .catch(err => console.error('Error downloading the image:', err));
16254    }
16255
16256    store.components.view_photo_carousel = {
16257        json_photos: [],
16258        current_index: 0,
16259        downloading: false,
16260        sharing: false,
16261        detail_photo_loaded: false,
16262        json_config: {
16263            allow_share: false, 
16264            allow_download: false, 
16265            context: '',
16266        },
16267    }
16268    
16269    app_overlays.component("view-photo-carousel", {
16270        template: "#template-view-photo-carousel",
16271        data() {
16272            return store.components.view_photo_carousel;
16273        },
16274        mounted() {
16275            this.bound_keydown = (event) => {
16276                this.handle_detail_keydown(event);
16277            };
16278            window.addEventListener('keydown', this.bound_keydown);
16279            this.$nextTick(() => {
16280                this.check_detail_photo_cached();
16281            });
16282        },
16283        unmounted() {
16284            if( this.bound_keydown ){
16285                window.removeEventListener('keydown', this.bound_keydown);
16286            }
16287        },
16288        watch: {
16289            current_index(){
16290                this.detail_photo_loaded = false;
16291                this.$nextTick(() => {
16292                    this.check_detail_photo_cached();
16293                });
16294            },
16295        },
16296        methods: {
16297            return_config_value( key, default_value ){
16298                if ( this.json_config && this.json_config[key] !== undefined && this.json_config[key] !== null ){
16299                    return this.json_config[key];
16300                }
16301                return default_value;
16302            },
16303            handle_detail_keydown( event ){
16304                if( !event ){
16305                    return;
16306                }
16307                let tag = event.target && event.target.tagName;
16308                if( tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' ){
16309                    return;
16310                }
16311                if( event.key === 'ArrowLeft' ){
16312                    event.preventDefault();
16313                    this.prev();
16314                    return;
16315                }
16316                if( event.key === 'ArrowRight' ){
16317                    event.preventDefault();
16318                    this.next();
16319                }
16320            },
16321            check_detail_photo_cached(){
16322                if( this.current_photo && this.returnIsVideo(this.current_photo.file_name) ){
16323                    this.detail_photo_loaded = true;
16324                    return;
16325                }
16326                let el = this.$refs.detail_photo;
16327                if( el && el.complete && el.naturalWidth ){
16328                    this.detail_photo_loaded = true;
16329                }
16330            },
16331            async tapped_download(){
16332                if ( !this.current_photo || !this.current_photo.url ){
16333                    return;
16334                }
16335                let url = this.current_photo.url;
16336                let file_name = this.current_photo.file_name || ('photo-' + (this.current_index + 1));
16337                this.downloading = true;
16338                try {
16339                    downloadImage( url, file_name ).then(() => {
16340                        displayMessage("File downloaded.", false, 6000);
16341                        this.downloading = false;
16342                    }).catch(() => {
16343                        displayMessage("Sorry, something went wrong with your download. Please try again, or contact support if the problem persists.", true, 8000);
16344                        this.downloading = false;
16345                    })
16346                } catch (error) {
16347                    displayMessage("Sorry, something went wrong with your download. Please try again, or contact support if the problem persists.", true, 8000);
16348                }
16349            },
16350            returnIsVideo(filename) {
16351                var ext = this.getExtension(filename);
16352                switch (ext.toLowerCase()) {
16353                    case 'm4v':
16354                    case 'avi':
16355                    case 'mpg':
16356                    case 'mp4':
16357                    case 'mp3':
16358                    case 'mov':
16359                    // etc
16360                    return true;
16361                }
16362                return false;
16363            },
16364            getExtension(filename) {
16365                var parts = filename.split('.');
16366                return parts[parts.length - 1];
16367            },
16368            tapped_share(){
16369                copyToClipboard( window.location.href );
16370                displayMessage("Link copied.", false, 6000);
16371                this.sharing = true;
16372                setTimeout(() => {
16373                    this.sharing = false;
16374                }, 3000);
16375            },
16376            prev(){
16377                if ( this.current_index === 0 ){
16378                    this.current_index = this.json_photos.length - 1;
16379                } else {
16380                    this.current_index -= 1;
16381                }
16382            },
16383            next(){
16384                if ( this.current_index === this.json_photos.length - 1 ){
16385                    this.current_index = 0;
16386                } else {
16387                    this.current_index += 1;
16388                }
16389            },
16390            dismiss_overlay(){
16391                store.overlays.view_photo_carousel = false;
16392            },
16393            clicked_overlay(){
16394                if( ! should_close_overlay( this.last_touch ) ){
16395                    return
16396                }
16397                this.dismiss_overlay();
16398            },
16399            touched_overlay(){
16400                this.last_touch = new Date().getTime();
16401            },
16402        },
16403        computed: {
16404            current_photo(){
16405                if ( this.json_photos && this.json_photos.length > 0 && this.json_photos[this.current_index] ){
16406                    return this.json_photos[this.current_index];
16407                }
16408                return null;
16409            },
16410            can_show_download(){
16411                var default_value = false;
16412                return this.return_config_value('allow_download', default_value) === true;
16413            },
16414            can_show_share(){
16415                var default_value = true;
16416                return this.return_config_value('allow_share', default_value) === true;
16417            },
16418            show_caption(){
16419                var default_value = false;
16420                return this.return_config_value('show_caption', default_value) === true;
16421            },
16422            caption_size(){
16423                var default_value = 16;
16424                return this.return_config_value('caption_size', default_value);
16425            },
16426            caption_font_weight(){
16427                var default_value = 400;
16428                return this.return_config_value('caption_font_weight', default_value);
16429            },
16430            should_blur_background(){
16431                var default_value = false;
16432                return this.return_config_value('blur_background', default_value) === true;
16433            },
16434            use_icon_only_actions(){
16435                var default_value = false;
16436                return this.return_config_value('icon_only_actions', default_value) === true;
16437            },
16438            hide_nav_arrows(){
16439                let default_value = false;
16440                return this.return_config_value('hide_nav_arrows', default_value) === true;
16441            },
16442        }
16443    });
16444    
16445</script>
16445    
16445<script type="text/html" id="template-bottom-sheet-overlay">
16446
16447    <div 
16448        class="v-overlay scrollable hugePaddingBottom active" 
16449        @touchstart.self="touched_overlay()"
16450        @mousedown.self="touched_overlay()"
16451        @click.self="clicked_overlay()"
16452        :class="[{'noPointer': prevent_close}, {'bottom-sheet-blur': blur_background}]"
16453    >
16454        <div 
16455            class="standardOverlayCard centeredBlock bottom-sheet"
16456            :class="[
16457                {'short': height == 'short' || is_short}, 
16458                {'narrow': narrow},
16459                {'noMaxWidth': full_width}
16460            ]"
16461        >
16462
16463            <!-- generic header content -->
16464            <div class="bottom-sheet-overlay-header">
16465
16466                <div 
16467                    v-if="! prevent_close"
16468                    class="relative"
16469                >
16470                    <div 
16471                        class="overlay-header-handle-wrap centeredBlock"
16472                        @click.stop="toggle_height()" 
16473                    >
16474                        <div class="overlay-header-handle"></div>
16475                    </div>
16476                    <i 
16477                        class="overlay-x-button fa-regular fa-xmark pointer"
16478                        @click.stop="dismiss_overlay()"
16479                    ></i>
16480                </div>
16481
16482                <!-- header content injected here -->
16483                <slot name="header"></slot>
16484
16485            </div>
16486
16487            <!-- body content injected here -->
16488            <slot name="body"></slot>
16489
16490            <!-- footer content injected here -->
16491            <slot name="footer"></slot>
16492
16493        </div>
16494    </div>
16495
16496</script>
16496
16497
16498<style>
16499
16500    .standardOverlayCard.bottom-sheet.narrow{
16501        max-width: 600px;
16502    }
16503
16504    .standardOverlayCard.bottom-sheet.medium{
16505        max-width: 800px;
16506    }
16507
16508    .standardOverlayCard.bottom-sheet{
16509        position: fixed;
16510        bottom: 0px;
16511        top: 50px;
16512        left: 0px;
16513        right: 0px;
16514        z-index: 1001;
16515        background-color: #fff;
16516        box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
16517        transition: bottom 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
16518        border-top-left-radius: 10px;
16519        border-top-right-radius: 10px;
16520        padding-bottom: 10px;
16521        padding-top: 10px;
16522        max-width: 1100px;
16523        margin-top: 0px;
16524        border-bottom-left-radius: 0px;
16525        border-bottom-right-radius: 0px;
16526    }
16527
16528    .standardOverlayCard.bottom-sheet.short{
16529        top: 50%;
16530    }
16531
16532    @media (max-height: 600px) {
16533
16534        .standardOverlayCard.bottom-sheet.short{
16535            top: 150px;
16536        }
16537
16538    }
16539
16540    .standardOverlayCard.bottom-sheet h3{
16541        padding-top:0px;
16542    }
16543
16544    .standardOverlayCard.bottom-sheet{
16545        display: flex;
16546        flex-direction: column;
16547        padding-bottom:0px;
16548        padding-top: 0px;
16549    }
16550
16551    .standardOverlayCard.bottom-sheet .bottom-sheet-overlay-card-header
16552    {
16553        flex: 0 0 auto; /* Fixed height, doesn't grow or shrink */
16554    }
16555
16556    .standardOverlayCard.bottom-sheet .standardCardFooter{
16557        position: absolute;
16558        bottom: 0px;
16559        padding-bottom: var(--cs-safe-area-bottom, 10px);
16560        left: 0px;
16561        right: 0px;
16562        background-color: #fff;
16563        border-top: 1px solid #e4ebf1;
16564        z-index: 10;
16565    }
16566
16567    .standardOverlayCard.bottom-sheet .standardCardBody{
16568        flex: 1 1 auto; /* Flexible height, can grow and shrink */
16569        padding-bottom: 90px !important;
16570    }
16571
16572    .overlay-header-handle-wrap{
16573        padding: 10px;
16574        cursor: pointer;
16575        max-width: 120px;
16576    }
16577    .overlay-header-handle{
16578        width: 70px;
16579        height: 3px;
16580        border-radius: 10px;
16581        background-color: #acb3ba;
16582        margin-left: auto;
16583        margin-right: auto;
16584        margin-top: 8px;
16585        margin-bottom: 8px;
16586    }
16587    .overlay-x-button{
16588        position: absolute;
16589        right: 14px;
16590        color: #535e6c;
16591        font-size: 20px;
16592        top: 12px;
16593    }
16594</style>
16595
16596<script>
16597
16598    //define the component
16599    app_overlays.component("bottom-sheet-overlay", {
16600        template: "#template-bottom-sheet-overlay",
16601        data() {
16602            return {
16603                height: "tall"
16604            }
16605        },
16606        props: {
16607            blur_background: {
16608                type: Boolean,
16609                default: false
16610            },
16611            narrow: {
16612                type: Boolean,
16613                default: false
16614            },
16615            prevent_close: {
16616                type: Boolean,
16617                default: false
16618            },
16619            full_width: {
16620                type: Boolean,
16621                default: false
16622            },
16623            is_short: {
16624                type: Boolean,
16625                default: false
16626            }
16627        },
16628        mounted(){
16629            const universalEditOverlay = document.getElementById('overlay_universal-edit');
16630            if (universalEditOverlay) {
16631                universalEditOverlay.style.zIndex = "1000000";
16632            }
16633            const membershipValidationOverlay = document.getElementById('overlay_verifyMembership');
16634            if (membershipValidationOverlay) {
16635                membershipValidationOverlay.style.zIndex = "1000000";
16636            }
16637        },
16638        beforeUnmount() {
16639            //reset the z-index
16640            const universalEditOverlay = document.getElementById('overlay_universal-edit');
16641            if (universalEditOverlay) {
16642                universalEditOverlay.style.zIndex = ""; // Revert to default
16643            }
16644            const membershipValidationOverlay = document.getElementById('overlay_verifyMembership');
16645            if (membershipValidationOverlay) {
16646                membershipValidationOverlay.style.zIndex = ""; // Revert to default
16647            }
16648        },
16649        methods: {
16650            dismiss_overlay(){
16651                this.$emit('dismiss_overlay');
16652            },
16653            toggle_height(){
16654                if( this.height === "tall" ){
16655                    this.height = "short";
16656                }else{
16657                    this.height = "tall";
16658                }
16659            },
16660            clicked_overlay(){
16661                if( this.prevent_close ){
16662                    return;
16663                }
16664                if (should_close_overlay(this.last_touch)) {
16665                    this.dismiss_overlay();
16666                }
16667            },
16668            touched_overlay(){
16669                this.last_touch = new Date().getTime();
16670            },
16671        },
16672    });
16673
16674    
16675</script>
16675
16676
16677<style>
16678    .bottom-sheet-blur{
16679        backdrop-filter: blur(6px);
16680    }
16681
16682    @keyframes bottom-sheet-fade-in {
16683        from { opacity: 0; }
16684        to   { opacity: 1; }
16685    }
16686</style>    
16686<script type="text/html" id="template-show-event-details-in-member-cal">
16687    <bottom-sheet-overlay
16688        @dismiss_overlay="dismiss_overlay()"
16689        :narrow="true"
16690    >
16691        <!-- header content -->
16692        <template #header></template>
16693
16694        <!-- body content -->
16695        <template #body>
16696            <div 
16697                class="standardCardBody fff scrollable "
16698            >
16699                <h2 v-if="event_json.title" class="event-page-name centeredText marginTop">{{ event_json.title }}</h2>           
16700                <div class="flexWrap smallMarginBottom tinyMarginTop">
16701                    <p v-if="date_string" class="hundop flexNoWrapLeftAlign">
16702                        <i class="fa-light fa-calendar-week" style="font-size:20px;"></i>
16703                        <span class="leftText tinyMarginLeft">{{ date_string }}</span>
16704                    </p>
16705                    <p v-if="time_string" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;">
16706                        <i class="fa-light fa-clock" style="font-size:20px;"></i>
16707                        <span class="leftText tinyMarginLeft">{{ time_string }}</span>
16708                    </p>
16709                    <!-- in-portal booking links (eg. group classes surfaced in the member
16710                         portal booking hub) navigate within the SPA instead of opening the
16711                         standalone page in a new tab -->
16712                    <p v-if="event_json.in_portal_path" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;">
16713                        <i class="fa-light fa-link" style="font-size:20px;"></i>
16714                        <span class="leftText tinyMarginLeft">
16715                            <a class="pointer" @click="tapped_in_portal_link()">Follow link</a>
16716                        </span>
16717                    </p>
16718                    <p v-else-if="event_json.external_event_url" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;">
16719                        <i class="fa-light fa-link" style="font-size:20px;"></i>
16720                        <span class="leftText tinyMarginLeft">
16721                            <a target="_blank" :href="event_json.external_event_url">Follow link</a>
16722                        </span>
16723                    </p>
16724                </div>
16725
16726                <div
16727                    v-if="event_image_url"
16728                    style="display:flex; width:100%; justify-content:center;"
16729                >
16730                    <div
16731                        v-if="image_crop_style == 'cover'"
16732                        style="width:100%; max-width:580px; height:400px; position:relative; overflow:hidden; border:1px solid #e4ebf1; border-radius:5px; display:flex; align-items:center; justify-content:center;"
16733                    >
16734                        <img
16735                            :src="event_image_url"
16736                            style="width:100%; height:100%; display:block; object-fit:cover;"
16737                            @error="console.log('event image failed to load:', event_image_url)"
16738                        />
16739                    </div>
16740
16741                    <div
16742                        v-else
16743                        style="width:100%; max-width:580px; height:400px; position:relative; overflow:hidden; border:1px solid #e4ebf1; border-radius:5px;"
16744                    >
16745                        <div
16746                            :style="{ backgroundImage: 'url(' + event_image_url + ')', position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: 'cover', filter: 'blur(6px)' }"
16747                        >
16748                            <div
16749                                style="position:absolute; top:0; right:0; left:0; bottom:0; background-color: rgba(253,253,253,0.8);"
16750                            ></div>
16751                        </div>
16752                        <div
16753                            :style="{ backgroundImage: 'url(' + event_image_url + ')', position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: 'contain' }"
16754                        ></div>
16755                    </div>
16756                </div>
16757                <div
16758                    class="event-page-details flex-col"
16759                >
16760                    <div class="event-page-details-wrapper maxSixFifty flex-col centeredText">
16761                        <p 
16762                            v-if="event_json.description" 
16763                            class="smallMarginTop tiptap-container marginBottom" 
16764                            style="line-height: 1.5;" 
16765                            v-html="event_json.description">
16766                        </p>
16767                    </div>
16768                </div>
16769            </div>
16770        </template>
16771    
16772
16773    </bottom-sheet-overlay>
16774</script>
16774
16775
16776
16777<script>
16778
16779    function follow_member_cal_event_link(event_json) {
16780        if (event_json.in_portal_path) {
16781            updateURL(event_json.in_portal_path);
16782            displayViewForUrlPath();
16783            return true;
16784        }
16785        if (event_json.external_event_url) {
16786            window.open(event_json.external_event_url, '_blank');
16787            return true;
16788        }
16789        return false;
16790    }
16791
16792    function show_event_details_in_member_cal(params) {
16793        store.components.show_event_details_in_member_cal.event_json = params.event_json;
16794        store.overlays.show_event_details_in_member_cal = true;
16795    }
16796
16797    store.components["show_event_details_in_member_cal"] = {
16798        event_json: null,
16799    }
16800
16801    app_overlays.component("show-event-details-in-member-cal-overlay", {
16802        template: "#template-show-event-details-in-member-cal",
16803        data() {
16804            return store.components["show_event_details_in_member_cal"];
16805        },
16806        methods: {
16807            dismiss_overlay(){
16808                store.overlays.show_event_details_in_member_cal = false;
16809            },
16810            tapped_in_portal_link(){
16811                this.dismiss_overlay();
16812                follow_member_cal_event_link(this.event_json);
16813            },
16814        }, 
16815        computed: {
16816            event_image_url(){
16817                if(this.event_json && this.event_json.image && this.event_json.image.url){
16818                    return this.event_json.image.url;
16819                }
16820                return null;
16821            },
16822            image_crop_style(){
16823                if(this.event_json && this.event_json.image && this.event_json.image.crop){
16824                    return this.event_json.image.crop;
16825                }
16826                return null;
16827            },
16828            date_string(){
16829                var start_date_string = format_utc_date_v2({
16830                    utcDate: this.event_json.ts,
16831                    formatString: 'MMMM D, YYYY', 
16832                    timezone: store.clubObject.get('timezone')
16833                });
16834                if(this.event_json.isAllDay){
16835                    //it could span multiple days, check if we should show the end date
16836                    var endDate = this.event_json.end_ts || null;
16837                    if(endDate){
16838                        //compare the date/month/year only
16839                        var startDateObj = new Date(this.event_json.ts);
16840                        var endDateObj = new Date(endDate);
16841                        if(startDateObj.getUTCFullYear() !== endDateObj.getUTCFullYear() ||startDateObj.getUTCMonth() !== endDateObj.getUTCMonth() || startDateObj.getUTCDate() !== endDateObj.getUTCDate()){
16842                            //different dates, show the end date
16843                            start_date_string += ` - ${ format_utc_date_v2({
16844                                utcDate: endDate,
16845                                formatString: 'MMMM D, YYYY', 
16846                                timezone: store.clubObject.get('timezone')
16847                            })}`;
16848                        }
16849                    }
16850                }
16851                return start_date_string;
16852            },
16853            time_string(){
16854                if(this.event_json.isAllDay){
16855                    return "";
16856                }
16857                var startDate = this.event_json.ts;
16858                var endDate = this.event_json.end_ts || null;
16859                var time_string = format_utc_date_v2({
16860                    utcDate: this.event_json.ts,
16861                    formatString: 'h:mm A',
16862                    timezone: store.clubObject.get('timezone')
16863                });
16864                if(endDate && endDate !== startDate) {
16865                    time_string += ` - ${ format_utc_date_v2({
16866                        utcDate: endDate,
16867                        formatString: 'h:mm A',
16868                        timezone: store.clubObject.get('timezone')
16869                    })}`;
16870                }
16871                return time_string;
16872            }
16873        },
16874    });
16875</script>
16875
16876
16877
16878    <!-- define/register shared vue components  -->
16879    
16880    
16880<script type="text/html" id="template-calendar-date-picker">
16881    <vue-date-picker
16882        v-model="selected_date" 
16883        :mode="mode"
16884        :modes="['year', 'month']" 
16885        :monthPicker="true" 
16886        :yearPicker="true"
16887        @update:modelValue="date_changed()"
16888        :clearable="false"
16889        format="MMM-d-yyyy"
16890        auto-apply
16891        :teleport="true"
16892        :month-change-on-scroll="false"
16893        menu-class-name="cs-calendar-date-picker-menu"
16894        input-class-name="cs-calendar-date-picker-input"
16895    >
16896        <template #input-icon>
16897            <i class="fa-regular fa-calendar-arrow-down"></i>
16898        </template>
16899        <template #calendar-icon>
16900            <i class="fa-regular fa-arrow-turn-down-left"></i>
16901        </template>
16902        <template #arrow-left>
16903            <i class="fa-regular fa-chevron-left"></i>
16904        </template>
16905        <template #arrow-right>
16906            <i class="fa-regular fa-chevron-right"></i>
16907        </template>
16908    </vue-date-picker>
16909 </script>
16909
16910 
16911 <style>
16912
16913    .cs-calendar-date-picker-menu .dp__btn{
16914        color: #000000 !important;
16915        background: none !important; 
16916        font: inherit !important;
16917        line-height: normal !important;
16918     }
16919
16920     .cs-calendar-date-picker-input{
16921        width: 0px;
16922        padding-left: 26px;
16923        padding-right: 22px;
16924        text-align: center;
16925        margin-right: 8px;
16926        overflow: hidden;
16927        padding-top: 7px;
16928        padding-bottom: 7px;
16929        border: 1px solid #e4ebf1;
16930     }
16931
16932     .cs-calendar-menu-wrap .dp__input_icon i{
16933        font-size: 16px !important;
16934        transform: none;
16935     }
16936
16937 </style>
16938 
16939 
16939<script>
16940 
16941     app_content.component("calendar-date-picker-component", {
16942         template: "#template-calendar-date-picker",
16943         data() {
16944            return {
16945                selected_date: new Date(),
16946                mode: 'month',
16947            }
16948         },
16949         props: {
16950            handle_button_click: Function,
16951         },
16952         methods: {
16953            date_changed(){
16954                this.handle_button_click( 'date-picker', this.selected_date );
16955            },
16956         },
16957     });
16958 
16959 </script>
16959
16960 
16961 
16961<script>
16962     app_content.component('VueDatePicker', VueDatePicker);
16963 </script>
16963
16964     
16964<script type="text/html" id="template-calendar-grid">
16965    <div class="centeredOnMobile smallMarginTop" :class="{'ninetyPercent': !is_membership_cal, 'standardCard': !is_membership_cal}">
16966        <!-- main options header // stays consistent across all views -->
16967        <div class="standardCardBody noBorderTop calendar noPadding fff" style="overflow: none;">
16968            <!-- options for non-membership cal -->
16969            <div v-if="filterOptions?.length >= 1 && !is_membership_cal" class="cal-filter-option-wrap scrollable paddingLeftRight tinyPaddingTop smallPaddingBottom">
16970                <div class="flexNoWrap">
16971                    <template v-for="option in filterOptions">
16972                        <div
16973                            class="cs-cal-filter-option" 
16974                            :class="[
16975                                'cs-cal-filter-option_' + option.data_option,
16976                                { 'active': selectedFilterOptions.indexOf(option.data_option) !== -1 }
16977                            ]"
16978                            :data-option="option.data_option"
16979                            @click="$emit('handleFilterSelection', option)"
16980                        >
16981                            {{ option.name }}
16982                        </div>
16983                    </template>
16984                </div>
16985            </div>
16986            <!-- main calendar header // stays consistent across all views -->
16987            <!-- options for membership cal -->
16988            <div class="cs-calendar-wrap overflowHidden" :class="[{ 'wrapBorderBottom': is_membership_cal }, 'cs-calendar-wrap_' + client_id, class_string]" :style="generateWrapStyle('cs-calendar-wrap')" :data-client-id="client_id">
16989                <div v-if="filterOptions?.length >= 1 && is_membership_cal" class="scrollable posScrollbar lightBorderTop noPaddingBottom">
16990                    <div class="flexNoWrap" :style="generateWrapStyle('options')">
16991                        <template v-for="option in filterOptions">
16992                            <div
16993                                class="cs-cal-filter-option" 
16994                                :class="{
16995                                    ['cs-cal-filter-option_' + option.data_option]: true,
16996                                    'active': selectedFilterOptions.indexOf(option.data_option) !== -1
16997                                  }"
16998                                :data-option="option.data_option"
16999                                @click="$emit('handleFilterSelection', option)"
17000                            >
17001                                {{ option.name }}
17002                            </div>
17003                        </template>
17004                    </div>
17005                </div>
17006                <div class="cs-calendar-menu-wrap flexNoWrap" :style="generateWrapStyle('cs-calendar-menu-wrap')">
17007                    <div class="flexWrapLeftAlign">
17008                        <div class="flexNoWrap">
17009                            <div class="cs-calendar-button roboto hideOnMobile" data-option="today" @click="handleButtonClick('today')">Today</div>
17010                            <div class="cs-calendar-button cs-calendar-button-previous roboto" data-option="previous" @click="handleButtonClick('previous')"></div>
17011                            <div class="cs-calendar-button cs-calendar-button-next roboto" data-option="next" @click="handleButtonClick('next')"></div>
17012                        </div>
17013                        <p v-if="calendarTitle" class="cs-calendar-date-range-title roboto">{{ calendarTitle }}</p>
17014                    </div>
17015                    <div class="flexGrowOne roboto"></div>
17016                    <!-- calendar date picker -->
17017                    <calendar-date-picker-component
17018                        :handle_button_click="handleButtonClick"
17019                    ></calendar-date-picker-component>
17020                    <div class="vue-cs-calendar-select-wrap onWhite roboto">
17021                        <select 
17022                            class="vue-cs-calendar-view-select roboto" 
17023                            v-model="view_preference" 
17024                            @change="changeViewPreference"
17025                        >
17026                            <option value="Month">Month</option>
17027                            <option value="Week">Week</option>
17028                            <option value="Day">Day</option>
17029                        </select>
17030                    </div>
17031                </div>
17032                <!-- inject month view -->
17033                <div v-if="view_preference === 'Month' && !loading" class="flexGrid">
17034                    <!-- header -->
17035                    <div class="daysOfWeek-grid">
17036                        <template v-for="day in daysOfWeek">
17037                            <calendar-cell-component
17038                                class="grid-cell dayOfWeek"
17039                                :class="{ 'borderClose': day === 'Sat' }"
17040                                :day="day.toUpperCase()"
17041                                cellType="dayOfWeek"
17042                                :view_preference="view_preference"
17043                            ></calendar-cell-component>
17044                        </template>
17045                    </div>
17046                    <!-- events -->
17047                    <div class="calendar-grid">
17048                        <template v-for="(calendarObj, i) in calendar">
17049                            <calendar-cell-component
17050                                class="grid-cell"
17051                                :client_id="client_id"
17052                                :date="calendarObj.date"
17053                                :class="{
17054                                    'notCurrentMonth': calendarObj.class === 'prevMonthDate' || calendarObj.class === 'nextMonthDate',
17055                                    'currentMonth': calendarObj.class === 'currentMonthDate',
17056                                    'borderClose': calendarObj.date.day() === 6,
17057                                    'borderTopRightBottomClose': i >= 35 && i <= 40,
17058                                    'borderTopBottomClose': i === calendar.length - 1
17059                                }"
17060                                :month_class="calendarObj.class"
17061                                :day="calendarObj.day"
17062                                :events="calendarObj.events"
17063                                cellType="calendarObj"
17064                                :view_preference="view_preference"
17065                                :defaultDate="calendarObj.defaultDate"
17066                                :monthIndex="i"
17067                                @createSchedule_emitted="createSchedule_emitted"
17068                                @clickSchedule_emitted="clickSchedule_emitted"
17069                                :is_membership_cal="is_membership_cal"
17070                            ></calendar-cell-component>
17071                        </template>
17072                    </div>
17073                </div>
17074                <!-- inject week view -->
17075                <div v-if="view_preference === 'Week' && !loading" class="flexGrid">
17076                    <!-- header -->
17077                    <div class="placeholder-daysOfWeek-grid">
17078                        <template v-for="day in placeholder_daysOfWeek">
17079                            <calendar-cell-component v-if="day.dayOfWeek === null"
17080                                class="grid-cell-week dayOfWeek hide"
17081                                :day="day"
17082                                cellType="dayOfWeek"
17083                                :view_preference="view_preference"
17084                            ></calendar-cell-component> 
17085                            <calendar-cell-component v-if="day.dayOfWeek !== null"
17086                                class="grid-cell-week dayOfWeek"
17087                                :day="day"
17088                                cellType="dayOfWeek"
17089                                :view_preference="view_preference"
17090                                :defaultDate="day.defaultDate"
17091                            ></calendar-cell-component>
17092                        </template>
17093                        <!-- all day events -->
17094                        <template v-for="allDay in placeholder_daysOfWeek">
17095                            <calendar-cell-component v-if="allDay.dayOfWeek === null"
17096                                class="grid-cell-week allDay"
17097                                :allDay="allDay"
17098                                cellType="allDay_events"
17099                                :view_preference="view_preference"
17100                                :is_membership_cal="is_membership_cal"
17101                            ></calendar-cell-component> 
17102                            <calendar-cell-component v-if="allDay.dayOfWeek !== null"
17103                                class="grid-cell-week allDay"
17104                                :allDay="allDay"
17105                                cellType="allDay_events"
17106                                :view_preference="view_preference"
17107                                @createSchedule_emitted="createSchedule_emitted"
17108                                @clickSchedule_emitted="clickSchedule_emitted"
17109                                :is_membership_cal="is_membership_cal"
17110                            ></calendar-cell-component>
17111                        </template>
17112                    </div>
17113                    <!-- timestamps and events -->
17114                    <div class="placeholder-calendar-grid">
17115                        <template v-for="(timestamp, i) in timestamps">
17116                            <template v-for="(calendarObj, j) in calendar">
17117                                <calendar-cell-component 
17118                                    v-if="j === 0"
17119                                    class="grid-cell-week timestamp"
17120                                    :date="calendarObj.date"
17121                                    :timestamp="i % 4 === 0 ? formatShortTime( timestamp.customFormat ) : null"
17122                                    :events="[]"
17123                                    cellType="timestamp"
17124                                    :view_preference="view_preference"
17125                                    :is_membership_cal="is_membership_cal"
17126                                    :class="{ 'borderBottomClose': i === timestamps.length - 1 }"
17127                                ></calendar-cell-component>
17128                                <calendar-cell-component 
17129                                    v-else
17130                                    class="grid-cell-week"
17131                                    :style="calendarObj.defaultDate ? { 'background-color': 'rgba( 175, 213, 240, 0.1)' } : null"
17132                                    :class="{
17133                                        'showBorder': i % 4 === 0,
17134                                        'hideBorder': i === 0 || i % 4 !== 0
17135                                    }"
17136                                    :date="calendarObj.date"
17137                                    :events="generateEventForTimestamp( timestamp.customFormat, calendarObj.events, calendarObj.date )"
17138                                    cellType="calendarObj"
17139                                    :view_preference="view_preference"
17140                                    @createSchedule_emitted="createSchedule_emitted"
17141                                    @clickSchedule_emitted="clickSchedule_emitted"
17142                                ></calendar-cell-component>
17143                            </template>
17144                        </template>
17145                    </div>
17146                </div>
17147                <!-- inject day view -->
17148                <div v-if="view_preference === 'Day' && !loading" class="flexGrid">
17149                    <!-- header -->
17150                    <div class="day-daysOfWeek-grid">
17151                        <template v-for="day in today_daysOfWeek">
17152                            <calendar-cell-component v-if="day.dayOfWeek === null"
17153                                class="grid-cell-week dayOfWeek hide"
17154                                :day="day"
17155                                cellType="dayOfWeek"
17156                                :view_preference="view_preference"
17157                            ></calendar-cell-component> 
17158                            <calendar-cell-component v-if="day.dayOfWeek !== null"
17159                                class="grid-cell-week day"
17160                                :day="day"
17161                                cellType="dayOfWeek"
17162                                :view_preference="view_preference"
17163                                :defaultDate="day.defaultDate"
17164                            ></calendar-cell-component>
17165                        </template>
17166                        <!-- all day events -->
17167                        <template v-for="allDay in today_daysOfWeek">
17168                            <calendar-cell-component v-if="allDay.dayOfWeek === null"
17169                                class="grid-cell-week allDay"
17170                                :allDay="allDay"
17171                                cellType="allDay_events"
17172                                :view_preference="view_preference"
17173                                :is_membership_cal="is_membership_cal"
17174                            ></calendar-cell-component> 
17175                            <calendar-cell-component v-if="allDay.dayOfWeek !== null"
17176                                class="grid-cell-week allDay"
17177                                :allDay="allDay"
17178                                cellType="allDay_events"
17179                                :view_preference="view_preference"
17180                                @createSchedule_emitted="createSchedule_emitted"
17181                                @clickSchedule_emitted="clickSchedule_emitted"
17182                                :is_membership_cal="is_membership_cal"
17183                            ></calendar-cell-component>
17184                        </template>
17185                    </div>
17186                    <!-- timestamps and events -->
17187                    <div class="day-calendar-grid">
17188                        <template v-for="(timestamp, i) in timestamps">
17189                            <template v-for="(calendarObj, j) in calendar">
17190                                <calendar-cell-component v-if="j === 0"
17191                                    class="grid-cell-week timestamp"
17192                                    :date="calendarObj.date"
17193                                    :timestamp="i % 4 === 0 ? formatShortTime( timestamp.customFormat ) : null"
17194                                    :events="[]"
17195                                    cellType="timestamp"
17196                                    :view_preference="view_preference"
17197                                    :is_membership_cal="is_membership_cal"
17198                                    :class="{ 'borderBottomClose': i === timestamps.length - 1 }"
17199                                ></calendar-cell-component>
17200                                <calendar-cell-component v-if="j !== 0"
17201                                    class="grid-cell-week"
17202                                    :style="calendarObj.defaultDate ? { 'background-color': 'rgba( 175, 213, 240, 0.1)' } : null"
17203                                    :class="{
17204                                        'showBorder': i % 4 === 0,
17205                                        'hideBorder': i === 0 || i % 4 !== 0
17206                                    }"
17207                                    :date="calendarObj.date"
17208                                    :events="generateEventForTimestamp( timestamp.customFormat, calendarObj.events, calendarObj.date )"
17209                                    cellType="calendarObj"
17210                                    :view_preference="view_preference"
17211                                    @createSchedule_emitted="createSchedule_emitted"
17212                                    @clickSchedule_emitted="clickSchedule_emitted"
17213                                ></calendar-cell-component>
17214                            </template>
17215                        </template>
17216                    </div>
17217                </div>
17218                <!-- spinner while loading -->
17219                <div v-if="loading" class="cardBodySpinnerWrap relative active">
17220                    <div class="cardBodySpinner centeredBlock"></div>
17221                </div>
17222            </div>
17223        </div>
17224    </div>
17225</script>
vendor: 1 bytes, line 17225
17225
17226<script>
17227    /* ----- STORE ----- */
17228    store.components["calendar_grid"] = {
17229        calendar: [],
17230        loading: true,
17231        view_preference: 'Month',
17232        dropdownOpen: false,
17233        daysOfWeek: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
17234        placeholder_daysOfWeek: [],
17235        today_daysOfWeek: [],
17236        view_options: [ 'Month', 'Week', 'Day' ],
17237        calendarTitle: null,
17238        //dates and times
17239        currentDate: dayjs() || new Date(),
17240        defaultDate: dayjs() || new Date(),
17241        currentMonth: dayjs().format('MMMM'),
17242        startOfWeek: dayjs().startOf('week'), 
17243        endOfWeek: dayjs().endOf('week'),
17244        currentYear: dayjs().year(),
17245        timestamps: [],
17246        currentTime: dayjs().format('HH:mm:ss'),
17247    };
17248
17249    /* ----- COMPONENT ----- */
17250    app_content.component("calendar-grid-component", {
17251        template: "#template-calendar-grid",
17252        data() {
17253            return store.components['calendar_grid']
17254        },
17255        created(){
17256            if (this.timestamps.length === 0){
17257                this.generateTimestamps();  
17258            }
17259        },
17260        props: {
17261            client_id: String,
17262            class_string: String,
17263            style_string: String,
17264            filterOptions: Array,
17265            selectedFilterOptions: Array,
17266            schedules: Array,
17267            is_membership_cal: Boolean,
17268            load_cal: Function,
17269            handleFilterSelection: Function,
17270            createSchedule: Function,
17271            clickSchedule: Function,
17272        },
17273        methods: {
17274            createSchedule_emitted( date ){
17275                this.$emit('createSchedule', date)
17276            },
17277            clickSchedule_emitted( event ){
17278                this.$emit('clickSchedule', event )
17279            },
17280            formatShortTime( timeString ){
17281                if (!timeString){
17282                    return '' // placeholder for 11:45-12:00 block
17283                }
17284                // if (timeString === '12:00 AM'){
17285                //     return Intl.DateTimeFormat().resolvedOptions().timeZone;
17286                // }
17287                const [hours, minutes] = timeString.split(':');
17288                const AM_PM = minutes.split(' ')[1]
17289                return `${hours} ${AM_PM}`;
17290            },
17291            generateWrapStyle( type ){
17292                if (type === 'cs-calendar-wrap'){
17293                    return this.style_string === undefined ? {'width': '100%'} : this.style_string;
17294                } else if (type === 'options' && this.is_membership_cal){
17295                    return { 
17296                        'padding-top': '15px', 
17297                        'padding-right': '15px', 
17298                        'padding-left': '15px', 
17299                        'padding-bottom': '5px', 
17300                        'border-width': '0 1.5px 0 1.5px', 
17301                        'border-color': '#e2e8e8', 
17302                        'border-style': 'solid',
17303                        'overflow': 'auto',
17304                    }
17305                } else if (type === 'cs-calendar-menu-wrap' && this.is_membership_cal){
17306                    return { 
17307                        'border-width': '0 1.5px 0 1.5px', 
17308                        'border-color': '#e2e8e8', 
17309                        'border-style': 'solid' 
17310                    }
17311                }
17312            },
17313            generateEventForTimestamp( timestamp, events, date ){
17314                // do not want to display all day or multiple day events -- being handled separately
17315                let eventsArr = [];
17316                if (events.length > 0){
17317                    for (let i = 0; i < events.length; i++){
17318                        let event = events[i];
17319                        // normal event
17320                        if ((event.hours_mins_start && event.hours_mins_end) && !event.isAllDay && event.calendarOnly) {
17321                            let startTime = dayjs().set('hour', event.hours_mins_start.hours).set('minute', event.hours_mins_start.minutes);
17322                            let endTime = dayjs().set('hour', event.hours_mins_end.hours).set('minute', event.hours_mins_end.minutes).subtract(15, 'minutes');
17323
17324                            let formattedStartTime = startTime.format('h:mm A');
17325                            let formattedEndTime = endTime.format('h:mm A');
17326
17327                            let timesArray = [formattedStartTime];
17328                            while (startTime.isBefore(endTime)) {
17329                                startTime = startTime.add(15, 'minutes');
17330                                timesArray.push(startTime.format('h:mm A'));
17331                            }
17332                            if ( formattedStartTime === timestamp ){    
17333                                eventsArr.push({ event, numOfTimeBlocks: timesArray.length });
17334                            }
17335                        // recurring event
17336                        }
17336 else if ( (event.start && event.end) && (event.start !== event.end) && !event.isAllDay && !event.isMultipleDay && event.calendarOnly ){
17337                            let startTime = dayjs(event.start);
17338                            let endTime = dayjs(event.end).subtract(15, 'minutes');
17339
17340                            let formattedStartTime = startTime.format('h:mm A');
17341                            let formattedEndTime = endTime.format('h:mm A');
17342
17343                            let timesArray = [formattedStartTime];
17344
17345                            while (startTime.isBefore(endTime)) {
17346                                startTime = startTime.add(15, 'minutes');
17347                                timesArray.push(startTime.format('h:mm A'));
17348                            }
17349                            if ( formattedStartTime === timestamp ){    
17350                                eventsArr.push({ event, numOfTimeBlocks: timesArray.length });
17351                            }
17352                        // non-calendarOnly events
17353                        } else if ( (event.start && event.end) && (event.start !== event.end) && !event.calendarOnly ){
17354                            let startTime;
17355                            let endTime;
17356
17357                            if (dayjs(event.start).startOf('day').$d.toString() === dayjs(date).startOf('day').$d.toString()){
17358                                startTime = dayjs(event.start);
17359                                endTime = dayjs(event.end).subtract(15, 'minutes');
17360                            } else if (dayjs(event.end).startOf('day').$d.toString() === dayjs(date).startOf('day').$d.toString()) {
17361                                startTime = dayjs(event.end).startOf('day');
17362                                endTime = dayjs(event.end).subtract(15, 'minutes');
17363                            } else {
17364                                startTime = dayjs().startOf('day');
17365                                endTime = dayjs().endOf('day');
17366                            }
17367
17368                            let formattedStartTime = startTime.format('h:mm A');
17369                            let formattedEndTime = endTime.format('h:mm A');
17370
17371                            let timesArray = [formattedStartTime];
17372
17373                            while (startTime.isBefore(endTime)) {
17374                                startTime = startTime.add(15, 'minutes');
17375                                timesArray.push(startTime.format('h:mm A'));
17376                            }
17377                            if ( formattedStartTime === timestamp ){    
17378                                eventsArr.push({ event, numOfTimeBlocks: timesArray.length });
17379                            }
17380                        }
17381                    } 
17382                }
17383                if (eventsArr.length > 0){
17384                    return eventsArr;
17385                } else {
17386                    return [];
17387                }
17388            },
17389            changeViewPreference(){
17390                this.loading = true;
17391                //and re-load data
17392                this.$emit('load_cal', this.client_id);
17393            },
17394            calculateTitleRange(){
17395                if ( this.view_preference === 'Month' ){
17396                    this.calendarTitle = `${this.currentMonth} ${this.currentYear.toString()}`;
17397                } else if ( this.view_preference === 'Week' ){
17398                    this.calendarTitle = `${this.startOfWeek.format('MMMM')} ${this.startOfWeek.$D} - ${this.endOfWeek.format('MMMM')} ${this.endOfWeek.$D}`;
17399                } else if ( this.view_preference === 'Day' ){
17400                    this.calendarTitle = `${this.currentMonth} ${this.currentDate.$D}, ${this.currentYear}`;
17401                }
17402            },
17403            handleButtonClick( option, selectedDate ){
17404                this.loading = true;
17405                if (this.view_preference === 'Month'){
17406                    if( option === "today" ){
17407                        this.currentDate = dayjs();
17408                        this.currentMonth = this.currentDate.format('MMMM')
17409                        this.currentYear = this.currentDate.$y
17410                        this.startOfWeek = this.currentDate.startOf('week');
17411                        this.endOfWeek = this.currentDate.endOf('week');
17412                    }else if( option === "next" ){
17413                        this.currentDate = this.currentDate.add(1, 'month').startOf('month');
17414                        this.currentMonth = this.currentDate.format('MMMM')
17415                        this.currentYear = this.currentDate.$y
17416                        this.startOfWeek = this.currentDate.startOf('week');
17417                        this.endOfWeek = this.currentDate.endOf('week');
17418
17419                    }else if( option === "previous" ){
17420                        this.currentDate = this.currentDate.subtract(1, 'month').startOf('month');
17421                        this.currentMonth = this.currentDate.format('MMMM')
17422                        this.currentYear = this.currentDate.$y
17423                        this.startOfWeek = this.currentDate.startOf('week');
17424                        this.endOfWeek = this.currentDate.endOf('week');
17425                    }else if ( option === "date-picker" ){
17426                        let { month, year } = selectedDate;
17427                        this.currentDate = dayjs(new Date(year, month, 1));
17428                        this.currentMonth = this.currentDate.format('MMMM')
17429                        this.currentYear = this.currentDate.$y
17430                        this.startOfWeek = this.currentDate.startOf('week');
17431                        this.endOfWeek = this.currentDate.endOf('week');
17432                    }
17433                } else if (this.view_preference === 'Week'){
17434                    if( option === "today" ){
17435                        this.currentDate = dayjs();
17436                        this.currentMonth = this.currentDate.format('MMMM')
17437                        this.currentYear = this.currentDate.$y
17438                        this.startOfWeek = this.currentDate.startOf('week');
17439                        this.endOfWeek = this.currentDate.endOf('week');
17440                    }else if( option === "next" ){
17441                        this.currentDate = this.currentDate.add(1, 'week');
17442                        this.currentMonth = this.currentDate.format('MMMM')
17443                        this.currentYear = this.currentDate.$y
17444                        this.startOfWeek = this.currentDate.startOf('week');
17445                        this.endOfWeek = this.currentDate.endOf('week');
17446                    }else if( option === "previous" ){
17447                        this.currentDate = this.currentDate.subtract(1, 'week');
17448                        this.currentMonth = this.currentDate.format('MMMM')
17449                        this.currentYear = this.currentDate.$y
17450                        this.startOfWeek = this.currentDate.startOf('week');
17451                        this.endOfWeek = this.currentDate.endOf('week');
17452                    }else if ( option === "date-picker" ){
17453                        let { month, year } = selectedDate;
17454                        this.currentDate = dayjs(new Date(year, month, 1));
17455                        this.currentMonth = this.currentDate.format('MMMM')
17456                        this.currentYear = this.currentDate.$y
17457                        this.startOfWeek = this.currentDate.startOf('week');
17458                        this.endOfWeek = this.currentDate.endOf('week');
17459                    }
17460                } else if (this.view_preference === 'Day'){
17461                    if( option === "today" ){
17462                        this.currentDate = dayjs();
17463                        this.currentMonth = this.currentDate.format('MMMM')
17464                        this.currentYear = this.currentDate.$y
17465                        this.startOfWeek = this.currentDate.startOf('week');
17466                        this.endOfWeek = this.currentDate.endOf('week');
17467                    }else if( option === "next" ){
17468                        this.currentDate = this.currentDate.add(1, 'day');
17469                        this.currentMonth = this.currentDate.format('MMMM')
17470                        this.currentYear = this.currentDate.$y
17471                        this.startOfWeek = this.currentDate.startOf('week');
17472                        this.endOfWeek = this.currentDate.endOf('week');
17473                    }else if( option === "previous" ){
17474                        this.currentDate = this.currentDate.subtract(1, 'day');
17475                        this.currentMonth = this.currentDate.format('MMMM')
17476                        this.currentYear = this.currentDate.$y
17477                        this.startOfWeek = this.currentDate.startOf('week');
17478                        this.endOfWeek = this.currentDate.endOf('week');
17479                    }else if ( option === "date-picker" ){
17480                        let { month, year } = selectedDate;
17481                        this.currentDate = dayjs(new Date(year, month, 1));
17482                        this.currentMonth = this.currentDate.format('MMMM')
17483                        this.currentYear = this.currentDate.$y
17484                        this.startOfWeek = this.currentDate.startOf('week');
17485                        this.endOfWeek = this.currentDate.endOf('week');
17486                    }
17487                }
17488                //and re-load data
17489                this.$emit('load_cal', this.client_id);
17490            },
17491            /* ----- date methods ----- */
17492            generateTimestamps(){
17493                const startTime = dayjs().startOf('day');
17494                const endTime = dayjs().endOf('day');
17495                let currentTime = startTime;
17496                while (currentTime.isBefore(endTime)) {
17497                    this.timestamps.push({
17498                        customFormat: currentTime.format('h:mm A'),
17499                        militaryFormat: currentTime.format('HH:mm:ss'),
17500                    });
17501                    currentTime = currentTime.add(15, 'minutes');
17502                }
17503                this.timestamps.push({
17504                    customFormat: null,
17505                    militaryFormat: null,
17506                })
17507            },
17508            generateCalendar( ){
17509                console.log("generate calendar")
17510                this.loading = true;
17511                if ( this.view_preference === 'Month'){
17512                    // current month start date, end date, total days calculation
17513                    const start = this.currentDate.startOf('month');
17514                    const end = start.endOf('month');
17515                    const totalDays = end.diff(start, 'day') + 1;
17516                    // number of calendar days to add before and after current month days
17517                    const spaceBefore = start.day();
17518                    const spaceAfter = 42 - (spaceBefore + totalDays);
17519                    // generate number of days for grid cell of 42
17520                    this.calendar = [...Array(spaceBefore)].map((_, i) => {
17521                        const prevMonthDate = start.subtract(spaceBefore - i, 'day');
17522                        return {
17523                            date: prevMonthDate,
17524                            day: prevMonthDate.$D,
17525                            class: 'prevMonthDate',
17526                            events: [],
17527                            defaultDate: false,
17528                        }
17529                    }).concat([...Array(totalDays)].map((_, i) => {
17530                        const currentMonthDate = start.add(i, 'day');
17531                        if (this.defaultDate.startOf('day').$d.toString() === currentMonthDate.$d.toString()){
17532                            return {
17533                                date: currentMonthDate,
17534                                day: currentMonthDate.$D === 1 ? `${currentMonthDate.format('MMM')} ${currentMonthDate.$D}` : currentMonthDate.$D,
17535                                class: 'currentMonthDate',
17536                                events: [],
17537                                defaultDate: true,
17538                            }
17539                        }else {
17540                            return {
17541                                date: currentMonthDate,
17542                                day: currentMonthDate.$D === 1 ? `${currentMonthDate.format('MMM')} ${currentMonthDate.$D}` : currentMonthDate.$D,
17543                                class: 'currentMonthDate',
17544                                events: [],
17545                                defaultDate: false,
17546                            }
17547                        }
17548                    })).concat([...Array(spaceAfter)].map((_, i) => {
17549                        const nextMonthDate = end.add(i + 1, 'day');
17550                        return {
17551                            date: nextMonthDate,
17552                            day: nextMonthDate.$D === 1 ? `${nextMonthDate.format('MMM')} ${nextMonthDate.$D}` : nextMonthDate.$D,
17553                            class: 'nextMonthDate',
17554                            events: [],
17555                            defaultDate: false,
17556                        }
17557                    }))
17558                } else if ( this.view_preference === 'Week' ){
17559                    console.log("generate week A")
17560                    // start and end of current week
17561                    const startOfWeek = this.currentDate.startOf('week');
17562                    const endOfWeek = this.currentDate.endOf('week');
17563
17564                    const weekDays = [{ date: null, day: null, events: [] }];
17565                    let currentDay = startOfWeek;
17566                    while (currentDay.isBefore(endOfWeek) || currentDay.isSame(endOfWeek, 'day')) {
17567                        weekDays.push(currentDay);
17568                        currentDay = currentDay.add(1, 'day');
17569                    }
17570                    this.placeholder_daysOfWeek = [null, 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
17571                    this.placeholder_daysOfWeek = this.placeholder_daysOfWeek.map((dayOfTheWeek, i) => {
17572                        if (JSON.stringify(this.defaultDate.startOf('day').$d) === JSON.stringify(weekDays[i].$d)){
17573                            return {
17574                                dayOfWeek: dayOfTheWeek,
17575                                day: weekDays[i].$D,
17576                                date: weekDays[i],
17577                                events: [],
17578                                defaultDate: true,
17579                            }
17580                        } else {
17581                            return {
17582                                dayOfWeek: dayOfTheWeek,
17583                                day: weekDays[i].$D,
17584                                date: weekDays[i],
17585                                events: [],
17586                                defaultDate: false,
17587                            }
17588                        }
17589                    })
17590                    console.log("generate week B")
17591                    this.mapAllDayEvents();
17592                    this.calendar = weekDays.map(date => {
17593                        if(JSON.stringify(this.defaultDate.startOf('day').$d) === JSON.stringify(date.$d)){
17594                            return {
17595                                date: date,
17596                                events: [],
17597                                defaultDate: true,
17598                            }
17599                        } else {
17600                            return {
17601                                date: date,
17602                                events: [],
17603                                defaultDate: false,
17604                            }   
17605                        }
17606                    })
17607                    console.log("generate week C")
17608                } else if ( this.view_preference === 'Day' ){
17609                    this.today_daysOfWeek = [null, this.daysOfWeek[this.currentDate.day()]]
17610                    this.today_daysOfWeek = this.today_daysOfWeek.map((dayOfTheWeek, i) => {
17611                        if (i === 1 && this.defaultDate.startOf('day').$d.toString() === this.currentDate.startOf('day').$d.toString()){
17612                            return {
17613                                dayOfWeek: dayOfTheWeek,
17614                                day: dayOfTheWeek !== null ? this.currentDate.$D : null,
17615                                date: dayOfTheWeek !== null ? this.currentDate : null,
17616                                events: [],
17617                                defaultDate: true,
17618                            }
17619                        } else {
17620                            return {
17621                                dayOfWeek: dayOfTheWeek,
17621
17622                                day: dayOfTheWeek !== null ? this.currentDate.$D : null,
17623                                date: dayOfTheWeek !== null ? this.currentDate : null,
17624                                events: [],
17625                                defaultDate: false,
17626                            }
17627                        }
17628                    })
17629                    this.mapAllDayEvents();
17630                    this.calendar = [{ date: null, day: null, events: [] }, this.currentDate ].map((date, i) => {
17631                        if(i === 1 && this.defaultDate.startOf('day').$d.toString() === date.startOf('day').$d.toString()){
17632                            return {
17633                                date: date,
17634                                events: [],
17635                                defaultDate: true,
17636                            }
17637                        } else {
17638                            return {
17639                                date: date,
17640                                events: [],
17641                                defaultDate: false,
17642                            }
17643                        }
17644                    })
17645                }
17646                // map events onto calendar days
17647                this.mapCalendarEvents_v2();
17648            },
17649            // mapCalendarEvents(){
17650            //     let multipleDaySchedules = [];
17651            //     this.calendar.map(day => {
17652            //         let calendarDay;
17653            //         if ( day.date !== null ){
17654            //             calendarDay = dayjs(day.date.$d).format('MMMM DD, YYYY');
17655            //         }
17656            //         this.schedules.forEach(schedule => {
17657            //             let scheduleStart = dayjs( schedule.start ).format('MMMM DD, YYYY');
17658            //             let scheduleEnd = dayjs( schedule.end ).format('MMMM DD, YYYY');
17659            //             let scheduleOnDay = scheduleStart === calendarDay && scheduleStart === scheduleEnd;
17660            //             let scheduleOnDay_isAllDay = scheduleStart === calendarDay && schedule.isAllDay;
17661            //             // check to see if there are any multiple event days
17662            //             if ( multipleDaySchedules.length > 0 ){
17663            //                 multipleDaySchedules.forEach((multipleDaySchedule, i) => {
17664            //                     if ( multipleDaySchedule.daysRemaining > 0 && calendarDay === multipleDaySchedule.scheduleStart.format('MMMM DD, YYYY')){
17665            //                         day.events.push(multipleDaySchedule.schedule)
17666            //                         multipleDaySchedule.scheduleStart = multipleDaySchedule.scheduleStart.add(1, 'day')
17667            //                         multipleDaySchedule.daysRemaining--;
17668            //                     } else if ( multipleDaySchedule.daysRemaining === 0 ){
17669            //                         multipleDaySchedules.splice(i, 1)
17670            //                     }
17671            //                 })
17672            //             }
17673            //             // there is an event or all day event on this day
17674            //             if ( scheduleOnDay || scheduleOnDay_isAllDay ){
17675            //                 day.events.push(schedule)
17676            //             // there is a multiple day event that starts on this day
17677            //             } else if ( scheduleStart === calendarDay && scheduleStart !== scheduleEnd && !schedule.isAllDay ){
17678            //                 multipleDaySchedules.push({
17679            //                     schedule: schedule,
17680            //                     scheduleStart: dayjs(scheduleStart, { format: 'MMMM DD, YYYY' }).add(1, 'day'),
17681            //                     daysRemaining: dayjs(scheduleEnd, { format: 'MMMM DD, YYYY' }).diff(dayjs(scheduleStart, { format: 'MMMM DD, YYYY' }), 'day') - 1
17682            //                 })
17683            //                 schedule.isMultipleDay = true;
17684            //                 day.events.push(schedule);
17685            //             }
17686            //         })
17687            //         return day;
17688            //     })
17689            //     console.log('this is final mapping: ', this.calendar)
17690            //     this.loading = false;
17691            // },
17692            mapCalendarEvents_v2(){
17693                console.log("map calendar events v2");
17694                let previousDayEventOrder = [];
17695                this.calendar.map((day, dayIndex) => {
17696                    let calendarDay;
17697                    if ( day.date !== null ){
17698                        calendarDay = dayjs(day.date.$d)
17699                    }
17700                    this.schedules.forEach(schedule => {
17701                        let scheduleStart = dayjs( schedule.start )
17702                        let scheduleEnd = dayjs( schedule.end )
17703                        let scheduleOnDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') === scheduleEnd.format('MMMM DD, YYYY');
17704                        let scheduleOnDay_isAllDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && schedule.isAllDay;
17705                        // event falls on current day OR it's an all day event
17706                        // added scheduleOnDay && scheduleOnDay_isAllDay 1/25/24 for regatta subevents start start and end on the same day
17707                        if ( scheduleOnDay && !scheduleOnDay_isAllDay || scheduleOnDay && scheduleOnDay_isAllDay ){
17708                            day.events.push(schedule)
17709                        } 
17710                        // it's the first day of a multiple day event
17711                        // these checks are not thorough enough
17712                        else if (scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') !== scheduleEnd.format('MMMM DD, YYYY')){
17713                            schedule.isMultipleDay = true;
17714                            day.events.push(schedule)
17715                        }
17716                        // it's a multiple day event and the current date falls between the start and end date
17717                        else if ( scheduleStart.format('MMMM DD, YYYY') !== calendarDay.format('MMMM DD, YYYY') && (calendarDay.isAfter(scheduleStart) && calendarDay.isBefore(scheduleEnd)) ){
17718                            schedule.isMultipleDay = true;
17719                            day.events.push(schedule)
17720                        }  
17721                    })
17722
17723                    /* ui stacking logic for month view */
17724                    if (this.view_preference === 'Month'){
17725                        if ( dayIndex !== 0 ){
17726                            let currentDayEventOrder;
17727                            let currentDayIndexOrder = {};
17728                            let notMatchingEvents = [];
17729                            let matchingEvents = [];
17730                            for (let i = 0; i < day.events.length; i++){
17731                                let currentDayEventId = day.events[i].id;
17732                                let previousDayIndex = previousDayEventOrder?.indexOf(currentDayEventId);
17733                                if (previousDayIndex === -1){
17734                                    notMatchingEvents.push(day.events[i])
17735                                } else if (previousDayIndex !== -1) {
17736                                    currentDayIndexOrder[previousDayIndex] = day.events[i];
17737                                    matchingEvents.push(day.events[i]);
17738                                }
17739                            }
17740
17741                            let missingIndexes = [];
17742                            for (let j = 0; j < 3; j++ ){
17743                                if (Object.keys(currentDayIndexOrder).indexOf(j.toString()) === -1 ){
17744                                    missingIndexes.push(j)
17745                                }
17746                            }
17747                   
17748                            let placeholder = { 
17749                                bgColor: '',
17750                                calendarId: '',
17751                                calendarOnly: '',
17752                                category: '',
17753                                className: '',
17754                                color: '',
17755                                end: '',
17756                                id: 'placeholder',
17757                                raw: {dot_color: ''},
17758                                start: '',
17759                                title: 'placeholder',
17760                            };
17761
17762                            for (let k = 0; k < missingIndexes.length; k++){
17763                                let missingIndexValue = missingIndexes[k];
17764                                currentDayIndexOrder[missingIndexValue] = notMatchingEvents[k] || placeholder;
17765                            }
17766
17767                            let remainingIndexes = day.events.length - 3;
17768                            let calculateRemainingEvents = matchingEvents.length > 3 ? matchingEvents.concat(notMatchingEvents).splice(3) : [...notMatchingEvents].splice(missingIndexes.length);
17769
17770                            if (remainingIndexes > 0){
17771                                for (let l = 3; l < 3 + remainingIndexes; l++){
17772                                    let targetIndex = matchingEvents.length > 3 ? l - 3 : l - 3;
17773                                    //added placeholder for white lake yacht club bugs
17774                                    currentDayIndexOrder[l] = calculateRemainingEvents[targetIndex] || placeholder;
17775                                }
17776                            }
17777
17778                            const sortedObj = Object.fromEntries(
17779                                Object.entries(currentDayIndexOrder).sort(([keyA], [keyB]) => parseInt(keyA) - parseInt(keyB))
17780                            );
17781                            currentDayEventOrder = Object.values(sortedObj);
17782                            //set order of previous day events using event ids
17783                            previousDayEventOrder = currentDayEventOrder.map(event =>
17783 { return event.id });
17784                            //set current day events to newly sorted order
17785                            day.events = currentDayEventOrder.filter((event, i, self) => event.id === 'placeholder' || (event.id !== 'placeholder' && i === self.findIndex(e => e.id === event.id)));
17786                        }
17787                    }
17788
17789                    return day;
17790                })
17791                this.loading = false;
17792            },
17793            mapAllDayEvents(){
17794                let daysOfWeekArr;
17795                if (this.view_preference === 'Week'){
17796                    daysOfWeekArr = this.placeholder_daysOfWeek;
17797                } else if (this.view_preference === 'Day'){
17798                    daysOfWeekArr = this.today_daysOfWeek;
17799                }
17800                let multipleDaySchedules = [];
17801                daysOfWeekArr = daysOfWeekArr.map((day, i) => {
17802                    this.schedules.forEach((schedule, i) => {
17803                        if ( (i !== 0 && this.view_preference === 'Day' || i !== 0 && this.view_preference === 'Week') && ( schedule.calendarOnly || schedule.category === 'allday') ){
17804                            let calendarDay = dayjs(day.date)
17805                            let scheduleStart = dayjs( schedule.start )
17806                            let scheduleEnd = dayjs( schedule.end )
17807                            let scheduleOnDay_isAllDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && schedule.isAllDay;
17808                            // it's an all day event
17809                            if ( scheduleOnDay_isAllDay ){
17810                                day.events.push(schedule)
17811                            } 
17812                            // it's the first day of a multiple day event
17813                            else if (scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') !== scheduleEnd.format('MMMM DD, YYYY')){
17814                                schedule.isMultipleDay = true;
17815                                day.events.push(schedule)
17816                            }
17817                            // it's a multiple day event and the current date falls between the start and end date
17818                            else if ( scheduleStart.format('MMMM DD, YYYY') !== calendarDay.format('MMMM DD, YYYY') && (calendarDay.isAfter(scheduleStart) && calendarDay.isBefore(scheduleEnd)) ){
17819                                schedule.isMultipleDay = true;
17820                                day.events.push(schedule)
17821                            }
17822                        } 
17823                    })
17824                    return day;
17825                })
17826                if (this.view_preference === 'Week'){
17827                    this.placeholder_daysOfWeek = daysOfWeekArr;
17828                } else if (this.view_preference === 'Day'){
17829                    this.today_daysOfWeek = daysOfWeekArr;
17830                }
17831            },
17832        },
17833    })
17834</script>
17834    
17834<script type="text/html" id="template-calendar-cell">
17835    <div @click="$emit('createSchedule_emitted', date)">
17836        <!-- cell for month view -->
17837        <template v-if="view_preference === 'Month'">
17838            <div class="cell-content" :class="cellType">
17839                <div v-if="day" class="cell-day" :class="cellType" :style="checkDefaultDate( day, defaultDate, 'month' )">{{ day }}</div>
17840                <!-- all events -->
17841                <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-month" ref="cell" @click.stop>
17842                    <template v-for="(event, index) in events">
17843                        <span
17844                            v-if="index <= 2"
17845                            :class="{
17846                                'cell-event-month': event.id !== 'placeholder',
17847                                'transparent-event': event.id === 'placeholder'
17848                            }"
17849                            :style="generateEventStyleForMonth( event, 'event_body', date, monthIndex )"
17850                            @click="handleScheduleClickForMonth( event )"
17851                        ><div :style="generateEventStyleForMonth( event, 'status_dot', '', '', month_class )"></div><div class="event-title" :style="generateFontSize('eventTitle', event)">{{ generateEventTitle( event ) }}</div></span>
17852                        <span v-if="index >= 3 && index === events.length - 1" class="overflow-cell-events" ref="overflow-cell" @click="toggleOverflowView( date, events, $event )">
17853                            <div :style="[ { display: 'flex' }, generateFontSize('eventTitle') ]" class="event-title">{{ index - 2 }} more</div>
17854                        </span>
17855                    </template>
17856                </div>
17857            </div>
17858        </template>
17859        <!-- cell for week view -->
17860        <template v-if="view_preference === 'Week'">
17861            <div class="cell-content-week" :class="cellType">
17862                <div v-if="day" :class="{ 'week-cellDay-container' : day.dayOfWeek !== null }">
17863                    <div class="cell-day numberHeader" :class="cellType" :style="[{ 'font-size': '25px', 'font-weight': '400' }, checkDefaultDate(day, defaultDate, 'week_header')]">{{ day.day }}</div>
17864                    <div class="cell-day dayHeader" :class="cellType">{{ day.dayOfWeek !== null ? day.dayOfWeek.toUpperCase() : null }}</div>
17865                </div>
17866                <div v-if="timestamp" class="cell-day" :class="cellType">
17867                    <div class="timestamp"  :style="generateFontSize('timestamp')">{{ timestamp }}</div>
17868                </div>
17869                <!-- all day events -->
17870                <div v-if="allDay">
17871                    <div v-if="allDay.dayOfWeek === null" class="allDay_events" :class="cellType" :style="generateFontSize('allDay')">All Day</div>
17872                    <div class="cell-events-month">
17873                        <template v-if="allDay.dayOfWeek !== null" v-for="event in allDay.events">
17874                            <span
17875                                :style="generateEventStyleForWeek( event, 'allDay_event_body' )"
17876                                class="allDay-cell-event"
17877                                @click="$emit('clickSchedule_emitted', event)"
17878                            >{{ generateEventTitle( event ) }}</span>
17879                        </template>
17880                    </div>
17881                </div>
17882                 <!-- all events -->
17883                <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-week" @click.stop>
17884                    <template v-for="(event, index) in events">
17885                        <span
17886                            class="cell-event-week"
17887                            :style="generateEventStyleForWeek( event, 'event_body' )"
17888                            @click="$emit('clickSchedule_emitted', event.event)"
17889                        ><div></div>{{ generateEventTitle( event.event ) }}</span>
17890                    </template>
17891                </div>
17892            </div>
17893        </template>
17894        <!-- cell for day view -->
17895        <template v-if="view_preference === 'Day'">
17896            <div class="cell-content-week" :class="cellType">
17897                <div v-if="day" :class="{ 'day-cellDay-container' : day.dayOfWeek !== null }">
17898                    <div class="cell-day numberHeader" :class="cellType" :style="[{ 'font-size': '25px', 'font-weight': '400' }, checkDefaultDate(day, defaultDate, 'day_header')]">{{ day.day }}</div>
17899                    <div class="cell-day dayHeader" :class="cellType">{{ day.dayOfWeek !== null ? day.dayOfWeek.toUpperCase() : null }}</div>
17900                </div>
17901                <div v-if="timestamp" class="cell-day" :class="cellType">
17902                    <div class="timestamp" :style="generateFontSize('timestamp')">{{ timestamp }}</div>
17903                </div>
17904                <!-- all day events -->
17905                <div v-if="allDay">
17906                    <div v-if="allDay.dayOfWeek === null" class="allDay_events" :class="cellType" :style="generateFontSize('allDay')">All Day</div>
17907                    <div class="cell-events-month">
17908                        <template v-if="allDay.dayOfWeek !== null" v-for="event in allDay.events">
17909                            <span
17910                                :style="generateEventStyleForWeek( event, 'allDay_event_body' )"
17911                                class="allDay-cell-event"
17912                                @click="$emit('clickSchedule_emitted', event)"
17913                            >{{ generateEventTitle( event ) }}</span>
17914                        </template>
17915                    </div>
17916                </div>
17917                <!-- all events -->
17918                <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-week" @click.stop>
17919                    <template v-for="(event, index) in events">
17920                        <span
17921                            class="cell-event-week"
17922                            :style="generateEventStyleForWeek( event, 'event_body' )"
17923                            @click="$emit('clickSchedule_emitted', event.event )"
17924                        ><div></div>{{ generateEventTitle( event.event ) }}</span>
17925                    </template>
17926                </div>
17927            </div>
17928        </template>
17929    </div>
17930</script>
17930
17931
17932<script>
17933    /* ----- STORE ----- */
17934    store.components["calendar_cell"] = {
17935        cellWidth: this.getCellWidth,
17936        foo: null,
17937    };
17938    /* ----- COMPONENT ----- */
17939    app_content.component("calendar-cell-component", {
17940        template: "#template-calendar-cell",
17941        data() {
17942            return store.components['calendar_cell']
17943        },
17944        mounted(){
17945            window.addEventListener('resize', this.handleResize);
17946            this.$nextTick(() => {
17947                this.handleResize();
17948            });
17949        },
17950        beforeDestroy(){
17951            window.removeEventListener('resize', this.handleResize);
17952        },
17953        props: {
17954            client_id: String,
17955            date: Object,
17956            defaultDate: Boolean,
17957            day: {
17958                type: [String, Number, Object],
17959                required: false,
17960            },
17961            allDay: Object,
17962            timestamp: String,
17963            currentTime: String,
17964            events: Array,
17965            cellType: String,
17966            view_preference: String,
17967            monthIndex: Number,
17968            month_class: String,
17969            is_membership_cal: Boolean,
17970            cell_emitted: Function,
17971            createSchedule_emitted: Function,
17972            clickSchedule_emitted: Function,
17973        },
17974        methods: {
17975            handleResize(){
17976                this.cellWidth = this.getCellWidth();
17977            },
17978            getCellWidth(){
17979                const cell = this.$refs.cell;
17980                // Get the computed width of the cell
17981                if (cell){
17982                    this.cellWidth = cell.getBoundingClientRect().width;
17983                    return cell.getBoundingClientRect().width;
17984                }
17985            },
17986            checkDefaultDate( day, defaultDate, type ){
17987                if( defaultDate ){
17988                    if ( type === 'month' ){
17989                        if ( day.length > 2){
17990                            return {
17991                                'padding': '2.5px',
17992                                'background-color': '#4a56f8',
17993                                'color': '#fff',
17994                                'min-width': 'fit-content',
17995                                'max-width': 'fit-content',
17996                                'min-height': 'fit-content',
17997                                'max-height': 'fit-content',
17998                            }
17999                        } else {
18000                            return {
18001                                'background-color': '#4a56f8',
18002                                'color': '#fff',
18003                                'min-width': '22px',
18004                                'max-width': '22px',
18005                                'min-height': '22px',
18006                                'max-height': '22px',
18007                            }
18008                        }
18009                    } else if ( type === 'week_header' || type === 'day_header' ){
18010                        return {
18011                            'font-weight': '575',
18012                            // 'color': '#4a56f8',
18013                        }
18014                    }
18015                } else if ( !defaultDate ){
18016                    if ( type === 'month' ){
18017                        if ( day.length > 2){
18018                            return {
18019                                'padding': '2.5px',
18020                                'min-width': 'fit-content',
18021                                'max-width': 'fit-content',
18022                                'min-height': 'fit-content',
18023                                'max-height': 'fit-content',
18024                            }
18025                        } else {
18026                            return {
18027                                'min-width': '22px',
18028                                'max-width': '22px',
18029                                'min-height': '22px',
18030                                'max-height': '22px',
18031                            }
18032                        }
18033                    }
18034                }
18035            },
18036            generateEventTitle( eventObject ){
18037                if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) {
18038                    return eventObject.raw.nice_time !== undefined ? `${eventObject.raw.nice_time} ${eventObject.title}` : `${eventObject.title}`;
18039                } else if ( eventObject.isAllDay || eventObject.isMultipleDay ) {
18040                    return eventObject.title || 'Not Provided';
18041                }
18042            },
18043            generateFontSize( type, eventObject ){
18044                if (this.is_membership_cal){
18045                    if (type === 'timestamp') {
18046                        return {
18047                            'font-size': '12px', 
18048                            'font-weight': '300',
18049                        }
18050                    } else if (type === 'allDay'){
18051                        return {
18052                            'font-size': '13px',
18053                        }
18054                    } else if (type === 'eventTitle'){
18055                        if (eventObject?.id === 'placeholder'){
18056                            return {
18057                                'color': 'transparent',
18058                                'font-size': '13px',
18059                            }
18060                        } 
18061                        return {
18062                            'font-size': '13px',
18063                        }
18064                    }
18065                } else {
18066                    if (type === 'timestamp') {
18067                        return {
18068                            'font-size': '10px', 
18069                            'font-weight': '300',
18070                        }
18071                    } else if (type === 'allDay'){
18072                        return {
18073                            'font-size': '11px',
18074                        }
18075                    }else if (type === 'eventTitle'){
18076                        if (eventObject?.id === 'placeholder'){
18077                            return {
18078                                'color': 'transparent',
18079                                'font-size': '12.5px',
18080                            }
18081                        } 
18082                        return {
18083                            'font-size': '12.5px',
18084                        }
18085                    }
18086                }
18087            },
18088            generateEventStyleForMonth( eventObject, type, date = null, monthIndex = null, month_class = null ){
18089                let styleObject = {};
18090                const dotColor = eventObject.raw.dot_color;
18091                const bgColor = eventObject.bgColor || '#e8edf1';
18092                const color = /* eventObject.color || */ this.get_contrast_color( bgColor ) || 'inherit';
18093                if ( type === 'status_dot' ){
18094                    if ( eventObject.id === 'placeholder' ){
18095                        styleObject = {
18096                            'color': 'trasparent',
18097                        }
18098                    } else if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) {
18099                        if ( month_class === 'currentMonthDate' ){
18100                            styleObject = {
18101                                'min-width': '2.2px',
18102                                'max-width': '2.2px',
18103                                'min-height': '2.2px',
18104                                'max-height': '2.2px',
18105                                'border': `2.2px solid ${dotColor || '#333'}`,
18106                                'border-radius': '50%',
18107                                'background-color': `${dotColor || '#333'}`,
18108                                'margin': '0 5px 0 5px',
18109                            }
18110                        } else if ( month_class === 'prevMonthDate' || month_class === 'nextMonthDate' ){
18111                            styleObject = {
18112                                'min-width': '2.2px',
18113                                'max-width': '2.2px',
18114                                'min-height': '2.2px',
18115                                'max-height': '2.2px',
18116                                'border': `2.2px solid rgb(232, 237, 241)`,
18117                                'border-radius': '50%',
18118                                'background-color': `rgb(232, 237, 241)`,
18119                                'margin': '0 5px 0 5px',
18120                            }
18121                        }
18122                    } else if ( eventObject.isAllDay || eventObject.isMultipleDay ){
18123                        styleObject = {
18124                            'margin-right': '3.5px',
18125                        }
18126                    }
18127                } else if ( type === 'event_body' ){
18128                    if ( eventObject.isAllDay && !eventObject.isMultipleDay ) {
18129                        styleObject = {
18130                            'background-color': bgColor,
18131                            'color': color,
18132                        };
18133                    } else if ( eventObject.isMultipleDay ){
18134                        let calendarDay = dayjs(date.$d).format('MMMM DD, YYYY');
18135                        let scheduleStart = dayjs( eventObject.start ).format('MMMM DD, YYYY');
18136                        let scheduleEnd = dayjs( eventObject.end ).format('MMMM DD, YYYY');
18137                        let isOneDay = ((dayjs( eventObject.start ).format('HH:mm:ss') === dayjs( eventObject.end ).format('HH:mm:ss')) && (dayjs( eventObject.start ).format('HH:mm:ss') === dayjs( eventObject.start ).startOf('day').format('HH:mm:ss'))) && dayjs( eventObject.start ).add(1, 'day').format('MMMM DD, YYYY') === scheduleEnd;
18138                        let endsAtMidnight = dayjs( eventObject.end ).format('HH:mm:ss') === dayjs().startOf('day').format('HH:mm:ss');
18139                        if (calendarDay === scheduleStart || monthIndex % 7 === 0 ){
18140                            let remainingDays = dayjs(scheduleEnd, { format: 'MMMM DD, YYYY' }).diff(dayjs(calendarDay, { format: 'MMMM DD, YYYY' }), 'day');
18141                            //handle non-calendarOnly events since end time is different than calendarOnly events
18142                            if (!eventObject.calendarOnly && !isOneDay && !endsAtMidnight ){
18143                                remainingDays += 1;
18144                            }
18145                            if (this.cellWidth === undefined){
18146                                styleObject = {
18147                                    'background-color': bgColor,
18148                                    'color': color,
18149                                    'z-index': '5',
18150                                    'width' : `${remainingDays*this.getCellWidth()}px`
18151                                };
18152                            } else {
18153                                styleObject = {
18154                                    'background-color': bgColor,
18155                                    'color': color,
18156                                    'z-index': '5',
18157                                    'width': `${remainingDays*this.cellWidth}px`
18158                                };
18159                            }
18160                        }
18161                    }
18162                }
18163                return styleObject
18164            },
18165            generateEventStyleForWeek( eventObject, type ){
18166                const event = eventObject.event || eventObject;
18167                const numOfTimeBlocks = eventObject.numOfTimeBlocks;
18168
18169                let styleObject = {};
18170                const dotColor = event?.raw.dot_color;
18171                const bgColor = event?.bgColor || '#e8edf1';
18172                const color = /* event?.color */ this.get_contrast_color( bgColor ) || 'inherit';
18173
18174                if ( type === 'event_body' ){
18175                    if ( event ) {
18176                        styleObject = {
18177                            'background-color': dotColor || bgColor,
18178                            'color': color,
18179                            'width': '50%',
18180                            'border-width': '1px 1px 1px 1.5px',
18181                            'border-left-color': 'black',
18182                            'border-top-color':'white',
18183                            'border-right-color':'transparent',
18184                            'border-bottom-color': 'white',
18185                            'border-style': 'solid',
18186                            'border-radius': '2px',
18187                            'opacity': '85%',
18188                            'z-index': '5',
18189                            'height': `${(numOfTimeBlocks*12) - 5}px`, /* (number of 15 min increments * .placeholder-calendar-grid > grid-auto-rows) - .cell-event-week > padding(top) */
18190                        };
18191                    }
18192                } else if ( type === 'allDay_event_body' ){
18193                    if ( event ) {
18194                        styleObject = {
18195                            'background-color': dotColor || bgColor,
18196                            'color': color,
18197                        };
18198                    }
18199                }
18200                return styleObject
18201            },
18202            toggleOverflowView( date, eventsArr, event ){
18203                if (store.overlays.overflow_overlay){
18204                    store.overlays.overflow_overlay = false;
18205                } else {
18206                    store.components.overflow_overlay.cellPosition = {
18207                        top: (event.clientY + window.scrollY)-(230/2), //230 overlay height
18208                        left: (event.clientX + window.scrollX)-(230/2), //230 overlay width
18209                    }
18210                    if (this.is_membership_cal){
18211                        store.components.overflow_overlay.is_membership_cal = true;
18212                    }
18213                    store.components.overflow_overlay.client_id = this.client_id;
18214                    store.components.overflow_overlay.date = date;
18215                    store.components.overflow_overlay.events = eventsArr;
18216                    store.overlays.overflow_overlay = true;
18217                }
18218            },
18219            handleScheduleClickForMonth( event ){
18220                if (event.id !== 'placeholder') {
18221                    this.$emit('clickSchedule_emitted', event);
18222                } else {
18223                    this.$emit('createSchedule_emitted', this.date);
18224                }
18225            },
18226            //methods to determine font color in contrast to bg color
18227            calculate_luminance(color) {
18228                const hex = color.substring(1); //remove #
18229                const rgb = parseInt(hex, 16); //convert to RGB
18230                const r = (rgb >> 16) & 0xff; //extract the red channel
18231                const g = (rgb >>  8) & 0xff; //extract the green channel
18232                const b = (rgb >>  0) & 0xff; //extract the blue channel
18233                //convert RGB to linear values
18234                const sR = r / 255;
18235                const sG = g / 255;
18236                const sB = b / 255;
18237                //calculate luminance
18238                const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
18239                return luminance;
18240            },
18241            get_contrast_color(background) {
18242                const backgroundLuminance = this.calculate_luminance(background);
18243                const fontColor = backgroundLuminance > 0.5 ? '#000000' : '#ffffff';
18244                return fontColor;
18245            }
18246        },
18247    })
18248</script>
18248    
18248<script type="text/html" id="template-overflow-overlay">
18249    <div v-if="global.overlays.overflow_overlay" id="overlay_overflow_overlay" :style="generateStyle()" class="calendar-overlay active" @click.self="global.overlays.overflow_overlay=false;">
18250        <div class="calendarOverlayCard">
18251            <div class="overlay-events-contain">
18252                <div v-if="me.date" class="overflow-date-contain">
18253                    <span class="overflow-dateOfWeek">{{ me.date.format('ddd') }}</span>
18254                    <span class="overflow-day">{{ me.date.$D }}</span>
18255                </div>
18256                </div>
18257                <template v-for="event in me.events">
18258                    <template v-if="event.id !== 'placeholder'">
18259                        <span 
18260                            class="cell-event-overlay" 
18261                            :style="generateEventStyleForMonth( event, 'event_body' )"
18262                            @click="clickSchedule( event )"
18263                        ><div :style="generateEventStyleForMonth( event, 'status_dot' )"></div><div class="event-title">{{ generateEventTitle( event ) }}</div></span>
18264                    </template>
18265                </template>
18266            </div>
18267            <div class="xButton-calendarOverlay" @click.self="global.overlays.overflow_overlay=false;"></div>
18268        </div>
18269    </div>
18270</script>
18270
18271
18272<script>
18273    //global store
18274    store.components["overflow_overlay"] = {
18275        client_id: null,
18276        date: null,
18277        events: [],
18278        cellPosition: {},
18279        loading: false,
18280        is_membership_cal: false,
18281    };
18282    //define the component
18283    app_overlays.component("overflow-overlay", {
18284        template: "#template-overflow-overlay",
18285        data() {
18286            return {
18287            	global: store,
18288                me: store.components.overflow_overlay
18289            }
18290        },
18291        methods: {
18292            generateStyle(){
18293                const cellPosition = this.me.cellPosition;
18294                const viewportHeight = window.innerHeight;
18295                const viewportWidth = window.innerWidth;
18296                const overlayHeight = 230;
18297                const overlayWidth = 230;
18298
18299                let top = cellPosition.top;
18300                let left = cellPosition.left;
18301
18302                // Adjust top position if overlay goes off-screen
18303                // if ( top + overlayHeight > viewportHeight) {
18304                // }
18305
18306                // Adjust left position if overlay goes off-screen
18307                if ( left + overlayWidth > viewportWidth) {
18308                    let difference = left + overlayWidth;
18309                    left -= difference - viewportWidth; // 20px margin from right
18310                }
18311                return {
18312                    'top': `${top}px`,
18313                    'left': `${left}px`
18314                };
18315            },
18316            generateEventTitle( eventObject ){
18317                if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) {
18318                    return eventObject.raw.nice_time !== undefined ? `${eventObject.raw.nice_time} ${eventObject.title}` : `${eventObject.title}`;
18319                } else if ( eventObject.isAllDay || eventObject.isMultipleDay ) {
18320                    return eventObject.title || 'Not Provided';
18321                }
18322            },
18323            generateEventStyleForMonth( eventObject, type ){
18324                let styleObject = {};                
18325                const dotColor = eventObject.raw.dot_color;
18326                const bgColor = eventObject.bgColor || '#e8edf1';
18327                const color = /* eventObject.color */ this.get_contrast_color( bgColor ) || 'inherit';
18328                if ( type === 'status_dot' ){
18329                    if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) {
18330                        styleObject = {
18331                            'min-width': '2.2px',
18332                            'max-width': '2.2px',
18333                            'min-height': '2.2px',
18334                            'max-height': '2.2px',
18335                            'border': `2.2px solid ${dotColor || '#333'}`,
18336                            'border-radius': '50%',
18337                            'background-color': `${dotColor || '#333'}`,
18338                            'margin': '0 5px 0 5px',
18339                        }
18340                    } else if ( eventObject.isAllDay || eventObject.isMultipleDay ){
18341                        styleObject = {
18342                            'margin-right': '3.5px',
18343                        }
18344                    }
18345                } else if ( type === 'event_body' ){
18346                    if ( eventObject.isAllDay || eventObject.isMultipleDay ) {
18347                        styleObject = {
18348                            'background-color': bgColor,
18349                            'color': color,
18350                            'height': '22px',
18351                        };  
18352                    } 
18353                }
18354                return styleObject
18355            },
18356            clickSchedule(event){
18357                if ( this.me.client_id === 'primary' ){
18358                    store.eventBus.$emit('clickSchedule_events_cal', event)
18359                    store.overlays.overflow_overlay = false;
18360                } else if ( this.me.client_id === 'banquets' ){
18361                    store.eventBus.$emit('clickSchedule_banquets_cal', event)
18362                    store.overlays.overflow_overlay = false;
18363                } else if ( this.me.client_id === 'bookings' ){
18364                    store.eventBus.$emit('clickSchedule_reservations_cal', event)
18365                    store.overlays.overflow_overlay = false;
18366                } else if ( this.me.client_id === 'time_blocks' ){
18367                    store.eventBus.$emit('clickSchedule_time_blocks_cal', event)
18368                    store.overlays.overflow_overlay = false;
18369                } else if (this.me.is_membership_cal ){
18370                    store.eventBus.$emit('clickSchedule_membership_cal', event)
18371                    store.overlays.overflow_overlay = false;
18372                }
18373            },
18374            //methods to determine font color in contrast to bg color
18375            calculate_luminance(color) {
18376                const hex = color.substring(1); //remove #
18377                const rgb = parseInt(hex, 16); //convert to RGB
18378                const r = (rgb >> 16) & 0xff; //extract the red channel
18379                const g = (rgb >>  8) & 0xff; //extract the green channel
18380                const b = (rgb >>  0) & 0xff; //extract the blue channel
18381                //convert RGB to linear values
18382                const sR = r / 255;
18383                const sG = g / 255;
18384                const sB = b / 255;
18385                //calculate luminance
18386                const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
18387                return luminance;
18388            },
18389            get_contrast_color(background) {
18390                const backgroundLuminance = this.calculate_luminance(background);
18391                const fontColor = backgroundLuminance > 0.5 ? '#000000' : '#ffffff';
18392                return fontColor;
18393            }
18394        },
18395    });
18396
18397    
18398</script>
18398    
18398<script type="text/html" id="template-custom-field-wb">
18399    <div class="custom-field-wrapper tinyMarginTop" :id="custom_field.id">
18400        <div class="flex-row-start" style="width: 100%;">
18401            <!-- question -->
18402            <div class="flex-column-start tinyMarginRight" style="width: 100%;">
18403                <div class="custom-field flex-row-start">
18404                    <p v-if="custom_field && custom_field.name" class="default-font-body">{{ custom_field.name }}</p>
18405                    <span v-if="custom_field && custom_field.required" class="flex-row-start">
18406                        <p style="color: rgba(255, 0, 0, 0.885); margin: 0 5.5px 0 3px;">*</p>
18407                        <p class="default-font-body" style="font-size: 14px; color: rgb(150, 152, 154); font-style: italic;">(required)</p>
18408                    </span>
18409                </div>
18410            </div>
18411        </div>
18412        <!-- response options -->
18413        <div style="width: 100%;">
18414            <!-- short response -->
18415            <template v-if="custom_field && custom_field.type === 'text'">
18416                <span class="flex-row-start tinyMarginBottom">
18417                    <input
18418                        type="text" 
18419                        placeholder="Your answer"
18420                        class="border-bottom-grey-2 default-font-body"
18421                        style="width: 50%; line-height: 24px;"
18422                        v-model="input_value"
18423                        :disabled="is_admin"
18424                    >
18425                </span>
18426            </template>
18427            <!-- paragraph -->
18428            <template v-if="custom_field && custom_field.type === 'textarea'">
18429                <span class="flex-row-start tinyMarginBottom">
18430                    <input
18431                        type="text" 
18432                        placeholder="Your answer"
18433                        class="border-bottom-grey-2 default-font-body"
18434                        style="width: 100%; line-height: 24px;"
18435                        v-model="input_value"
18436                        :disabled="is_admin"
18437                    >
18438                </span>
18439            </template>
18440            <!-- multiple choice, checkbox -->
18441            <div v-if="custom_field.type === 'radio' || custom_field.type === 'checkbox'" class="flex-column-start">
18442                <template v-for="(option, index) in custom_field_options" :id="index">
18443                    <span class="flex-row-start" style="position: relative; width: 100%; left: -5px;">
18444                        <!-- multiple choice -->
18445                        <template v-if="custom_field.type === 'radio'">
18446                            <span v-if="!option.selected" @click="select_mc_option( true, option )" class="flex-row-start">
18447                                <i class="fa-light fa-circle options-icon"></i>
18448                                <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p>
18449                            </span>
18450                            <span v-if="option.selected" @click="select_mc_option( false, option )" class="flex-row-start">
18451                                <i class="fa-solid fa-circle-dot options-icon"></i>
18452                                <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p>
18453                            </span>
18454                        </template>
18455                        <!-- checkbox -->
18456                        <template  v-if="custom_field.type === 'checkbox'">
18457                            <span v-if="!option.selected" @click="option.selected = true;" class="flex-row-start">
18458                                <i class="fa-light fa-square options-icon"></i>
18459                                <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p>
18460                            </span>
18461                            <span v-if="option.selected" @click="option.selected = false;" class="flex-row-start">
18462                                <i class="fa-solid fa-square-check options-icon"></i>
18463                                <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p>
18464                            </span>
18465                        </template>
18466                    </span>
18467                </template>
18468            </div>
18469            <!-- dropdown -->
18470            <template  v-if="custom_field.type === 'select' && custom_field_options">
18471                <div id="response-dropdown" :style="{ 
18472                    borderBottomLeftRadius: dropdown_open ? '0' : '4px', 
18473                    borderBottomRightRadius: dropdown_open ? '0' : '4px' 
18474                }">
18475                    <!-- dropdown closed -->
18476                    <div id="selected-response" @click="dropdown_open = !dropdown_open;">
18477                        <p v-if="selected_dropdown_index < 0" class="response-option default-font-body">Choose</p>
18478                        <p v-if="selected_dropdown_index >= 0 && custom_field_options && custom_field_options[selected_dropdown_index]" class="response-option default-font-body">
18479                            {{ custom_field_options[selected_dropdown_index].name }}
18480                        </p>
18481                        <i v-if="!dropdown_open" class="fa-solid fa-sort-down" style="color: rgb(95, 95, 95)"></i>
18482                        <i v-if="dropdown_open" class="fa-solid fa-sort-up" style="color: rgba(95, 95, 95)"></i>
18483                    </div>
18484                    <!-- dropdown open -->
18485                    <div v-if="dropdown_open" id="response-options">
18486                        <div 
18487                            class="response-option border-bottom-grey-2"
18488                            @click="select_dropdown_option( null, -1 )"
18489                            :style="[ return_selected_style( -1 ) ]"
18490                        >
18491                            <p class="default-font-body" style="color: rgb(150, 152, 154);">Choose</p>
18492                        </div>
18493                        <div 
18494                            v-for="(option, index) in custom_field_options" 
18495                            class="response-option"
18496                            :key="option.value" 
18497                            @click="select_dropdown_option( option, index )"
18498                            :style="[ return_selected_style( index ) ]"
18499                        >
18500                            <p class="default-font-body">{{ option.name }}</p>
18501                        </div>
18502                    </div>
18503                </div>
18504            </template>
18505            <!-- searchable dropdown (same option model as select) -->
18506            <template v-if="custom_field && custom_field.type === 'searchable_select' && custom_field_options">
18507                <div class="tinyMarginBottom" style="width: 100%;">
18508                    <rich-select-dropdown
18509                        :value="get_searchable_select_value()"
18510                        :options="rich_wb_dropdown_options()"
18511                        :placeholder="custom_field.name || 'Select...'"
18512                        @input="set_searchable_select_from_rich($event)"
18513                        :showFieldLabelWhenSelected="true"
18514                        :searchable="true"
18515                        :searchSubtext="false"
18516                    ></rich-select-dropdown>
18517                </div>
18518            </template>
18519            <!-- numeric value -->
18520            <template v-if="custom_field && custom_field.type === 'number'">
18521                <span class="flex-row-start tinyMarginBottom">
18522                    <input
18523                        type="text" 
18524                        placeholder="Number"
18525                        class="border-bottom-grey-2 inherit-font-css"
18526                        style="width: 20%; line-height: 24px;"
18527                        v-model="input_value"
18528                        @input="check_input_is_num()"
18529                        :disabled="is_admin"
18530                    >
18531                    <span v-if="display_num_warning" class="flex-row-start" @mouseover="display_popup = true;" @mouseleave="display_popup = false;">
18532                        <i class="fa-solid fa-triangle-exclamation" style="color: rgba(255, 0, 0, 0.885); font-size: 1em; cursor: pointer;"></i>
18533                    </span>
18534                    <div v-if="display_popup" class="display-popup" style="font-size: 12px;">This value must be a number.</div>
18535                </span>
18536            </template>
18537            <!-- file upload -->
18538            <template v-if="custom_field && custom_field.type === 'file_upload'">              
18539                <file-upload-component
18540                    @file_selected="file_selected"
18541                    :valid_file_types="valid_file_types"
18542                ></file-upload-component>
18543            </template>
18544            <!-- date value -->
18545            <template v-if="custom_field && custom_field.type === 'date'">
18546                <span class="flex-row-start tinyMarginBottom">
18547                    <input
18548                        type="date" 
18549                        placeholder="mm/dd/yyyy"
18550                        class="border-bottom-grey-2 default-font-body"
18551                        style="width: 20%; line-height: 24px;"
18552                        v-model="input_value"
18553                        :disabled="is_admin"
18554                    >
18555                </span>
18556            </template>
18557            <!-- rating -->
18558            <template v-if="custom_field && custom_field.type === 'rating'">
18559                <div class="flex-row-start tinyMarginBottom" style="gap: 12px; padding-top: 4px;">
18560                    <i
18561                        v-for="star in 5"
18562                        :key="star"
18563                        :class="star <= input_value ? 'fa-solid fa-star' : 'fa-regular fa-star'"
18564                        style="font-size: 28px; color: #4a56f8; cursor: pointer;"
18565                        @click="set_rating( star )"
18566                    ></i>
18567                </div>
18568            </template>
18569        </div>
18570    </div>
18571</script>
vendor: 1 bytes, line 18571
18571
18572<script>
18573    app_content.component("custom-field-component-wb", {
18574        template: "#template-custom-field-wb",
18575        data() {
18576            return {
18577                selected_dropdown_index: -1, //default
18578                dropdown_open: false,
18579                custom_field_name: '',
18580                custom_field_required: false,
18581                display_num_warning: false,
18582                display_popup: false,
18583                input_value: '',
18584            }
18585        },
18586        props: {
18587            form: Object,
18588            is_admin: Boolean,
18589            custom_field: Object,
18590            last_update: Number,
18591        },
18592        methods: {
18593            return_selected_style( index ){
18594                if ( index === this.selected_dropdown_index ){
18595                    return {
18596                        'background-color': 'rgba(26, 115, 232, .09)',
18597                    }
18598                }
18599            },
18600            clear_dropdown_index(){
18601                this.selected_dropdown_index = -1;
18602            },
18603            clear_field(){
18604                this.input_value = '';
18605                this.selected_dropdown_index = -1;
18606                this.display_num_warning = false;
18607                if ( this.custom_field_options ){
18608                    this.custom_field_options.forEach(option => {
18609                        option.selected = false;
18610                    });
18611                }
18612            },
18613            select_dropdown_option( option, index ){
18614                this.selected_dropdown_index = index;
18615                this.dropdown_open = false;
18616                if ( option ){
18617                    option.selected = true;
18618                    this.unselect_other_options( option, false );
18619                } else if ( !option ){
18620                    this.unselect_other_options( option, true );
18621                }
18622            },
18623            select_mc_option( select, option ){
18624                if ( select ){
18625                    option.selected = true;
18626                    this.unselect_other_options( option, false );
18627                } else if ( !select ){
18628                    option.selected = false;
18629                }
18630            },
18631            get_searchable_select_value(){
18632                if ( !this.custom_field_options || !this.custom_field_options.length ) return null;
18633                const selected = this.custom_field_options.find(o => o.selected);
18634                return selected ? selected.id : null;
18635            },
18636            rich_wb_dropdown_options(){
18637                if ( !this.custom_field_options ) return [];
18638                return this.custom_field_options.map(o => ({ id: o.id, name: o.name || '' }));
18639            },
18640            set_searchable_select_from_rich(val){
18641                let id = null;
18642                if ( val != null && typeof val === 'object' && val.id != null ){
18643                    id = val.id;
18644                } else {
18645                    id = val;
18646                }
18647                if ( !id ){
18648                    this.select_dropdown_option(null, -1);
18649                    return;
18650                }
18651                const idx = this.custom_field_options.findIndex(o => o.id === id);
18652                if ( idx === -1 ){
18653                    this.select_dropdown_option(null, -1);
18654                    return;
18655                }
18656                this.select_dropdown_option(this.custom_field_options[idx], idx);
18657            },
18658            unselect_other_options( option, clear_all = false ){
18659                if ( clear_all ){
18660                    this.custom_field_options.forEach( custom_field_option => {
18661                        custom_field_option.selected = false;
18662                    })
18663                } else if ( !clear_all ){
18664                    this.custom_field_options.forEach( custom_field_option => {
18665                        if ( custom_field_option.id !== option.id ){
18666                            custom_field_option.selected = false;
18667                        }
18668                    })
18669                }
18670            },
18671            check_input_is_num(){
18672                let number = Number(this.input_value);
18673                if ( isNaN(number) || !this.input_value ){
18674                    this.display_num_warning = true;
18675                } else {
18676                    this.input_value = number;
18677                    this.display_num_warning = false;
18678                }
18679            },
18680            file_selected( data ){
18681                let file = data.value;
18682                if (file) {
18683                    this.input_value = file;
18684                }
18685            },
18686            set_rating( star ){
18687                if ( this.is_admin ){
18688                    return;
18689                }
18690                this.input_value = star;
18691            },
18692        },
18693        computed: {
18694            custom_field_options(){
18695                if ( this.custom_field && this.custom_field.custom_field_options ){
18696                    return this.custom_field.custom_field_options;
18697                }
18698                return [];
18699            },
18700            valid_file_types(){
18701                return [ ];
18702            },
18703        },
18704        watch: {
18705            input_value( newValue ){
18706                this.$emit('field-updated', {
18707                    id: this.custom_field.id,
18708                    value: newValue
18709                });
18710            }
18711        }
18712    })
18713</script>
18713
18714
18715
18716<style scoped>
18717    :root {
18718        --custom-field-grey-1: rgba(180, 180, 180, 0.8);
18719        --custom-field-grey-2: rgba(195, 195, 195, 0.7);
18720        --custom-field-grey-3: rgb(154, 160, 166);
18721        --custom-field-primary: #202124;
18722    }
18723    .custom-field-wrapper{
18724        width: 100%;
18725        border: 1px solid rgba(195, 195, 195, 0.7);
18726        border-radius: 8px;
18727        padding: 1.45em;
18728        padding-top: 10px;
18729        background-color: #fff;
18730        box-sizing: border-box;
18731    }
18732    .custom-field-wrapper .options-icon{
18733        color: var(--custom-field-grey-3);
18734        margin-right: 8px;
18735        padding: 7.5px;
18736        cursor: pointer;
18737        border-radius: 50%;
18738        font-size: 1.1em;
18739    }
18740    .custom-field-wrapper .options-icon:hover{
18741        background-color: rgba(150, 150, 150, 0.1);
18742    }
18743    .custom-field-wrapper .default-font-body{
18744        font-family: Roboto, Arial, Helvetica, sans-serif;
18745        font-size: 15px; 
18746        font-weight: 400;
18747        text-align: left;
18748    }
18749    .custom-field-wrapper .file-upload-wrapper{
18750        height: 90px;
18751        width: 50%;
18752        border: 1px dashed var(--custom-field-grey-1);
18753    }
18754    .custom-field-wrapper  .inherit-font-css{
18755        font-family: inherit;
18756        font-size: inherit;
18757        font-weight: inherit;
18758    }
18759    .custom-field-wrapper .flex-row-center{
18760        display: flex;
18761        flex-direction: row;
18762        align-items: center;
18763        justify-content: center;
18764    }
18765    .custom-field-wrapper .flex-column-center{
18766        display: flex;
18767        flex-direction: column;
18768        align-items: center;
18769        justify-content: center;
18770    }
18771    .custom-field-wrapper .flex-row-start{
18772        display: flex;
18773        flex-direction: row;
18774        align-items: center;
18775        justify-content: flex-start;
18776    }
18777    .custom-field-wrapper .flex-column-start{
18778        display: flex;
18779        flex-direction: column;
18780        align-items: flex-start;
18781        justify-content: center;
18782    }
18783    .custom-field-wrapper .custom-field{
18784        color: rgb(32, 33, 36);
18785        box-sizing: border-box;
18786        font-weight: 400;
18787        font-size: 14px;
18788        width: 100%;
18789        line-height: 2.5;
18790        letter-spacing: 0;
18791    }
18792    .custom-field-wrapper .custom-field > .custom-field-input{
18793        font-family: inherit;
18794        font-size: inherit;
18795        font-weight: inherit;
18796        width: inherit;
18797        line-height: inherit;
18798        background-color: rgb(248, 249, 250);
18799        padding: 5px 0 5px 15px;
18800    }
18801    .custom-field-wrapper .border-bottom-grey-2, .custom-field-wrapper .input-is-focused{
18802        border-bottom: 1.25px solid var(--custom-field-grey-2);
18803    }
18804    .custom-field-wrapper input {
18805        transition: border-color 0.2s;
18806    }
18807    #response-dropdown{
18808        position: relative;
18809        display: flex;
18810        flex-direction: column;
18811        align-items: flex-start;
18812        justify-content: center;
18813        font-size: 14px; 
18814        color: var(--custom-field-primary);
18815        background-color: #fff;
18816        width: 33%;
18817        padding: 12px;
18818        border: 1px solid var(--custom-field-grey-1);
18819        border-top-left-radius: 4px;
18820        border-top-right-radius: 4px;
18821    }
18822    #response-dropdown > #selected-response{
18823        display: flex;
18824        flex-direction: row;
18825        align-items: center;
18826        justify-content: space-between;
18827        width: 100%;
18828        cursor: pointer;
18829    }
18830    #response-options{
18831        position: absolute;
18832        top: 100%;
18833        left: -1px;
18834        z-index: 10;
18835        background-color: #fff;
18836        border: 1px solid var(--custom-field-grey-1);
18837        border-bottom-left-radius: 4px;
18838        border-bottom-right-radius: 4px;
18839        width: 100%;
18840    }
18841    #response-options > .response-option{
18842        display: flex;
18843        flex-direction: row;
18844        align-items: center;
18845        justify-content: flex-start;
18846        padding: 12px;
18847        font-family: Roboto;
18848        cursor: pointer;
18849    }
18850    #response-options > .response-option:hover{
18851        background-color: rgba(150, 150, 150, 0.2);
18852    }
18853    .display-popup{
18854        background-color: rgb(245, 245, 245);
18855        padding: 5px;
18856        border-radius: 6px;
18857        position: relative;
18858        left: -100px;
18859        bottom: 30px;
18860        z-index: 10;
18861        width: fit-content;
18862    }
18863</style>
18864
18865    
18865<script type="text/html" id="template-rich-select-dropdown">
18866    <div class="relative">
18867        <div v-if="showFieldLabelWhenSelected && has_selected_value" class="rich-select-floating-label">
18868            <span class="rich-select-trigger-title">{{ label || placeholder }}</span>
18869        </div>
18870        <div 
18871            class="rich-select-dropdown relative" ref="container"
18872            style="width: 100%;"
18873        >    <!-- Closed state: box showing selected option(s) -->
18874            <div
18875                class="rich-select-trigger flexNoWrap align-items-center pointer"
18876                :class="[triggerClass, { 'rich-select-trigger-open': isOpen, 'rich-select-trigger-borderless': borderless, 'rich-select-trigger-disabled': disabled }]"
18877                @click="toggle()"
18878            >
18879                <slot name="indicator"></slot>
18880                <template v-if="multiple">
18881                    <template v-if="selectedOptionsForDisplay.length === 0">
18882                        <span class="rich-select-placeholder">{{ label || placeholder }}</span>
18883                    </template>
18884                    <template v-else-if="selectedOptionsForDisplay.length === 1">
18885                        <div v-if="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon-wrap">
18886                            <img v-if="optionImageIsUrl(selectedOptionsForDisplay[0])" :src="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon" alt="">
18887                            <i v-else :class="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon fa"></i>
18888                        </div>
18889                        <div class="rich-select-trigger-text">
18890                            <span class="rich-select-trigger-title">{{ optionTitle(selectedOptionsForDisplay[0]) }}</span>
18891                            <span v-if="optionSubtext(selectedOptionsForDisplay[0])" class="rich-select-trigger-subtext">{{ optionSubtext(selectedOptionsForDisplay[0]) }}</span>
18892                        </div>
18893                    </template>
18894                    <template v-else>
18895                        <span class="rich-select-trigger-title">{{ selectedOptionsForDisplay.length }} selected</span>
18896                    </template>
18897                </template>
18898                <template v-else>
18899                    <template v-if="selectedOptionSingle">
18900                        <div v-if="optionImage(selectedOptionSingle)" class="rich-select-option-icon-wrap">
18901                            <img v-if="optionImageIsUrl(selectedOptionSingle)" :src="optionImage(selectedOptionSingle)" class="rich-select-option-icon" alt="">
18902                            <i v-else :class="optionImage(selectedOptionSingle)" class="rich-select-option-icon fa"></i>
18903                        </div>
18904                        <div class="rich-select-trigger-text">
18905                            <span class="rich-select-trigger-title" :class="{ 'rich-select-trigger-title-no-subtext': !optionSubtext(selectedOptionSingle) }">{{ optionTitle(selectedOptionSingle) }}</span>
18906                            <span v-if="optionSubtext(selectedOptionSingle)" class="rich-select-trigger-subtext">{{ optionSubtext(selectedOptionSingle) }}</span>
18907                        </div>
18908                    </template>
18909                    <template v-else-if="disabled && disabledSubtext">
18910                        <div class="rich-select-trigger-text">
18911                            <span class="rich-select-placeholder">{{ label || placeholder }}</span>
18912                            <span class="rich-select-trigger-subtext">{{ disabledSubtext }}</span>
18913                        </div>
18914                    </template>
18915                    <template v-else>
18916                        <span class="rich-select-placeholder">{{ label || placeholder }}</span>
18917                    </template>
18918                </template>
18919                <div class="flexGrowOne"></div>
18920                <i class="fa-light fa-chevron-down rich-select-chevron" :class="{ 'rich-select-chevron-open': isOpen }" style="z-index: 10;"></i>
18921            </div>
18922
18923            <!-- Open state: dropdown panel (hovering above content) -->
18924            <div
18925                v-show="isOpen"
18926                class="rich-select-dropdown-panel boxShadowBottom moreBlur"
18927                :class="{ 'rich-select-dropdown-panel--fixed': uses_fixed_dropdown_panel && isOpen }"
18928                :style="fixedDropdownPanelInlineStyle"
18929                ref="dropdownPanel"
18930                @mouseover="isMouseOver = true"
18931                @mouseout="isMouseOver = false"
18932                tabindex="-1"
18933                @focusout="scheduleClose"
18934            >
18935                <div v-if="searchable" class="rich-select-search-wrap relative">
18936                    <input
18937                        ref="searchInput"
18938                        v-model="searchQuery"
18939                        class="rich-select-search-input"
18940                        :aria-label="search_placeholder || 'Search...'"
18941                        @click.stop
18942                    >
18943                    <span
18944                        v-if="!searchQuery"
18945                        class="unclickable"
18946                        style="position:absolute; left:18px; right:18px; top:50%; transform:translateY(-50%); color:#9ca4b7; font-size:14px; font-family:roboto, sans-serif; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none;"
18947                    >{{ search_placeholder || 'Search...' }}</span>
18948                </div>
18949                <div class="rich-select-dropdown-list scrollable" ref="dropdownList">
18950                    <div v-if="loading" class="rich-select-option flexNoWrap align-items-center unclickable">
18951                        <div class="rich-select-option-text">
18952                            <span class="rich-select-option-subtext">Searching...</span>
18953                        </div>
18954                    </div>
18955                    <div
18956                        v-if="showNoneOption"
18957                        class="rich-select-option flexNoWrap align-items-center"
18958                        :class="{ 'rich-select-option-selected': value == null }"
18959                        @click="selectOption(null)"
18960                    >
18961                        <div class="rich-select-option-text">
18962                            <span class="rich-select-option-title">None</span>
18963                        </div>
18964                    </div>
18965                    <!-- Suggested section: only renders when the parent passes
18966                            lookup-derived candidates AND the user hasn't typed a
18967                            search query. Header + divider use inline styles per
18968                            the no-new-classes rule for HTML files. -->
18969                    <template v-if="visibleSuggestedOptions.length > 0">
18970                        <p style="margin: 0; padding: 8px 14px 4px 14px; font-size: 10px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: #7a8298;">{{ suggested_label }}</p>
18971                        <div
18972                            v-for="option in visibleSuggestedOptions"
18973                            :key="'suggested-' + optionId(option)"
18974                            class="rich-select-option flexNoWrap align-items-center"
18975                            :class="{ 'rich-select-option-selected': isSelected(option), 'unclickable': option && option.disabled }"
18976                            @click="selectOption(option)"
18977                        >
18978                            <template v-if="multiple">
18979                                <i v-if="isSelected(option)" class="fa-solid fa-square-check rich-select-option-check"></i>
18980                                <i v-else class="fa-light fa-square rich-select-option-check"></i>
18981                            </template>
18982                            <div v-if="optionImage(option)" class="rich-select-option-icon-wrap">
18983                                <img v-if="optionImageIsUrl(option)" :src="optionImage(option)" class="rich-select-option-icon" alt="">
18984                                <i v-else :class="optionImage(option)" class="rich-select-option-icon fa"></i>
18985                            </div>
18986                            <div class="rich-select-option-text">
18987                                <span class="rich-select-option-title" :class="optionTitleClass(option)">{{ optionTitle(option) }}</span>
18988                                <span v-if="optionSubtext(option)" class="rich-select-option-subtext">{{ optionSubtext(option) }}</span>
18989                            </div>
18990                            <slot name="option-action" :option="option"></slot>
18991                        </div>
18992                        <div style="height: 1px; background-color: #e4ebf1; margin: 4px 0;"></div>
18993                    </template>
18994                    <div
18995                        v-for="option in nonSuggestedFilteredOptions"
18996                        :key="optionId(option)"
18997                        class="rich-select-option flexNoWrap align-items-center"
18998                        :class="{ 'rich-select-option-selected': isSelected(option), 'unclickable': option && option.disabled }"
18999                        @click="selectOption(option)"
19000                    >
19001                        <template v-if="multiple">
19002                            <i v-if="isSelected(option)" class="fa-solid fa-square-check rich-select-option-check"></i>
19003                            <i v-else class="fa-light fa-square rich-select-option-check"></i>
19004                        </template>
19005                        <div v-if="optionImage(option)" class="rich-select-option-icon-wrap">
19006                            <img v-if="optionImageIsUrl(option)" :src="optionImage(option)" class="rich-select-option-icon" alt="">
19007                            <i v-else :class="optionImage(option)" class="rich-select-option-icon fa"></i>
19008                        </div>
19009                        <div class="rich-select-option-text">
19010                            <span class="rich-select-option-title" :class="optionTitleClass(option)">{{ optionTitle(option) }}</span>
19011                            <span v-if="optionSubtext(option)" class="rich-select-option-subtext">{{ optionSubtext(option) }}</span>
19012                        </div>
19013                        <slot name="option-action" :option="option"></slot>
19014                    </div>
19015                    <div v-if="!loading && nonSuggestedFilteredOptions.length == 0" class="rich-select-option flexNoWrap align-items-center unclickable">
19016                        <div class="rich-select-option-text">
19017                            <span class="rich-select-option-subtext">{{ empty_text || "No options found" }}</span>
19018                        </div>
19019                    </div>
19020                </div>
19021                <slot name="panel-footer"></slot>
19022            </div>
19023        </div>
19024    </div>
19025</script>
19025
19026
19027<script>
19028(function() {
19029    function optionId(option) {
19030        if (!option) return '';
19031        return option.id != null ? option.id : (option.get ? option.get('id') : option);
19032    }
19033    function optionTitle(option) {
19034        if (!option) return '';
19035        return option.title != null ? option.title : (option.name != null ? option.name : (option.get ? (option.get('title') || option.get('name')) : ''));
19036    }
19037    function optionSubtext(option) {
19038        if (!option) return '';
19039        return option.subtext != null ? option.subtext : (option.get ? option.get('subtext') : '');
19040    }
19041    function optionTitleClass(option) {
19042        if (!option) return '';
19043        return option.title_class != null ? option.title_class : (option.get ? option.get('title_class') : '');
19044    }
19045    function optionImage(option) {
19046        if (!option) return null;
19047        return option.image != null ? option.image : (option.icon != null ? option.icon : (option.get ? (option.get('image') || option.get('icon')) : null));
19048    }
19049    function optionImageIsUrl(option) {
19050        var img = optionImage(option);
19051        return img && (typeof img === 'string' && (img.indexOf('http') === 0 || img.indexOf('/') === 0 || img.indexOf('data:') === 0));
19052    }
19053
19054    var richSelectDropdownConfig = {
19055        template: "#template-rich-select-dropdown",
19056        emits: ['input', 'update:value', 'change', 'search'],
19057        data() {
19058            return {
19059                isOpen: false,
19060                isMouseOver: false,
19061                closeTimeout: null,
19062                searchQuery: '',
19063                fixedPanelPositionStyle: null,
19064                _fixedPanelScrollCleanup: null,
19065                auto_fix_dropdown_panel: false,
19066            };
19067        },
19068        mounted() {
19069            this.auto_fix_dropdown_panel = !!this.$refs.container.closest('.wb-inline-controls');
19070        },
19071        watch: {
19072            isOpen: function(is_open) {
19073                if (!this.uses_fixed_dropdown_panel) {
19074                    return;
19075                }
19076                if (is_open) {
19077                    this.scroll_trigger_into_safe_view_if_needed();
19078                    this.sync_fixed_dropdown_panel();
19079                    this.$nextTick(() => {
19080                        this.sync_fixed_dropdown_panel();
19081                        this.teardown_fixed_dropdown_panel_listeners();
19082                        this._fixedPanelScrollCleanup = this.setup_fixed_dropdown_panel_listeners();
19083                    });
19084                } else {
19085                    this.fixedPanelPositionStyle = null;
19086                    this.teardown_fixed_dropdown_panel_listeners();
19087                }
19088            },
19089            searchQuery: function(updated_query) {
19090                if (!this.searchable) {
19091                    return;
19092                }
19093                this.$emit('search', updated_query);
19094            }
19095        },
19096        props: {
19097            options: {
19098                type: Array,
19099                default: function() { return []; }
19100            },
19101            value: {
19102                type: [Object, Array, String, Number],
19103                default: null
19104            },
19105            multiple: {
19106                type: Boolean,
19107                default: false
19108            },
19109            placeholder: {
19110                type: String,
19111                default: "Select..."
19112            },
19113            label: {
19114                type: String,
19115                default: ""
19116            },
19117            triggerClass: {
19118                type: String,
19119                default: ""
19120            },
19121            /** If true, options may be Backbone models with .get('key') */
19122            parse_objects: {
19123                type: Boolean,
19124                default: false
19125            },
19126            showFieldLabelWhenSelected: {
19127                type: Boolean,
19128                default: false
19129            },
19130            searchable: {
19131                type: Boolean,
19132                default: false
19133            },
19134            search_placeholder: {
19135                type: String,
19136                default: ""
19137            },
19138            remote_search: {
19139                type: Boolean,
19140                default: false
19141            },
19142            loading: {
19143                type: Boolean,
19144                default: false
19145            },
19146            empty_text: {
19147                type: String,
19148                default: ''
19149            },
19150            /** When true, the closed trigger renders without its border/box so it can sit flush inside a table cell. */
19151            borderless: {
19152                type: Boolean,
19153                default: false
19154            },
19155            searchSubtext: {
19156                type: Boolean,
19157                default: true
19158            },
19159            /** When true, the open panel uses position:fixed so it escapes overflow:auto 
19159scroll parents (e.g. lodging step). */
19160            dropdownPanelFixed: {
19161                type: Boolean,
19162                default: false
19163            },
19164            /** When true, a "None" option is shown at the top of the list to clear the selection. */
19165            showNoneOption: {
19166                type: Boolean,
19167                default: false
19168            },
19169            /**
19170             * Optional, ranked list of options to surface at the top of the
19171             * dropdown panel under a "Suggested" header. Items must use the
19172             * same shape as `options` (id + title/name). The component
19173             * doesn't decide what's "suggested" — that's the parent's job
19174             * (e.g. the add-product modal feeds in the top N candidates from
19175             * its similar-products lookup). Hidden once the user starts
19176             * typing in the search input so search results aren't fragmented
19177             * across two sections.
19178             */
19179            suggested_options: {
19180                type: Array,
19181                default: function() { return []; }
19182            },
19183            /** Header label rendered above suggested_options. */
19184            suggested_label: {
19185                type: String,
19186                default: 'Suggested'
19187            },
19188            /** When true, the trigger can't be opened and renders in a muted state. */
19189            disabled: {
19190                type: Boolean,
19191                default: false
19192            },
19193            /** Explanatory line rendered under the placeholder when disabled (e.g. "No products found"). */
19194            disabledSubtext: {
19195                type: String,
19196                default: null
19197            }
19198        },
19199        computed: {
19200            uses_fixed_dropdown_panel() {
19201                return this.dropdownPanelFixed || this.auto_fix_dropdown_panel;
19202            },
19203            fixedDropdownPanelInlineStyle() {
19204                if (!this.uses_fixed_dropdown_panel || !this.isOpen || !this.fixedPanelPositionStyle) {
19205                    return null;
19206                }
19207                return this.fixedPanelPositionStyle;
19208            },
19209            displayOptions() {
19210                return this.options || [];
19211            },
19212            filteredOptions() {
19213                if (this.remote_search) {
19214                    return this.displayOptions;
19215                }
19216                if (!this.searchable || !this.searchQuery.trim()) {
19217                    return this.displayOptions;
19218                }
19219                var normalized_search_query = this.searchQuery.trim().toLowerCase();
19220                var searchSubtext = this.searchSubtext;
19221                return this.displayOptions.filter(function(option) {
19222                    var title = (optionTitle(option) || '').toLowerCase();
19223                    if (title.indexOf(normalized_search_query) !== -1) {
19224                        return true;
19225                    }
19226                    if (searchSubtext) {
19227                        var option_subtext = (optionSubtext(option) || '').toLowerCase();
19228                        if (option_subtext.indexOf(normalized_search_query) !== -1) {
19229                            return true;
19230                        }
19231                    }
19232                    return false;
19233                });
19234            },
19235            // Suggested-section visibility. Hidden once the user types so the
19236            // search hit list doesn't get split across two sections.
19237            visibleSuggestedOptions() {
19238                if (!Array.isArray(this.suggested_options) || this.suggested_options.length === 0) {
19239                    return [];
19240                }
19241                if (this.searchable && this.searchQuery && this.searchQuery.trim()) {
19242                    return [];
19243                }
19244                return this.suggested_options;
19245            },
19246            // Filtered options minus anything already rendered in the
19247            // suggested section, so suggested items don't appear twice in
19248            // the panel.
19249            nonSuggestedFilteredOptions() {
19250                var suggested = this.visibleSuggestedOptions;
19251                if (!suggested.length) {
19252                    return this.filteredOptions;
19253                }
19254                var suggestedIds = {};
19255                suggested.forEach(function(option) {
19256                    suggestedIds[optionId(option)] = true;
19257                });
19258                return this.filteredOptions.filter(function(option) {
19259                    return !suggestedIds[optionId(option)];
19260                });
19261            },
19262            selectedOptionSingle() {
19263                if (this.multiple || !this.options.length) return null;
19264                var v = this.value;
19265                if (v == null) return null;
19266                var id = typeof v === 'object' && v !== null && (v.id != null || (v.get && v.get('id') != null))
19267                    ? (v.id != null ? v.id : v.get('id'))
19268                    : v;
19269                return this.options.find(function(o) { return optionId(o) == id; }) || null;
19270            },
19271            selectedOptionsForDisplay() {
19272                if (!this.multiple || !Array.isArray(this.value)) return [];
19273                var ids = this.value;
19274                return this.options.filter(function(o) { return ids.some(function(id) { return id == optionId(o); }); });
19275            },
19276            has_selected_value() {
19277                if (this.multiple) {
19278                    return this.selectedOptionsForDisplay.length > 0;
19279                }
19280                return !!this.selectedOptionSingle;
19281            }
19282        },
19283        methods: {
19284            optionId: function(o) { return optionId(o); },
19285            optionTitle: function(o) { return optionTitle(o); },
19286            optionSubtext: function(o) { return optionSubtext(o); },
19287            optionTitleClass: function(option) { return optionTitleClass(option); },
19288            optionImage: function(o) { return optionImage(o); },
19289            optionImageIsUrl: function(o) { return optionImageIsUrl(o); },
19290            isSelected(option) {
19291                var id = optionId(option);
19292                if (this.multiple && Array.isArray(this.value))
19293                    return this.value.some(function(v) { return v == id; });
19294                var single = this.value;
19295                if (single == null) return false;
19296                var currentId = typeof single === 'object' && single !== null
19297                    ? (single.id != null ? single.id : (single.get ? single.get('id') : null))
19298                    : single;
19299                return currentId == id;
19300            },
19301            toggle() {
19302                if (this.disabled) {
19303                    return;
19304                }
19305                if (this.closeTimeout) {
19306                    clearTimeout(this.closeTimeout);
19307                    this.closeTimeout = null;
19308                }
19309                this.isOpen = !this.isOpen;
19310                if (this.isOpen) {
19311                    this.searchQuery = '';
19312                    this.$nextTick(() => {
19313                        if (this.$refs.dropdownList) {
19314                            this.$refs.dropdownList.scrollTop = 0;
19315                        }
19316                        if (this.uses_fixed_dropdown_panel && this.searchable) {
19317                            requestAnimationFrame(() => {
19318                                requestAnimationFrame(() => {
19319                                    this.focus_open_panel();
19320                                });
19321                            });
19322                        } else {
19323                            this.focus_open_panel();
19324                        }
19325                    });
19326                    document.addEventListener('click', this.handleClickOutside);
19327                } else {
19328                    this.searchQuery = '';
19329                    document.removeEventListener('click', this.handleClickOutside);
19330                }
19331            },
19332            focus_open_panel() {
19333                if (!this.isOpen) {
19334                    return;
19335                }
19336                if (this.searchable && this.$refs.searchInput) {
19337                    this.$refs.searchInput.focus();
19338                } else if (this.$refs.dropdownPanel) {
19339                    this.$refs.dropdownPanel.focus();
19340                }
19341            },
19342            scheduleClose() {
19343                var self = this;
19344                this.closeTimeout = setTimeout(function() {
19345                    if (!self.isMouseOver) {
19346                        self.isOpen = false;
19347                        document.removeEventListener('click', self.handleClickOutside);
19348                    }
19349                }, 150);
19350            },
19351            handleClickOutside(e) {
19352                var panel = this.$refs.dropdownPanel;
19353                var inPanel = panel && panel.contains(e.target);
19354                var inContainer = this.$refs.container && this.$refs.container.contains(e.target);
19355                if (!inContainer && !inPanel) {
19356                    this.isOpen = false;
19357                    document.removeEventListener('click', this.handleClickOutside);
19358                }
19359            },
19360            return_visual_viewport() {
19361                let view = window.visualViewport;
19362                if (view) {
19363                    return {
19364                        height: view.height,
19365                        width: view.width,
19366                        offset_top: view.offsetTop || 0,
19367                        offset_left: view.offsetLeft || 0,
19368                    };
19369                }
19370                return {
19371                    height: window.innerHeight,
19372                    width: window.innerWidth,
19373                    offset_top: 0,
19374                    offset_left: 0,
19375                };
19376            },
19377            scroll_trigger_into_safe_view_if_needed() {
19378                if (!this.searchable || !this.$refs.container) {
19379                    return;
19380                }
19381                let view = this.return_visual_viewport();
19382                let trigger_rect = this.$refs.container.getBoundingClientRect();
19383                if (view.height - trigger_rect.bottom >= 200) {
19384                    return;
19385                }
19386                let node = this.$refs.container.parentElement;
19387                while (node && node !== document.documentElement) {
19388                    let style = window.getComputedStyle(node);
19389                    let overflow_y = style.overflowY;
19390                    let overflow = style.overflow;
19391                    if (overflow_y === 'auto' || overflow_y === 'scroll' || overflow === 'auto' || overflow === 'scroll') {
19392                        let parent_rect = node.getBoundingClientRect();
19393                        let delta = trigger_rect.top - parent_rect.top - 8;
19394                        if (delta > 0) {
19395                            node.scrollTop = node.scrollTop + delta;
19396                        }
19397                        return;
19398                    }
19399                    node = node.parentElement;
19400                }
19401            },
19402            sync_fixed_dropdown_panel() {
19403                if (!this.uses_fixed_dropdown_panel || !this.isOpen || !this.$refs.container) {
19404                    return;
19405                }
19406                let trigger_rect = this.$refs.container.getBoundingClientRect();
19407                let view = this.return_visual_viewport();
19408                let edge = 8;
19409                let gap = 4;
19410                let desired_height = 280;
19411                let min_usable = 160;
19412                let space_below = view.height - trigger_rect.bottom - edge;
19413                let space_above = trigger_rect.top - edge;
19414                let visual_top = trigger_rect.bottom + gap;
19415                let max_height = Math.min(desired_height, space_below);
19416
19417                if (space_below < min_usable && space_above > space_below) {
19418                    max_height = Math.min(desired_height, space_above);
19419                    visual_top = trigger_rect.top - gap - max_height;
19420                } else if (space_below < 80 && space_above >= 80) {
19421                    max_height = Math.min(desired_height, space_above);
19422                    visual_top = trigger_rect.top - gap - max_height;
19423                } else if (space_below < 80 && space_above < 80) {
19424                    visual_top = edge;
19425                    max_height = view.height - edge * 2;
19426                } else if (space_below < min_usable) {
19427                    max_height = Math.max(space_below, 0);
19428                    visual_top = trigger_rect.bottom + gap;
19429                }
19430
19431                if (visual_top < edge) {
19432                    max_height = max_height - (edge - visual_top);
19433                    visual_top = edge;
19434                }
19435                if (visual_top + max_height > view.height - edge) {
19436                    max_height = view.height - edge - visual_top;
19437                }
19438                if (max_height < 80) {
19439                    max_height = Math.min(80, Math.max(0, view.height - edge * 2));
19440                    visual_top = Math.min(visual_top, view.height - edge - max_height);
19441                    visual_top = Math.max(edge, visual_top);
19442                }
19443
19444                //keyboard pan: getBoundingClientRect is visual, position:fixed is layout
19445                this.fixedPanelPositionStyle = {
19446                    position: 'fixed',
19447                    top: (visual_top + view.offset_top) + 'px',
19448                    left: (trigger_rect.left + view.offset_left) + 'px',
19449                    width: trigger_rect.width + 'px',
19450                    minWidth: trigger_rect.width + 'px',
19451                    maxHeight: max_height + 'px',
19452                    zIndex: 10050,
19453                };
19454            },
19455            setup_fixed_dropdown_panel_listeners() {
19456                let sync_panel = () => {
19457                    this.sync_fixed_dropdown_panel();
19458                };
19459                let scroll_nodes = [];
19460                let node = this.$refs.container;
19461                while (node && node !== document.documentElement) {
19462                    let style = window.getComputedStyle(node);
19463                    let overflow_y = style.overflowY;
19464                    let overflow_x = style.overflowX;
19465                    let overflow = style.overflow;
19466                    if (overflow_y === 'auto' || overflow_y === 'scroll' || overflow_x === 'auto' || overflow_x === 'scroll' || overflow === 'auto' || overflow === 'scroll') {
19467                        node.addEventListener('scroll', sync_panel, { passive: true });
19468                        scroll_nodes.push(node);
19469                    }
19470                    node = node.parentElement;
19471                }
19472                window.addEventListener('resize', sync_panel);
19473                window.addEventListener('scroll', sync_panel, true);
19474                let visual_viewport = window.visualViewport;
19475                if (visual_viewport) {
19476                    visual_viewport.addEventListener('resize', sync_panel);
19477                    visual_viewport.addEventListener('scroll', sync_panel);
19478                }
19479                return () => {
19480                    scroll_nodes.forEach((scroll_node) => {
19481                        scroll_node.removeEventListener('scroll', sync_panel);
19482                    });
19483                    window.removeEventListener('resize', sync_panel);
19484                    window.removeEventListener('scroll', sync_panel, true);
19485                    if (visual_viewport) {
19486                        visual_viewport.removeEventListener('resize', sync_panel);
19487                        visual_viewport.removeEventListener('scroll', sync_panel);
19488                    }
19489                };
19490            },
19491            teardown_fixed_dropdown_panel_listeners() {
19492                if (this._fixedPanelScrollCleanup) {
19493                    this._fixedPanelScrollCleanup();
19494                    this._fixedPanelScrollCleanup = null;
19495                }
19496            },
19497            selectOption(option) {
19498                if (option && option.disabled) {
19499                    return;
19500                }
19501                var id = optionId(option);
19502                if (this.multiple) {
19503                    var arr = Array.isArray(this.value) ? this.value.slice() : [];
19504                    var idx = arr.indexOf(id);
19505                    if (idx >= 0) {
19506                        arr.splice(idx, 1);
19507                    } else {
19508                        arr.push(id);
19509                    }
19510                    this.$emit('input', arr);
19511                    this.$emit('update:value', arr);
19512                    this.$emit('change', arr);
19513                } else {
19514                    this.$emit('input', option);
19515                    this.$emit('update:value', option);
19516                    this.$emit('change', option);
19517                    this.isOpen = false;
19518                    document.removeEventListener('click', this.handleClickOutside);
19519                }
19520            }
19521        },
19522        beforeDestroy() {
19523            document.removeEventListener('click', this.handleClickOutside);
19524            if (this.closeTimeout) {
19525                clearTimeout(this.closeTimeout);
19526            }
19527            this.teardown_fixed_dropdown_panel_listeners();
19528        }
19529    };
19530    app_content.component("rich-select-dropdown", richSelectDropdownConfig);
19531    app_overlays.component("rich-select-dropdown", richSelectDropdownConfig);
19532})();
19533</script>
19533
19534
19535<style>
19536.rich-select-floating-label {
19537    position: absolute;
19538    left: 10px;
19539    top: -4px;
19540    z-index: 10;
19541    font-size: 11px;
19542    padding-left: 8px;
19543    padding-right: 8px;
19544    background-color: #fff;
19545    color: #8493a0;
19546    text-transform: uppercase;
19547    letter-spacing: 1px;
19548}
19549.rich-select-floating-label .rich-select-trigger-title {
19550    font-size: 11px;
19551    color: #8493a0;
19552}
19553
19554.rich-select-dropdown {
19555    display: inline-block;
19556    min-width: 180px;
19557}
19558
19559.rich-select-trigger {
19560    border: 1px solid #e4ebf1;
19561    border-radius: 8px;
19562    padding: 10px 12px;
19563    min-height: 32px;
19564    -webkit-user-select: none;
19565    user-select: none;
19566    transition: border-color 0.15s ease, box-shadow 0.15s ease;
19567}
19568
19569.rich-select-trigger:hover {
19570    border-color: #c5cdd6;
19571}
19572
19573.rich-select-trigger.rich-select-trigger-borderless,
19574.rich-select-trigger.rich-select-trigger-borderless:hover,
19575.rich-select-trigger.rich-select-trigger-borderless.rich-select-trigger-open {
19576    border-color: transparent;
19577    box-shadow: none;
19578    padding-left: 2px;
19579    padding-right: 2px;
19580}
19581
19582.rich-select-trigger.rich-select-trigger-disabled,
19583.rich-select-trigger.rich-select-trigger-disabled:hover {
19584    background-color: #fbfcfe;
19585    border-color: #e4ebf1;
19586    cursor: default;
19587}
19588
19589.rich-select-trigger.rich-select-trigger-disabled .rich-select-chevron {
19590    opacity: 0.4;
19591}
19592
19593.rich-select-trigger-open,
19594.rich-select-trigger.rich-select-trigger-open {
19595    /* border-color: #2e59d6; */
19596    /* box-shadow: 0 0 0 1px #2e59d6; */
19597    border-color: #e4ebf1;
19598    box-shadow: none;
19599    outline: none;
19600}
19601
19602.rich-select-dropdown:focus-within .rich-select-trigger {
19603    border-color: #e4ebf1;
19604    box-shadow: none;
19605    outline: none;
19606}
19607
19608.rich-select-trigger:focus,
19609.rich-select-trigger:focus-visible,
19610.rich-select-dropdown-panel:focus,
19611.rich-select-dropdown-panel:focus-visible {
19612    border-color: #e4ebf1;
19613    box-shadow: none;
19614    outline: none;
19615}
19616
19617.rich-select-trigger-text {
19618    /* flex: 1; */
19619    min-width: 0;
19620    display: flex;
19621    flex-direction: column;
19622    gap: 2px;
19623}
19624
19625.rich-select-trigger-title {
19626    font-size: 15px;
19627    color: #081333;
19628    font-family: 'roboto', sans-serif;
19629}
19630
19631.rich-select-trigger-title-no-subtext {
19632    font-size: 18px;
19633    font-weight: 100;
19634    color: #081333;
19635    font-family: 'roboto', sans-serif;
19636}
19637
19638.rich-select-trigger-subtext {
19639    font-size: 13px;
19640    color: #83909d;
19641}
19642
19643.rich-select-placeholder {
19644    font-size: 15px;
19645    color: #83909d;
19646}
19647
19648.rich-select-option-icon-wrap {
19649    width: 32px;
19650    height: 32px;
19651    min-width: 32px;
19652    border-radius: 50%;
19653    overflow: hidden;
19654    background-color: #e8eaf1;
19655    display: flex;
19656    align-items: center;
19657    justify-content: center;
19658    margin-right: 10px;
19659}
19660
19661.rich-select-option-icon {
19662    width: 100%;
19663    height: 100%;
19664    object-fit: cover;
19665}
19666
19667.rich-select-option-icon.fa {
19668    width: auto;
19669    height: auto;
19670    font-size: 16px;
19671    color: #52596f;
19672    background: none !important;
19673}
19674.rich-select-option-icon.fa::before {
19675    background: none !important;
19676}
19677
19678.rich-select-chevron {
19679    margin-left: 8px;
19680    font-size: 12px;
19681    color: #83909d;
19682    transition: transform 0.2s ease;
19683}
19684
19685.rich-select-chevron.rich-select-chevron-open {
19686    transform: rotate(180deg);
19687}
19688
19689/* Dropdown panel: floats below trigger, above surrounding content */
19690.rich-select-dropdown-panel {
19691    position: absolute;
19692    top: 100%;
19693    left: 0;
19694    margin-top: 4px;
19695    z-index: 1001;
19696    background-color: #fff;
19697    border: 1px solid #e4ebf1;
19698    border-radius: 5px;
19699    overflow: hidden;
19700    padding: 6px 0;
19701    max-height: 280px;
19702    min-width: 100%;
19703    display: flex;
19704    flex-direction: column;
19705    transform-origin: top center;
19706    animation: rich-select-dropdown-panel-open 0.11s ease-out;
19707}
19708
19709/* v-show flips display, which restarts the animation on every open */
19710@keyframes rich-select-dropdown-panel-open {
19711    from {
19712        opacity: 0;
19713        transform: translateY(-5px) scaleY(0.96);
19714    }
19715    to {
19716        opacity: 1;
19717        transform: translateY(0) scaleY(1);
19718    }
19719}
19720
19721/* Fixed panel: escapes overflow:auto ancestors (position + size from inline style) */
19722.rich-select-dropdown-panel--fixed {
19723    position: fixed;
19724    top: auto;
19725    left: auto;
19726    margin-top: 0;
19727    min-width: 0;
19728    max-height: none;
19729}
19730
19731.rich-select-search-wrap {
19732    padding: 6px 8px;
19733    border-bottom: 1px solid #e4ebf1;
19734    flex-shrink: 0;
19735}
19736
19737.rich-select-search-input {
19738    width: 100%;
19739    border: 1px solid #e4ebf1;
19740    border-radius: 5px;
19741    padding: 8px 10px;
19742    font-size: 14px;
19743    font-family: 'roboto', sans-serif;
19744    outline: none;
19745    box-sizing: border-box;
19746}
19747
19748.rich-select-search-input:focus {
19749    /* border-color: #2e59d6; */
19750    /* box-shadow: 0 0 0 1px #2e59d6; */
19751    border-color: #e4ebf1;
19752    box-shadow: none;
19753    outline: none;
19754}
19755
19756.rich-select-dropdown-list {
19757    flex: 1 1 auto;
19758    overflow-y: auto;
19759    overflow-x: hidden;
19760}
19761
19762.rich-select-option {
19763    padding: 10px 14px;
19764    cursor: pointer;
19765    transition: background-color 0.1s ease;
19766}
19767
19768.rich-select-option:hover {
19769    background-color: #f6f8f9;
19770}
19771
19772.rich-select-option-selected {
19773    background-color: #f0f4ff;
19774}
19775
19776.rich-select-option-check {
19777    margin-right: 10px;
19778    font-size: 18px;
19779    color: #52596f;
19780}
19781
19782.rich-select-option-selected .rich-select-option-check {
19783    color: var(--custom-mp-accent, #2e59d6);
19784}
19785
19786.rich-select-option-text {
19787    flex: 1;
19788    min-width: 0;
19789    display: flex;
19790    flex-direction: column;
19791    gap: 2px;
19792}
19793
19794.rich-select-option-title {
19795    font-size: 15px;
19796    color: #081333;
19797    font-family: 'roboto', sans-serif;
19798}
19799
19800.rich-select-option-subtext {
19801    font-size: 13px;
19802    color: #83909d;
19803}
19804</style>
19805    
19805<script type="text/html" id="template-default-field-wb">
19806    <div class="default-field-wrapper tinyMarginTop">
19807        <span class="flex-row-start">
19808            <p style="color: rgba(255, 0, 0, 0.885); margin: 0 5.5px 0 3px;">*</p>
19809            <p class="default-font-body" style="font-size: 14px; color: rgb(150, 152, 154); font-style: italic;">(required)</p>
19810        </span>
19811        <!-- default fields -->
19812        <div class="flex-row-start" style="width: 100%;">
19813            <div class="flex-column-start" style="width: 100%;">
19814                <template v-if="default_configuration === 'list'" v-for="(field, index) in default_fields" :id="index">
19815                    <div class="flex-row-start smallMarginTop" style="width: 100%;">
19816                        <input
19817                            type="text"
19818                            :placeholder="field.label"
19819                            class="default-field-input border-bottom-grey-2 default-font-body"
19820                            style="width: 100%;"
19821                            v-model="field.input_value"
19822                            :disabled="is_admin"
19823                        >
19824                    </div>
19825                </template>
19826                <template v-if="default_configuration === 'grid'" v-for="(row, i) in default_fields" :id="i">
19827                    <div class="flex-row-start smallMarginTop" style="width: 100%;">
19828                        <template v-for="(field, j) in row" :id="j">
19829                            <input
19830                                type="text"
19831                                :placeholder="field.label"
19832                                class="default-field-input border-bottom-grey-2  default-font-body marginRight"
19833                                style="width: 100%;"
19834                                v-model="field.input_value"
19835                                :disabled="is_admin"
19836                            >
19837                        </template>
19838                    </div>
19839                </template>
19840            </div>
19841        </div>
19842    </div>
19843</script>
19843
19844
19845<style scoped>
19846    :root {
19847        --default-field-grey-1: rgba(180, 180, 180, 0.8);
19848        --default-field-grey-2: rgba(195, 195, 195, 0.7);
19849        --default-field-grey-3: rgb(154, 160, 166);
19850        --default-field-primary: #202124;
19851    }
19852    .default-field-wrapper{
19853        width: 100%;
19854        border: 1px solid rgba(195, 195, 195, 0.7);
19855        border-radius: 8px;
19856        padding: 1.45em;
19857        background-color: #fff;
19858        box-sizing: border-box;
19859    }
19860    .default-field-wrapper .default-font-body{
19861        font-family: Roboto, Arial, Helvetica, sans-serif;
19862        font-size: 15px; 
19863        font-weight: 400;
19864    }
19865    .default-field-wrapper .configuration-icon{
19866        font-size: 1em;
19867        cursor: pointer;
19868        padding: 5px;
19869        border-radius: 50%;
19870        color: rgba(95, 95, 95);
19871        background-color: #fff;
19872        transition: color 0.2s, background-color 0.2s;
19873    }
19874    .default-field-wrapper .configuration-icon:hover{
19875        color: var(--default-field-primary);
19876        background-color: rgba(180, 180, 180, 0.15);
19877    }
19878    .default-field-wrapper .file-upload-wrapper{
19879        height: 90px;
19880        width: 50%;
19881        border: 1px dashed var(--default-field-grey-1);
19882    }
19883    .default-field-wrapper  .inherit-font-css{
19884        font-family: inherit;
19885        font-size: inherit;
19886        font-weight: inherit;
19887    }
19888    .default-field-wrapper .flex-row-center{
19889        display: flex;
19890        flex-direction: row;
19891        align-items: center;
19892        justify-content: center;
19893    }
19894    .default-field-wrapper .flex-column-center{
19895        display: flex;
19896        flex-direction: column;
19897        align-items: center;
19898        justify-content: center;
19899    }
19900    .default-field-wrapper .flex-row-start{
19901        display: flex;
19902        flex-direction: row;
19903        align-items: center;
19904        justify-content: flex-start;
19905    }
19906    .default-field-wrapper .flex-row-end{
19907        display: flex;
19908        flex-direction: row;
19909        align-items: center;
19910        justify-content: flex-end;
19911    }
19912    .default-field-wrapper .flex-column-start{
19913        display: flex;
19914        flex-direction: column;
19915        align-items: flex-start;
19916        justify-content: center;
19917    }
19918    .default-field-wrapper .default-field{
19919        color: rgb(32, 33, 36);
19920        box-sizing: border-box;
19921        font-weight: 400;
19922        font-size: 14px;
19923        width: 100%;
19924        line-height: 2.5;
19925        letter-spacing: 0;
19926    }
19927    .default-field-wrapper .default-field > .default-field-input{
19928        font-family: inherit;
19929        font-size: inherit;
19930        font-weight: inherit;
19931        width: inherit;
19932        line-height: inherit;
19933        background-color: rgb(248, 249, 250);
19934        padding: 5px 0 5px 15px;
19935    }
19936    .default-field-wrapper .border-bottom-grey-2, .default-field-wrapper .input-is-focused{
19937        border-bottom: 1.25px solid var(--default-field-grey-2);
19938    }
19939    .default-field-wrapper input {
19940        transition: border-color 0.2s;
19941    }
19942</style>
19943
19944<script>
19945    app_content.component("default-field-component-wb", {
19946        template: "#template-default-field-wb",
19947        data() {
19948            return {
19949
19950            }
19951        },
19952        props: {
19953            form: Object, //json
19954            is_admin: Boolean,
19955            default_fields: Array,
19956            rerender_key: String,
19957        },
19958        mounted() {
19959
19960        },
19961        methods: {
19962
19963        },
19964        computed: {
19965            default_configuration(){
19966                if ( this.form && this.form.default_configuration ){
19967                    return this.form.default_configuration;
19968                }
19969                return 'list';
19970            },
19971        }
19972    })
19973</script>
19973
19974    
19974<script type="text/html" id="template-file-upload-component">
19975    <div>
19976        <p v-if="label" class="tinyPaddingBottom subtext evenSmaller">{{ label }}</p>
19977        <div 
19978            class="file-upload-container lightBorderAllAround overflowHidden" 
19979            :class="{ 'file-selected': hasFiles, 'upload-loading': loading }"
19980            @dragover.prevent="!loading && (isDragging = true)" 
19981            @dragleave.prevent="!loading && (isDragging = false)" 
19982            @drop.prevent="handle_drop"
19983        >
19984            <div 
19985                class="file-upload-inner relative flexWrap align-items-center flexGrowOne"
19986            >
19987                <input 
19988                    type="file" 
19989                    id="fileInput" 
19990                    ref="fileInput" 
19991                    class="absoluteInput" 
19992                    @change="handle_file_upload"
19993                    :multiple="multiple"
19994                    :accept="acceptAttribute"
19995                    :disabled="loading"
19996                />
19997                <div v-if="loading" class="file-upload-loading-overlay">
19998                    <div class="cardBodySpinner small-spinner centeredBlock active"></div>
19999                </div>
20000                <div 
20001                    class="upload-placeholder flexNoWrapCenter align-items-center"
20002                    :style="{ opacity: loading ? 0.55 : 1 }"
20003                >
20004                    <div>
20005                        <i :class="icon ? icon : 'fa-light fa-file-arrow-up'" class="upload-icon tinyMarginBottom"></i>
20006                        <p class="upload-text">Drag and drop here or click to upload</p>
20007                        <p class="upload-subtext">{{ fileTypeMessage }}</p>
20008                    </div>
20009                </div>
20010            </div>
20011            
20012            <div 
20013                class="selected-file-wrap flexNoWrap lightBorderTop align-items-center"
20014                v-if="hasFiles && !loading"
20015            >
20016                <template v-if="multiple">
20017                    <div class="flexColumn flexGrowOne selected-file-list">
20018                        <div 
20019                            v-for="(file, index) in selectedFiles" 
20020                            :key="file.name + index" 
20021                            class="flexNoWrap align-items-center"
20022                            :class="{ 'smallMarginBottom': index !== selectedFiles.length - 1 }"
20023                        >
20024                            <img
20025                                v-if="imagePreviewURLs[return_file_key(file, index)]"
20026                                :src="imagePreviewURLs[return_file_key(file, index)]"
20027                                class="file-preview-thumb"
20028                                alt="Image preview"
20029                            >
20030                            <p class="selected-file-name flexGrowOne">
20031                                <i class="fa-solid fa-square-check" style="padding-right:3px;"></i> {{ file.name }}
20032                            </p>
20033                            <div 
20034                                class="remove-file-wrap pointer"
20035                                :class="{ unclickable: loading }"
20036                                @click="remove_file(index)" 
20037                            >
20038                                <i class="fa-light fa-trash-can"></i>
20039                            </div>
20040                        </div>
20041                    </div>
20042                </template>
20043                <template v-else>
20044                    <div class="flexNoWrap align-items-center flexGrowOne">
20045                        <img
20046                            v-if="selectedFiles[0] && imagePreviewURLs[return_file_key(selectedFiles[0], 0)]"
20047                            :src="imagePreviewURLs[return_file_key(selectedFiles[0], 0)]"
20048                            class="file-preview-thumb"
20049                            alt="Image preview"
20050                        >
20051                        <p class="selected-file-name flexGrowOne"><i class="fa-solid fa-square-check" style="padding-right:3px;"></i> {{ selectedFiles[0].name }}</p>
20052                    </div>
20053                    <div 
20054                        class="remove-file-wrap pointer"
20055                        :class="{ unclickable: loading }"
20056                        @click="remove_file" 
20057                    >
20058                        <i 
20059                            class="fa-light fa-trash-can" 
20060                        ></i>
20061                    </div>
20062                </template>
20063            </div>
20064
20065        </div>
20066    </div>
20067    
20068</script>
20068
20069
20070<script>
20071
20072    var config = {
20073        template: "#template-file-upload-component",
20074        data() {
20075            return {
20076                selectedFiles: [],
20077                imagePreviewURLs: {},
20078                isDragging: false,
20079                //file type mappings
20080                fileTypeMappings: {
20081                    csv: [".csv", "text/csv", "application/csv", "application/vnd.ms-excel"],
20082                    pdf: [".pdf", "application/pdf"],
20083                    image: [".jpg", ".jpeg", ".png", "image/jpeg", "image/png"],
20084                    docx: [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
20085                    video: [
20086                        ".mp4", ".webm", ".avi", ".mov", ".mkv", ".flv", ".wmv",
20087                        "video/mp4", "video/webm", "video/x-msvideo", "video/quicktime",
20088                        "video/x-matroska", "video/x-flv", "video/x-ms-wmv"
20089                    ]
20090                }
20091            }
20092        }, 
20093        props: {
20094            icon: String,
20095            label: String,
20096            valid_file_types: {
20097                type: Array,
20098                default: () => []
20099            },
20100            multiple: {
20101                type: Boolean,
20102                default: false
20103            },
20104            loading: {
20105                type: Boolean,
20106                default: false,
20107            },
20108        },
20109        mounted(){
20110            
20111        },
20112        beforeUnmount(){
20113            this.revoke_image_previews();
20114        },
20115        beforeDestroy(){
20116            this.revoke_image_previews();
20117        },
20118        methods: {
20119            open_file_picker() {
20120                if( this.loading ) {
20121                    return;
20122                }
20123                this.$refs.fileInput.click();
20124            },
20125            handle_file_upload(event) {
20126                if( this.loading ) return;
20127                const files = Array.from(event.target.files || []);
20128                this.set_files_from_input(files);
20129            },
20130            handle_drop(event) {
20131                if( this.loading ) return;
20132                event.preventDefault();
20133                const files = Array.from(event.dataTransfer.files || []);
20134                this.set_files_from_input(files);
20135                this.isDragging = false;
20136            },
20137            set_files_from_input(files) {
20138                if (!files.length) {
20139                    this.selectedFiles = [];
20140                    return;
20141                }
20142                const validFiles = files.filter(file => this.is_valid_file_type(file));
20143                if (!validFiles.length) {
20144                    alert("Invalid file type. Please upload a valid file.");
20145                    this.selectedFiles = [];
20146                    this.$refs.fileInput.value = "";
20147                    return;
20148                }
20149                if (validFiles.length !== files.length) {
20150                    alert("Some files were skipped because they are not allowed.");
20151                }
20152                if (this.multiple) {
20153                    // Append rather than replace so re-dropping or "add
20154                    // another" grows the batch. Dedupe on name/size/mtime so
20155                    // picking the same file twice doesn't create a duplicate.
20156                    const existingKeys = new Set(
20157                        this.selectedFiles.map(existingFile => `${existingFile.name}_${existingFile.size}_${existingFile.lastModified}`)
20158                    );
20159                    const filesToAdd = validFiles.filter(file => {
20160                        return !existingKeys.has(`${file.name}_${file.size}_${file.lastModified}`);
20161                    });
20162                    this.selectedFiles = this.selectedFiles.concat(filesToAdd);
20163                } else {
20164                    this.selectedFiles = [validFiles[0]];
20165                }
20166            },
20167            is_valid_file_type(file) {
20168                const fileExtension = `.${file.name.split('.').pop().toLowerCase()}`;
20169                const fileType = (file.type || "").toLowerCase();
20170                if (!this.valid_file_types.length){
20171                    // If no restrictions, allow all , but limit to standard types for security
20172                    const defaultAllowedTypes = this.default_file_types;
20173                    return defaultAllowedTypes.includes(fileExtension) || defaultAllowedTypes.includes(fileType);                    
20174                }
20175                
20176                return this.resolvedFileTypes.includes(fileExtension) || this.resolvedFileTypes.includes(fileType);
20177            },
20178            clear_files() {
20179                this.revoke_image_previews();
20180                this.selectedFiles = [];
20181                this.$refs.fileInput.value = "";
20182            },
20183            return_file_key(file, index = 0){
20184                if(!file){
20185                    return '';
20186                }
20187                return `${file.name}_${file.size}_${file.lastModified}_${index}`;
20188            },
20189            is_image_file(file){
20190                if(!file || !file.name){
20191                    return false;
20192                }
20193                const fileType = (file.type || "").toLowerCase();
20194                if(fileType.startsWith("image/")){
20195                    return true;
20196                }
20197                const fileExtension = `.${file.name.split('.').pop().toLowerCase()}`;
20198                return [".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(fileExtension);
20199            },
20200            refresh_image_previews(files){
20201                this.revoke_image_previews();
20202                const nextPreviewURLs = {};
20203                (files || []).forEach((file, index) => {
20204                    if(this.is_image_file(file)){
20205                        const key = this.return_file_key(file, index);
20206                        nextPreviewURLs[key] = URL.createObjectURL(file);
20207                    }
20208                });
20209                this.imagePreviewURLs = nextPreviewURLs;
20210            },
20211            revoke_image_previews(){
20212                Object.values(this.imagePreviewURLs || {}).forEach((url) => {
20213                    try {
20214                        URL.revokeObjectURL(url);
20215                    } catch (e) {
20216                        // no-op
20217                    }
20218                });
20219                this.imagePreviewURLs = {};
20220            },
20221            remove_file(index) {
20222                if( this.loading ) return;
20223                if (this.multiple && typeof index === "number") {
20224                    this.selectedFiles.splice(index, 1);
20225                    if (!this.selectedFiles.length) {
20226                        this.$refs.fileInput.value = "";
20227                    }
20228                    this.$emit("file_selected", {
20229                        value: this.multiple ? this.selectedFiles : this.selectedFiles[0]
20230                    });
20231                } else {
20232                    this.clear_files();
20233                }
20234            }
20235        },
20236        computed: {
20237            hasFiles() {
20238                return this.selectedFiles && this.selectedFiles.length > 0;
20239            },
20240            resolvedFileTypes() {
20241                if(this.valid_file_types && this.valid_file_types.length > 0){
20242                    return this.valid_file_types.flatMap(type => this.fileTypeMappings[type] || [type]);
20243                }else{
20244                    return this.default_file_types;
20245                }
20246            },
20247            acceptAttribute() {
20248                return this.resolvedFileTypes.join(",");
20249            },
20250            fileTypeMessage() {
20251                if (!this.valid_file_types.length) {
20252                    // No restrictions
20253                    return "";
20254                }
20255
20256                // Get only extensions and remove dots for display
20257                const displayTypes = this.valid_file_types
20258                    .flatMap(type => this.fileTypeMappings[type] || [type])
20259                    .filter(type => type.startsWith(".")) // Show only file extensions
20260                    .map(type => type.replace(".", "").toUpperCase()) // Remove dot and uppercase
20261
20262                return `You may upload ${displayTypes.join(", ")} files`;
20263            }, 
20264            default_file_types(){
20265                return [
20266                    // Documents
20267                    ".pdf", "application/pdf",
20268                    ".doc", ".docx",
20269                    "application/msword",
20270                    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
20271                    ".rtf", "application/rtf",
20272                    ".txt", "text/plain",
20273
20274                    // Spreadsheets
20275                    ".xls", ".xlsx", ".csv",
20276                    "application/vnd.ms-excel",
20277                    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
20278                    "text/csv", "application/csv", "application/vnd.ms-excel",
20279
20280                    // Presentations
20281                    ".ppt", ".pptx",
20282                    "application/vnd.ms-powerpoint",
20283                    "application/vnd.openxmlformats-officedocument.presentationml.presentation",
20284
20285                    // Images
20286                    ".jpg", ".jpeg", ".png", ".gif", ".webp",
20287                    "image/jpeg", "image/png", "image/gif", "image/webp",
20288                    // Videos
20289                    ".mp4", ".webm", ".avi", ".mov", ".mkv", ".flv", ".wmv",
20290                    "video/mp4", "video/webm",
20291                ];
20292            }
20293        },
20294        watch: {
20295            selectedFiles(newVal, oldVal){
20296                this.refresh_image_previews(newVal);
20297                this.$emit("file_selected", {
20298                    value: this.multiple ? newVal : newVal[0]
20299                });            
20300            }
20301        }
20302    }
20303
20304    //define the component
20305    app_content.component("file-upload-component", config);
20306    app_overlays.component("file-upload-component", config);
20307
20308
20309</script>
20309
20310
20311<style scoped>
20312
20313    .remove-file-wrap{
20314        padding-left: 4px;
20315        padding-right: 4px;
20316    }
20317
20318    .selected-file-wrap{
20319        padding: 12px;
20320        background-color: #fff;
20321    }
20322
20323    /* Ensure both children take full width but account for padding */
20324    .file-upload-inner, .selected-file-wrap {
20325        width: 100%;
20326        flex: 1; /* Make sure they stretch */
20327        box-sizing: border-box; /* Includes padding in width calculation */
20328        padding: 12px; /* Add your desired padding */
20329    }
20330
20331    .selected-file-name{
20332        color: #292e4a;
20333        font-size: 16px;
20334        line-height: 20px;
20335    }
20336    .file-preview-thumb{
20337        width: 42px;
20338        height: 42px;
20339        object-fit: cover;
20340        border-radius: 6px;
20341        border: 1px solid #e4ebf1;
20342        margin-right: 10px;
20343        flex-shrink: 0;
20344    }
20345
20346    .file-upload-inner{
20347        min-height: 160px;
20348    }
20349    .file-upload-container {
20350        display: flex;
20351        flex-direction: column;
20352        align-items: center;
20353        justify-content: center;
20354        width: 100%;
20355        border-radius: 8px;
20356        background-color: #f8f9fb;
20357        cursor: pointer;
20358        transition: all 0.3s ease;
20359    }
20360    .file-upload-container.upload-loading {
20361        cursor: not-allowed;
20362    }
20363    .file-upload-loading-overlay {
20364        position: absolute;
20365        top: 0;
20366        right: 0;
20367        bottom: 0;
20368        left: 0;
20369        z-index: 3;
20370        display: flex;
20371        justify-content: center;
20372        align-items: center;
20373        background: rgba(255, 255, 255, 0.6);
20374    }
20375    .file-upload-container.upload-loading .upload-text,
20376    .file-upload-container.upload-loading .upload-subtext,
20377    .file-upload-container.upload-loading .selected-file-name {
20378        color: transparent !important;
20379    }
20380    .file-upload-container.upload-loading .upload-icon {
20381        color: transparent !important;
20382    }
20383    .file-upload-container:hover:not(:has(.selected-file-wrap:hover)), 
20384    .file-upload-container.dragging:not(:has(.selected-file-wrap:hover)) {
20385        border-color: var(--custom-mp-accent, #4a56f8);
20386    }
20387    .file-upload-container:hover .upload-icon:not(:has(.selected-file-wrap:hover)), 
20388    .file-upload-container.dragging .upload-icon:not(:has(.selected-file-wrap:hover)){
20389        color: var(--custom-mp-accent, #4a56f8);
20390    }
20391
20392    .upload-placeholder {
20393        text-align: center;
20394        padding: 20px;
20395    }
20396
20397    .upload-icon{
20398        color: #788097;
20399        font-size: 28px;
20400        padding-right: 16px;
20401    }
20402    .upload-text {
20403        font-size: 16px;
20404        font-weight: 500;
20405        color: #081333;
20406    }
20407    .upload-subtext {
20408        font-size: 14px;
20409        color: #8890a3;
20410        padding-top: 4px;
20411        line-height: 18px;
20412    }
20413</style>
20414    
20414<script type="text/html" id="template-no-objects-found">
20415    
20416    <div 
20417        class="marginTopBottom"
20418        style="display: flex; flex-direction: column; justify-content: center; align-items: center;"
20419    >
20420        <i 
20421            :class="icon_class" 
20422            class="no-objects-icon"
20423        ></i>
20424        <p class="no-objects-subtext">{{ subtext }}</p>
20425        <p v-if="call_to_action" class="no-objects-subtext"><a @click="call_to_action.fn">{{ call_to_action.text }}</a></p>
20426    </div>
20427
20428</script>
20428
20429
20430<script>
20431
20432/* ----- COMPONENT ----- */
20433
20434let no_objects_config = {
20435    template: "#template-no-objects-found",
20436    data() {
20437        return {}
20438    },
20439    props: {
20440        icon_class: {
20441            type: String,
20442            required: true,
20443            default: "fa-regular fa-magnifying-glass",
20444        },
20445        subtext: {
20446            type: String,
20447            required: true,
20448            default: "No items found.",
20449        },
20450        call_to_action: {
20451            type: Object,
20452            required: false,
20453            default: null,
20454        }
20455    },
20456    mounted() {
20457        
20458    
20459    },
20460    methods: {
20461        
20462
20463    }
20464
20465}
20466app_content.component("no-objects-found", no_objects_config);
20467app_overlays.component("no-objects-found", no_objects_config);
20468if (typeof app_ai_mcp !== 'undefined') {
20469    app_ai_mcp.component("no-objects-found", no_objects_config);
20470}
20471
20472</script>
20472
20473
20474<style>
20475    .no-objects-icon{
20476        font-size: 18px;
20477        padding: 15px;
20478        background-color: rgba(230, 232, 237, 0.667);
20479        border-radius: 50%;
20480        color: #6a7482;
20481        height: 20px;
20482        width: 20px;
20483        text-align: center;
20484    }
20485    .no-objects-subtext{
20486        font-size: 16px;
20487        padding: 2px;
20488        margin-top: 5px;
20489        color: #9ca4b7;
20490        text-align: center;
20491    }
20492</style>
20493
20494
20495
20496    
20496<script type="text/html" id="template-icon-button">
20497     <button    
20498        class="card-header-button-v3 easeFast flexNoWrap align-items-center relative icon-button centeredText flexGrowOne"
20499        :class="[
20500            {'unclickable': disabled || loading}, 
20501            {'full-width': full_width}, 
20502            {'warning': warning},
20503            {'text-hides-on-mobile': hide_text_on_mobile}
20504        ]"
20505        :disabled="disabled || loading"
20506        @click="tapped_button()"
20507        style="width: auto; max-width: fit-content;"
20508        :style="height ? {'height': height + 'px', 'line-height': height + 'px', 'padding': '0 8px'} : {}"
20509        :style="no_margin_right ? {'margin-right': '0'} : {}"
20510    >
20511        <i 
20512            v-if="corrected_icon_position == 'left' && ! image_url"
20513            :class="icon_class" 
20514            :style="{ opacity: loading ? 0 : 1 }"
20515        ></i>
20516        <div
20517            v-else-if="corrected_icon_position == 'left' && image_url"
20518            class="icon-button-avatar"
20519            :style="{ backgroundImage: 'url(' + image_url + ')', opacity: loading ? 0 : 1 }"
20520        ></div>
20521        <span :style="{ opacity: loading ? 0 : 1 }" :class="{ 'hideOnMobile': hide_text_on_mobile }">{{ label }}</span>
20522        <i 
20523            v-if="corrected_icon_position == 'right' && ! image_url"
20524            :class="icon_class" 
20525            style="padding-left: 8px;"
20526            :style="{ opacity: loading ? 0 : 1 }"
20527        ></i>
20528        <div
20529            v-else-if="corrected_icon_position == 'right' && image_url"
20530            class="icon-button-avatar icon-button-avatar-right"
20531            :style="{ backgroundImage: 'url(' + image_url + ')', opacity: loading ? 0 : 1 }"
20532        ></div>
20533        <div v-if="loading" class="icon-button-spinner-wrap">
20534            <div class="card-header-button-v3-spinner easeFast"></div>
20535        </div>
20536        <a v-if="href" :href="href" :target="target" class="absoluteA"></a>
20537    </button>
20538</script>
vendor: 1 bytes, line 20538
20538
20539<script>
20540    var config = {
20541        template: "#template-icon-button",       
20542        props: {
20543            disabled: {
20544                type: Boolean,
20545                default: false,
20546            },
20547            label: {
20548                type: String,
20549                default: 'Done',
20550            },
20551            icon_class: String,
20552            image_url: {
20553                type: String,
20554                default: null,
20555            },
20556            icon_position: {
20557                type: String,
20558                default: 'left',
20559            },
20560            loading:  {
20561                type: Boolean,
20562                default: false,
20563            },
20564            href: {
20565                type: String,
20566                default: null,
20567            },
20568            target: {
20569                type: String,
20570                default: '',
20571            },
20572            full_width: {
20573                type: Boolean,
20574                default: false,
20575            },
20576            warning: {
20577                type: Boolean,
20578                default: false,
20579            },
20580            no_margin_right: {
20581                type: Boolean,
20582                default: false,
20583            },
20584            hide_text_on_mobile: {
20585                type: Boolean,
20586                default: false,
20587            }
20588        },
20589        methods: {
20590            tapped_button() {
20591                if (this.loading || this.disabled) return;
20592                this.$emit('clicked');
20593            },
20594        },
20595        computed: {
20596            corrected_icon_position(){
20597                return this.icon_position || 'left';
20598            },
20599        },
20600    }
20601    app_content.component("icon-button", config);
20602    if (typeof app_overlays !== "undefined") {
20603        app_overlays.component("icon-button", config);
20604    }
20605
20606</script>
20606
20607
20608<style>
20609    .icon-button.full-width{
20610        width: 100% !important;
20611        max-width: unset !important;
20612        justify-content: center !important;
20613        text-align: center !important;
20614        line-height: 30px;
20615        font-size: 15px;
20616    }
20617    .icon-button.taller {
20618        padding-top: 12px;
20619        padding-bottom: 12px;
20620    }
20621    .icon-button.warning {
20622        color: #9b1818 !important;
20623    }
20624    .icon-button-spinner-wrap {
20625        position: absolute;
20626        left: 0;
20627        right: 0;
20628        top: 0;
20629        bottom: 0;
20630        display: flex;
20631        justify-content: center;
20632        align-items: center;
20633    }
20634    .icon-button-avatar {
20635        width: 18px;
20636        height: 18px;
20637        border-radius: 50%;
20638        background-size: cover;
20639        background-position: center;
20640        background-repeat: no-repeat;
20641        flex-shrink: 0;
20642        margin-right: 4px;
20643    }
20644    .icon-button-avatar-right {
20645        margin-right: 0;
20646        margin-left: 4px;
20647    }
20648</style>
20649    <!-- new calendar -->
20650    <template id="template-booking-calendar">
20651    <div
20652        class="centeredOnMobile smallMarginTop"
20653        :class="{'standardCard ninetyPercent': calendar_type !== 'web_component'}"
20654    >
20655        <div
20656            class="standardCardBody noBorderTop calendar noPadding fff native-tab-bar-padding-bottom"
20657        >
20658           <div class="flexNoWrapLeftAlign hundop" style="display: flex; width: 100%;" :style="[{ height: height || '100vh' }]">
20659                <div
20660                    style="flex: 1; min-width: 0;"
20661                    :class="{ scrollable: calendar_type !== 'web_component' }"
20662                >
20663                  <div
20664                      class="cs-calendar-wrap noBorder"
20665                      :style="generateWrapStyle('cs-calendar-wrap')"
20666                  >
20667                    <!-- NAVIGATION MENU -->
20668                    <div
20669                        class="cs-calendar-menu-wrap flexNoWrap lightBorderBottom"
20670                        :style="generateWrapStyle('cs-calendar-menu-wrap')"
20671                    >
20672                        <div class="flexWrapLeftAlign flexGrowOne smallMarginRight align-items-center">
20673                            <div class="flexNoWrap">
20674                                <div class="card-header-button-v3 roboto hideOnMobile" data-option="today" @click="handleButtonClick('today')">Today</div>
20675                                <div class="card-header-button-v3 cs-calendar-button-previous roboto" data-option="previous" @click="handleButtonClick('previous')"></div>
20676                                <div class="card-header-button-v3 cs-calendar-button-next roboto" data-option="next" @click="handleButtonClick('next')"></div>
20677                            </div>
20678                            <p
20679                                v-if="calendarTitle"
20680                                class="cs-calendar-date-range-title roboto textLeft noMarginLeft hideOnMobile"
20681                            >{{ calendarTitle }}</p>
20682                            <p
20683                                v-if="calendarTitle_noYear"
20684                                class="cs-calendar-date-range-title roboto textLeft noMarginLeft showOnMobile"
20685                            >{{ calendarTitle_noYear }}</p>
20686                            <div class="flexGrowOne hideOnMobile" style="max-width: fit-content;">
20687                                <refresh-component
20688                                    @refresh-table="refresh_calendar"
20689                                    style="margin-left: 2px;"
20690                                ></refresh-component>
20691                            </div>
20692                        </div>
20693
20694                        <!-- nav butttons -->
20695                        <div class="flexWrapLeftAlign align-items-center">
20696                            <div class="flexGrowOne"></div>
20697                             <div class="flexWrap align-items-center calendar-menu-v2">
20698                                <calendar-date-picker-component
20699                                    :handle_button_click="handleButtonClick"
20700                                ></calendar-date-picker-component>
20701                            </div>
20702
20703                            <div
20704                                class="hideOnMobile card-header-button-v3 pointer noWrap centeredText relative"
20705                            >
20706                                {{ view_preference }}
20707                                <i class="fa-light fa-chevron-down" style="padding-left: 3px;"></i>
20708                                <select
20709                                    v-model="v_model_view_preference"
20710                                    @change="changeViewPreference"
20711                                    class="pointer"
20712                                    style="position:absolute;
20712left:0px;top:0px;width:100%;height:100%;opacity:0;"
20713                                >
20714                                    <option value="Month">Month</option>
20715                                    <option value="Week">Week</option>
20716                                    <option value="Day">Day</option>
20717                                </select>
20718                            </div>
20719                            <div 
20720                                v-if="!show_calendar_tags && !disable_filtering"
20721                                class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop"
20722                            >
20723                                <h3 
20724                                    class="calendar-list menu-icon"
20725                                >
20726                                    <i 
20727                                        class="fa-regular fa-bars pointer"
20728                                        @click="show_calendar_tags = !show_calendar_tags"
20729                                    ></i>
20730                                </h3>
20731                            </div>
20732                        </div>                      
20733                     
20734                    </div>
20735
20736                    <div
20737                        class="relative"
20738                    >
20739                        <!-- Progress Bar -->
20740                        <div v-if="loading_parent.calendar || loading_parent.ui_refresh || loading_parent.calendar_tags || loading_parent.filter" class="progress-bar"></div>
20741                    </div>
20742
20743                    <!-- CALENDAR VIEWS -->
20744
20745                    <!-- month view -->
20746                    <template v-if="view_preference == 'Month'">
20747                        <!-- days of week header -->
20748                        <div
20749                            class="daysOfWeek-grid relative"
20750                        >
20751                            <div v-for="(day, index) in daysOfWeek" :key="index" class="grid-cell dayOfWeek noBorderTop">
20752                                {{ day }}
20753                            </div>
20754                        </div>
20755
20756                        <!-- calendar grid -->
20757                        <div
20758                            class="calendar-grid month-view"
20759                            :style="{ '--calendar-row-height': calendarRowHeight() }"
20760                        >
20761                            <template v-for="(day, index) in days_array" :key="index">
20762                                <booking-calendar-cell
20763                                    :date="day.date"
20764                                    :day="day.day"
20765                                    :date_components="day.date_components"
20766                                    :day_of_week="day.day_of_week"
20767                                    :events="day.events"
20768                                    :slots="day.slots"
20769                                    :timezone="timezone"
20770                                    :days_array="days_array"
20771                                    :is_current_month="day.date.month() === current_date.month()"
20772                                    @clicked_event="clicked_event"
20773                                    @clicked_show_more_events="clicked_show_more_events"
20774                                    @add_event="add_event"
20775                                    @zoom_in_to_date="zoom_in_to_date"
20776                                />
20777                            </template>
20778                        </div>
20779
20780                    </template>
20781
20782                    <!-- week view -->
20783                    <template v-else-if="view_preference === 'Week'" >
20784
20785                        <!-- multi/all day events section -->
20786                        <div class="flexNoWrap">
20787                            <div style="width:60px;" class="flexWrap align-items-center">
20788                                <div class="time-label">All day</div>
20789                            </div>
20790                            <div class="flexGrowOne">
20791                                <!-- days of week header -->
20792                                <div
20793                                    class="daysOfWeek-grid relative"
20794                                >
20795                                    <div v-for="(day, index) in daysOfWeek" :key="index" class="grid-cell dayOfWeek noBorderTop">
20796                                        {{ day }}
20797                                        <br>
20798                                        <span class="day-date-label">{{ current_date.clone().startOf("week").add(index, "day").date() }}</span>
20799                                    </div>
20800                                </div>
20801                                <div
20802                                    class="all-day-week-wrap"
20803                                >
20804                                    <div v-for="(day, index) in days_array" :key="index" class="grid-cell noBorderTop">
20805                                        <div class="events">
20806                                            <!-- handle all day events -->
20807                                            <!-- render content for each slot -->
20808                                            <template
20809                                                v-for="(slot, slot_index) in day.slots"
20810                                                :key="slot_index"
20811                                            >
20812                                                <!-- ok, we have an event for this slot-->
20813                                                <template v-if="slot.event">
20814                                                    <div
20815                                                        class="event"
20816                                                        :class="[{'all_day': slot.event.isAllDay}, {'partial-day': ! slot.event.isAllDay}]"
20817                                                        @click.stop="clicked_event( slot.event )"
20818                                                        :style="return_multi_day_event_style( slot.event, day.date )"
20819                                                        style="position:relative;z-index:1;"
20820                                                    >
20821                                                        <div
20822                                                            class="event-title-v2 flexNoWrap align-items-center"
20823                                                            style="font-size:12.5px;"
20824                                                        >
20825                                                            <!-- event title -->
20826                                                            {{ slot.event.title }}
20827                                                        </div>
20828                                                    </div>
20829                                                </template>
20830                                                <template v-else-if="should_hold_slot(slot, slot_index, day)">
20831                                                    <div
20832                                                        class="event"
20833                                                    >
20834                                                        <!-- add a placeholder if there's an event in this slot but it doesn't start on this day -->
20835                                                        <div
20836                                                            class="event-title-v2 flexNoWrap align-items-center easeSuperFast"
20837                                                            style="font-size:12.5px; visibility: hidden;"
20838                                                        >&nbsp;</div><!-- &nbsp; adds a hidden, non-breaking space -->
20839                                                    </div>
20840                                                </template>
20841
20842                                                <!-- no event for this slot -->
20843                                                <template v-else>
20844                                                    <div
20845                                                        class="event partial-day"
20846                                                    ></div>
20847                                                </template>
20848
20849                                            </template>
20850
20851                                        </div>
20852                                    </div>
20853                                </div>
20854                            </div>
20855                        </div>
20856
20857                        <!-- main content -->
20858                        <div
20859                            class="flexNoWrap"
20860                        >
20861                            <!-- times -->
20862                            <div
20863                                class="time-label-wrap"
20864                                style="width: 60px;"
20865                            >
20866                                <div v-for="hour in 24" :key="hour" class="time-slot">
20867                                    <div
20868                                        class="time-label"
20869                                    >{{ formatHour( hour - 1 ) }}</div>
20870                                </div>
20871                            </div>
20872
20873                            <!-- main content -->
20874                            <div class="flexGrowOne">
20875
20876                                <!-- day columns -->
20877                                <div class="flexNoWrap">
20878                                    <template
20879                                        v-for="(day, index) in days_array"
20880                                        :key="index"
20881                                    >
20882                                        <booking-calendar-day-column
20883                                            :events="day.events"
20884                                            :date_components="day.date_components"
20885                                            :timezone="timezone"
20886                                            :view_preference="view_preference"
20887                                            @clicked_event="clicked_event"
20888                                            @add_event="add_event"
20889                                        ></booking-calendar-day-column>
20890                                    </template>
20891                                </div>
20892
20893                            </div>
20894                        </div>
20895                    </template>
20896
20897                    <!-- day view -->
20898                    <template v-else-if="view_preference === 'Day'">
20899                        <div class="showOnMobile">
20900                            <icon-button
20901                                :label="'Back to month'"
20902                                icon_class="fa-regular fa-chevron-left"
20903                                @clicked="back_to_month"
20904                                :style="[{ marginLeft: '8px' }, calendar_type !== 'web_component' ? { maxHeight: '28px' } : {}]"
20905                            ></icon-button>
20906                        </div>
20907                        <div
20908                            class="flexNoWrap tinyPaddingTop"
20909                        >
20910                            <booking-calendar-day-column
20911                                :events="days_array[0].events"
20912                                :date_components="days_array[0].date_components"
20913                                :view_preference="view_preference"
20914                                :timezone="timezone"
20915                                @clicked_event="clicked_event"
20916                                @add_event="add_event"
20917                            />
20918                        </div>
20919                    </template>
20920                  </div>
20921                </div>
20922                <!-- start of new component -->
20923                <booking-calendar-filters
20924                    v-if="!disable_filtering"
20925                    :show_calendar_tags="show_calendar_tags"
20926                    :default_calendar_tags="default_calendar_tags"
20927                    :default_calendar_tags_secondary="default_calendar_tags_secondary"
20928                    :show_catch_all_custom_tags_option="show_catch_all_custom_tags_option"
20929                    :catch_all_custom_tag_selected="catch_all_custom_tag_selected"
20930                    :ui_catch_all_custom_tag_selected="ui_catch_all_custom_tag_selected"
20931                    :ui_tag_ids_default_secondary="ui_tag_ids_default_secondary"
20932                    :view_preference="view_preference"
20933                    :calendar_tags="calendar_tags"
20934                    :selected_tags="selected_tags"
20935                    :ui_tag_ids_default="ui_tag_ids_default"
20936                    :ui_tag_ids_custom="ui_tag_ids_custom"
20937                    :collapsible_menus_open="collapsible_menus_open"
20938                    :loading_parent="loading_parent"
20939                    :calendar_type="calendar_type"
20940                    :view_only="view_only"
20941                    @clicked_default_tag="clicked_default_tag"
20942                    @clicked_default_tag_secondary="clicked_default_tag_secondary"
20943                    @clicked_calendar_tag="clicked_calendar_tag"
20944                    @clicked_catch_all_custom_tag="clicked_catch_all_custom_tag"
20945                    @clicked_add_calendar_tag="clicked_add_calendar_tag"
20946                    @clicked_remove_tag="clicked_remove_tag"
20947                    @isolate_default_tag="isolate_default_tag"
20948                    @isolate_default_tag_secondary="isolate_default_tag_secondary"
20949                    @select_all_default_tags="select_all_default_tags"
20950                    @select_all_default_tags_secondary="select_all_default_tags_secondary"
20951                    @select_visible_default_tags_secondary="select_visible_default_tags_secondary"
20952                    @clear_all_default_tags="clear_all_default_tags"
20953                    @clear_all_default_tags_secondary="clear_all_default_tags_secondary"
20954                    @clear_visible_default_tags_secondary="clear_visible_default_tags_secondary"
20955                    @select_visible_default_tags="select_visible_default_tags"
20956                    @clear_visible_default_tags="clear_visible_default_tags"
20957                    @isolate_calendar_tag="isolate_calendar_tag"
20958                    @isolate_catch_all_custom="isolate_catch_all_custom"
20959                    @select_all_custom_tags="select_all_custom_tags"
20960                    @clear_all_custom_tags="clear_all_custom_tags"
20961                    @select_visible_custom_tags="select_visible_custom_tags"
20962                    @clear_visible_custom_tags="clear_visible_custom_tags"
20963                    @add_event="add_event"
20964                    @change_view_preference="changeViewPreference"
20965                    @change_show_calendar_tags="change_show_calendar_tags"
20966                    @close="show_calendar_tags = false"
20967                ></booking-calendar-filters>
20968            </div>
20969        </div>
20970    </div>
20971  </template>
20972
20973  <style scoped>
20974    .cs-calendar-menu-wrap{
20975        position: sticky;
20976        top: 0px;
20977        background-color: #fff;
20978        z-index: 3;
20979    }
20980    .calendar-list.menu-icon{
20981        min-width: 38px !important;
20982        max-width: 38px !important;
20983        width: 38px !important;
20984        min-height: 38px !important;
20985        max-height: 38px !important;
20986        height: 38px !important;
20987        padding: 0;
20988        display: flex;
20989        justify-content: center;
20990        align-items: center;   
20991        cursor: pointer;
20992    }
20993
20994    .day-date-label{
20995        font-size: 20px;
20996        color: #081333;
20997    }
20998
20999    .cs-calendar-date-range-title {
21000        font-size: 18px !important;
21001    }
21002
21003    .calendar-menu-v2 .cs-calendar-date-picker-input{
21004        border-radius: 30px;
21005        overflow: hidden !important;
21006        padding-bottom: 8px;
21007        padding-top: 8px;
21008    }
21009
21010    .cs-calendar-button-next,
21011    .cs-calendar-button-previous{
21012        min-width: 38px !important;
21013        max-width: 38px !important;
21014        width: 38px !important;
21015        min-height: 38px !important;
21016        max-height: 38px !important;
21017        height: 38px !important;
21018    }
21019
21020    .daysOfWeek-grid{
21021        grid-auto-rows: initial;
21022    }
21023
21024    .all-day-week-wrap{
21025        display: grid;
21026        grid-template-columns: repeat(7, minmax(10%, 1fr));
21027        border-bottom: 1px solid #e7ecf0;
21028    }
21029
21030    .progress-bar {
21031        position: absolute;
21032        top: 0;
21033        left: 0;
21034        height: 2px; /* Thin progress bar */
21035        background-color: #5774f5; /* Primary color */
21036        z-index: 10; /* Ensure it appears above the calendar */
21037        animation: progress-bar-animation 0.5s linear infinite; /* Animation */
21038    }
21039
21040    /* Animation Keyframes */
21041    @keyframes progress-bar-animation {
21042        0% {
21043            left: -100%; /* Start outside of the viewport */
21044            width: 30%; /* Start with a small bar */
21045        }
21046        50% {
21047            left: 25%; /* Move towards the center */
21048            width: 50%; /* Expand the bar */
21049        }
21050        100% {
21051            left: 100%; /* End outside of the viewport */
21052            width: 30%; /* Shrink the bar */
21053        }
21054    }
21055
21056    .calendar-grid.month-view{
21057        grid-auto-rows: var(--calendar-row-height, 130px);
21058    }
21059
21060    .grid-cell.dayOfWeek{
21061        border-radius: 5px;
21062    }
21063
21064    .time-content {
21065        position: relative;
21066        height: 100%; /* Full height of the hour */
21067        flex-grow: 1;
21068        border-left: 1px solid #e7ecf0;
21069    }
21070
21071    /* Day Header */
21072    .day-header {
21073        background-color: white;
21074        padding: 16px;
21075        border-bottom: 1px solid #e7ecf0;
21076    }
21077
21078    .day-title {
21079        margin: 0;
21080        font-size: 24px;
21081        font-weight: bold;
21082        color: #202124;
21083    }
21084
21085    /* All Day Section */
21086    .all-day-section {
21087        padding: 12px;
21088        border-bottom: 1px solid #e7ecf0;
21089        width: 100%;
21090    }
21091
21092    .all-day-section h3 {
21093        font-size: 16px;
21094        font-weight: bold;
21095        color: #5f6368;
21096        margin-bottom: 8px;
21097    }
21098
21099    .all-day-events {
21100        display: flex;
21101        flex-wrap: wrap;
21102        gap: 8px;
21103        padding-left: 12px;
21104        padding-right: 12px;
21105    }
21106
21107    .no-events {
21108        color: #9e9e9e;
21109        font-size: 12px;
21110    }
21111
21112    /* Time Slots */
21113    .time-slots {
21114        flex-grow: 1;
21115        overflow-y: auto;
21116        background-color: white;
21117        width:100%;
21118    }
21119
21120    .time-slot {
21121        display: flex;
21122        border-bottom: 1px solid #e7ecf0;
21123        height: 60px; /* One slot per hour */
21124    }
21125
21126    .time-label {
21127        width: 60px;
21128        text-align: center;
21129        font-size: 12px;
21130        padding-top: 8px;
21131        color: #5f6368;
21132    }
21133
21134  </style>
21135
21136  
21136<script>
21137    app_content.component("calendar-booking-component", {
21138        template: "#template-booking-calendar",
21139        data() {
21140            return {
21141                show_calendar_tags: true,
21142                v_model_view_preference: this.view_preference,
21143                daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
21144                loading: {
21145                    calendar_tags: true,
21146                },
21147                catch_all_custom_tag_selected: true,
21148                ui_catch_all_custom_tag_selected: true,
21149                collapsible_menus_open: {
21150                    default_primary: true,
21151                    default_secondary: true,
21152                    custom: true,
21153                },
21154            };
21155        },
21156        mounted(){
21157            this.check_if_mobile();
21158        },
21159        props: {
21160            filter_logic: {
21161                type: String,
21162                default: "or" //and vs. or
21163            },
21164            height: String, //optional
21165            timezone: {
21166                type: String,
21167                default: moment.tz.guess(),
21168            },
21169            loading_parent: Object,  //object with several loading attributes
21170            view_preference: String,
21171            current_date: Date,
21172            events: Array,
21173            is_membership_cal: Boolean,
21174            calendar_type: String, 
21175            default_calendar_tags: Object,
21176            default_calendar_tags_secondary: Object,
21177            calendar_tags: Array,
21178            selected_tags: Object,
21179            ui_tag_ids_default: Object,
21180            ui_tag_ids_default_secondary: Object,
21181            ui_tag_ids_custom: Array,
21182            is_admin: Boolean, 
21183            view_only: {
21184                type: Boolean,
21185                default: false
21186            },
21187            disable_filtering: {
21188                type: Boolean,
21189                default: false
21190            }
21191        },
21192        methods: {
21193            back_to_month(){
21194                this.$emit("change_view_preference", "Month");
21195            },
21196            check_if_mobile(){
21197                if(this.disable_filtering){
21198                    this.show_calendar_tags = false;
21199                    return;
21200                }
21201                var is_mobile = window.innerWidth < 720;
21202                if(is_mobile || this.calendar_type === 'web_component'){
21203                    this.show_calendar_tags = false;
21204                }else{
21205                    this.show_calendar_tags = true;
21206                }
21207            },
21208            clicked_default_tag(tag) {
21209                this.$emit('clicked_default_tag', tag);
21210            },
21211            clicked_default_tag_secondary(tag) {
21212                this.$emit('clicked_default_tag_secondary', tag);
21213            },
21214            isolate_default_tag_secondary(tag) {
21215                this.$emit('isolate_default_tag_secondary', tag);
21216            },
21217            select_all_default_tags_secondary() {
21218                this.$emit('select_all_default_tags_secondary');
21219            },
21220            select_visible_default_tags_secondary(visibleIds) {
21221                this.$emit('select_visible_default_tags_secondary', visibleIds);
21222            },
21223            clear_all_default_tags_secondary() {
21224                this.$emit('clear_all_default_tags_secondary');
21225            },
21226            clear_visible_default_tags_secondary(visibleIds) {
21227                this.$emit('clear_visible_default_tags_secondary', visibleIds);
21228            },
21229            clicked_calendar_tag(tag) {
21230                this.$emit('clicked_calendar_tag', tag);
21231            }, 
21232            clicked_catch_all_custom_tag(){
21233                this.ui_catch_all_custom_tag_selected = !this.ui_catch_all_custom_tag_selected;
21234                requestAnimationFrame(() => {
21235                    // second RAF helps ensure we’re past layout/paint on Chrome/Safari
21236                    requestAnimationFrame(() => {
21237                        this.catch_all_custom_tag_selected = !this.catch_all_custom_tag_selected;
21238                    });
21239                });   
21240            },
21241            remaining_days_in_event(event, date) {
21242                const cellDate = moment.tz(date, this.timezone).startOf("day");
21243                const eventEnd = moment.tz(event.end_ts, this.timezone).startOf("day");
21244                if (cellDate.isAfter(eventEnd)) {
21245                    //already ended
21246                    return 0;
21247                }
21248                // Calculate the remaining days, including the current day
21249                return eventEnd.diff(cellDate, "days") + 1;
21250            },
21251            remaining_visible_days_in_event(event, date) {
21252                const cellDate = moment.tz(date, this.timezone).startOf("day");
21253                const eventEnd = moment.tz(event.end_ts, this.timezone).endOf("day");
21254                const eventStart = moment.tz(event.ts, this.timezone).startOf("day");
21255                const startOfWeek = this.current_date.clone().startOf("week");
21256                const endOfWeek = this.current_date.clone().endOf("week");
21257
21258                // Determine the latest starting point between cellDate and eventStart
21259                const visibleStart = moment.max(cellDate, eventStart, startOfWeek);
21260
21261                // Determine the earliest end point between eventEnd and endOfWeek
21262                const visibleEnd = moment.min(eventEnd, endOfWeek);
21263
21264                // If the visible start is after the visible end, the event isn't visible
21265                if (visibleStart.isAfter(visibleEnd)) {
21266                    return 0;
21267                }
21268
21269                // Calculate number of visible days the event should span, inclusive
21270                return visibleEnd.diff(visibleStart, "days") + 1;
21271            },
21272            return_multi_day_event_style( event, date ){
21273                if( ! this.is_multi_day_event( event ) ){
21274                    return {
21275                        "background": event.bgColors && event.bgColors.length > 0
21276                            ? `repeating-linear-gradient(
21277                                    -45deg,
21278                                    ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")}
21279                                )`
21280                            : event.bgColor || "",
21281                    };
21282                }
21283                let day_of_week = moment.tz(date, this.timezone).day();
21284                let days_until_end_of_week = 7 - day_of_week;
21285                let number_of_days = this.remaining_visible_days_in_event(event, date);
21286                let is_first_day = this.is_first_day_of_event( event, date );
21287                if( is_first_day ){
21288                    //figure out how many days it spans
21289                    let days_to_span = number_of_days;
21290                    if( days_to_span > days_until_end_of_week ){
21291                        days_to_span = days_until_end_of_week;
21292                    }
21293                    return {
21294                        "width": `calc(${days_to_span * 100}% - 4px)`,
21295                        "zIndex": 1,
21296                        "background": event.bgColors && event.bgColors.length > 0
21297                            ? `repeating-linear-gradient(
21298                                    -45deg,
21299                                    ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")}
21300                                )`
21301                            : event.bgColor || "",
21302                    }
21303                }else if( day_of_week == 0 ){
21304                    //not the first day of the event
21305                    //.. but see it's the first day of a new week
21306                    //... figure out which day of the event this is, and how many days are left (including this day)
21307                    let remaining_days_in_event = this.remaining_days_in_event( event, date );
21308                    let days_to_span = remaining_days_in_event;
21309                    if( days_to_span > days_until_end_of_week ){
21310                        days_to_span = days_until_end_of_week;
21311                    }
21312                    return {
21313                        "width": `calc(${days_to_span * 100}% - 4px)`,
21314                        "zIndex": 1,
21315                        "background": event.bgColors && event.bgColors.length > 0
21316                            ? `repeating-linear-gradient(
21317                                    -45deg,
21318                                    ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")}
21319                                )`
21320                            : event.bgColor || "",
21321                    }
21322                }else{
21323                    return {
21324                        "visibility":"hidden"
21325                    }
21326                }
21327            },
21328            zoom_in_to_date( date ){
21329                this.$emit("zoom_in_to_date", date);
21330            },
21331            clicked_event( event ){
21332                this.$emit('clicked_event', event);
21333            },
21334            clicked_show_more_events( date, day_of_week, events ){
21335                this.$emit('clicked_show_more_events', date, day_of_week, events);
21336            },
21337            add_event( date_components ){
21338                this.$emit('add_event', date_components);
21339            },
21340            formatHour(hour) {
21341                const baseDate = moment().startOf('day').hour(hour);
21342                return baseDate.format("h:mm A");
21343            },
21344            changeViewPreference( event ){
21345                this.$emit("change_view_preference", event.target.value);
21346            },
21347            return_number_of_days_for_event( event ){
21348                let start = moment.tz(event.ts, this.timezone);
21349                let end = moment.tz(event.end_ts, this.timezone);
21350                //continue
21351                return end.diff(start, 'days') + 1;
21352            },
21353            is_first_day_of_event( event, date ) {
21354                // Compare the event start date with the cell's date
21355                const eventStart = moment.tz(event.ts, this.timezone).startOf("day");
21356                const cellDate = moment.tz(date, this.timezone).startOf("day");
21357                return eventStart.isSame(cellDate, "day");
21358            },
21359           sort_events_for_day(events){
21360                //multi day and all day events first, then by start time
21361                return events.sort((a, b) => {
21362                    const aAllDay = a.isAllDay;
21363                    const bAllDay = b.isAllDay;
21364
21365                    const aMulti = this.is_multi_day_event(a);
21366                    const bMulti = this.is_multi_day_event(b);
21367
21368                    // Sort all-day events first
21369                    if (aAllDay && !bAllDay) return -1;
21370                    if (!aAllDay && bAllDay) return 1;
21371
21372                    // Then multi-day events
21373                    if (aMulti && !bMulti) return -1;
21374                    if (!aMulti && bMulti) return 1;
21375
21376                    // Otherwise, by start time
21377                    return moment.tz(a.ts, this.timezone).isBefore(moment.tz(b.ts, this.timezone)) ? -1 : 1;
21378                });
21379            },
21380            is_multi_day_event( event ){
21381                return moment.tz(event.ts, this.timezone).isBefore(moment.tz(event.end_ts, this.timezone), 'day');
21382            },
21383            handleButtonClick(option, new_date) {
21384                if (option === "today") {
21385                    //vReplace with a new moment object
21386                    var new_date = moment.tz( this.timezone ).startOf("day");
21387                } else if (option === "next") {
21388                    // Add time and replace with a new object
21389                    var new_date = moment(this.current_date).add(1, this.view_preference.toLowerCase());
21390                } else if (option === "previous") {
21391                    // Subtract time and replace with a new object
21392                    var new_date = moment(this.current_date).subtract(1, this.view_preference.toLowerCase());
21393                }else if( option === 'date-picker' ){
21394                    var new_date = moment.tz( new_date, this.timezone ).startOf("day");
21395                }
21396                if( new_date ){
21397                    this.$emit("change_date", new_date);
21398                }
21399            },
21400            generateWrapStyle( type ){
21401                if (type === 'cs-calendar-wrap'){
21402                    return {'width': '100%'}
21403                } else if (type === 'options' && this.is_membership_cal){
21404                    return {
21405                        'padding-top': '15px',
21406                        'padding-right': '15px',
21407                        'padding-left': '15px',
21408                        'padding-bottom': '5px',
21409                        'border-width': '0 1.5px 0 1.5px',
21410                        'border-color': '#e2e8e8',
21411                        'border-style': 'solid',
21412                        'overflow': 'auto',
21413                    }
21414                } else if (type === 'cs-calendar-menu-wrap' && this.is_membership_cal){
21415                    return {
21416                        'border-width': '0 1.5px 0 1.5px',
21417                        'border-color': '#e2e8e8',
21418                        'border-style': 'solid'
21419                    }
21420                }
21421            },
21422            // tapped_add_to_cal(){
21423            //     var params = {};
21424            //     params["title"] = "Create calendar item";
21425            //     params["hide_on_selection"] = true;
21426            //     params["retrieve_sections_fn"] = this.return_sections_for_handle_common_grid;
21427            //     handle_common_grid( params );
21428            // }, 
21429            // return_sections_for_handle_common_grid(){
21430            //     var sections = [];
21431            //     var options = [
21432            //         {
21433            //             text: "Event",
21434            //             fn: this.add_event,
21435            //             icon_class: "fa-regular fa-calendar-plus",
21436            //         }, 
21437            //         {
21438            //             text: "Calendar tag",
21439            //             fn: this.clicked_add_calendar_tag,
21440            //             icon_class: "fa-regular fa-tags",
21441            //         }, 
21442            //     ]
21443            //     sections.push({
21444            //         options: options
21445            //     });
21446            //     return sections;
21447            // }, 
21448            clicked_add_calendar_tag(){
21449                store.components.tag_event.type_text = "this calendar";
21450                store.components.tag_event.handle_selected_event_tag = this.handle_selected_event_tag;
21451                store.components.tag_event.event_tag_input = '';
21452                store.components.tag_event.event_source = this.calendar_type;
21453                store.overlays.tag_event = true;
21454            }, 
21455            handle_selected_event_tag(tag_data, event_tags){
21456                //emit
21457                this.$emit('added_calendar_tag', tag_data, event_tags);
21458            },
21459            refresh_calendar(){
21460                this.$emit('refresh_calendar');
21461            }, 
21462            clicked_remove_tag(tag){
21463                this.$emit('remove_calendar_tag', tag);
21464            }, 
21465            isolate_calendar_tag(tag){
21466                this.ui_catch_all_custom_tag_selected = false;
21467                this.$emit('isolate_calendar_tag', tag);
21468            },
21469            isolate_default_tag(tag){
21470                this.$emit('isolate_default_tag', tag);
21471            },
21472            select_all_custom_tags(){
21473                this.ui_catch_all_custom_tag_selected = true;
21474                this.$emit('select_all_custom_tags');
21475            },
21476            select_all_default_tags(){
21477                this.$emit('select_all_default_tags');
21478            },
21479            clear_all_default_tags(){
21480                this.$emit('clear_all_default_tags');
21481            },
21482            select_visible_default_tags(visibleIds){
21483                this.$emit('select_visible_default_tags', visibleIds);
21484            },
21485            clear_visible_default_tags(visibleIds){
21486                this.$emit('clear_visible_default_tags', visibleIds);
21487            },
21488            clear_all_custom_tags(){
21489                this.$emit('clear_all_custom_tags');
21490            },
21491            select_visible_custom_tags(visibleIds){
21492                this.$emit('select_visible_custom_tags', visibleIds);
21493            },
21494            clear_visible_custom_tags(visibleIds){
21495                this.$emit('clear_visible_custom_tags', visibleIds);
21496            },
21497            isolate_catch_all_custom(){
21498                this.$emit('unselect_all_custom_tags');
21499                this.ui_catch_all_custom_tag_selected = true;
21500            },
21501            calendarRowHeight() {
21502                let maxSlots = 0;
21503                this.days_array.forEach((d) => {
21504                    var used_slots = 0;
21505                    if (Array.isArray(d.slots)) {
21506                        used_slots = d.slots.filter(slot => slot.event).length;
21507                    }
21508                    maxSlots = Math.max(maxSlots, used_slots);
21509                });
21510                const baseHeight = 100; // Base height for the calendar row
21511                var slotHeight = 10; // Height for each slot
21512                if(maxSlots >= 6){
21513                    slotHeight = 20;  //extra height for more slots
21514                }
21515                return `${baseHeight + (maxSlots * slotHeight)}px`;
21516            },
21517            should_hold_slot(slot, index, day) {
21518                var date = day.date;
21519                const usedSlots = this.return_slots_in_use_for_week(slot, index, date);
21520                return usedSlots.includes(index);
21521            },
21522            return_slots_in_use_for_week(slot, index, date) {
21523                const startOfWeek = moment.tz(date, this.timezone).startOf("week");
21524                const endOfWeek = moment.tz(date, this.timezone).endOf("week");
21525
21526                const slotsUsed = new Set();
21527
21528                this.days_array.forEach(day => {
21529                    const dayMoment = moment.tz(day.date, this.timezone).startOf("day");
21530
21531                    // Only consider days in the current week
21532                    if (dayMoment.isBetween(startOfWeek, endOfWeek, null, '[]')) {
21533                        if (Array.isArray(day.slots)) {
21534                            day.slots.forEach(slotObj => {
21535                                if (slotObj.event) {
21536                                    slotsUsed.add(slotObj.slot);
21537                                }
21538                            });
21539                        }
21540                    }
21541                });
21542
21543                return Array.from(slotsUsed);
21544            },
21545            change_show_calendar_tags(  ){
21546                this.show_calendar_tags = !this.show_calendar_tags;
21547            },
21548            // default_tag_selected(tag) {
21549            //     return this.ui_tag_ids_default && this.ui_tag_ids_default.includes(tag.id);
21550            // },
21551        },
21552        computed: {
21553            show_catch_all_custom_tags_option() {
21554                //if we have AND logic, we want to show an option that's a catch-all for all custom tags
21555                return this.filter_logic == 'and';
21556            },
21557            selected_tags_set() {
21558                let selected_tags_set = new Set( this.selected_tags.custom );
21559                return selected_tags_set;
21560            },
21561            selected_filter_set() {
21562                let selected_filter_set = new Set( this.selected_tags.default );
21563                return selected_filter_set;
21564            },
21565            selected_filter_set_secondary() {
21566                return new Set( this.selected_tags.default_secondary || [] );
21567            },
21568            dates_for_days_array(){
21569                //create a basic structure of dates that does not change so that when filters change, we can just update events and slots instead of recreating every date object
21570               //create an object with the following structure for every day in the given date range 
21571                /** 
21572                {
21573                    "date": "2025-07-27T07:00:00.000Z",
21574                    "date_components": {
21575                        "year": 2025,
21576                        "month": 6,
21577                        "day": 27
21578                    },
21579                    "day": 27,
21580                    "day_of_week": "Sunday",
21581                    "slots": [
21582                        {
21583                            "slot": 0,
21584                            "event": null
21585                        },
21586                        {
21587                            "slot": 1,
21588                            "event": null
21589                        },
21590                        {
21591                            "slot": 2,
21592                            "event": null
21593                        },
21594                        {
21595                            "slot": 3,
21596                            "event": null
21597                        },
21598                        {
21599                            "slot": 4,
21600                            "event": null
21601                        },
21602                        {
21603                            "slot": 5,
21604                            "event": null
21605                        },
21606                        {
21607                            "slot": 6,
21608                            "event": null
21609                        },
21610                        {
21611                            "slot": 7,
21612                            "event": null
21613                        },
21614                        {
21615                            "slot": 8,
21616                            "event": null
21617                        },
21618                        {
21619                            "slot": 9,
21620                            "event": null
21621                        }
21622                    ],
21623                    "events": []
21624                } **/
21625
21626                const start = this.current_date.clone().startOf("month").startOf("week");
21627                const end = this.current_date.clone().endOf("month").endOf("week");
21628                let current = start.clone();
21629                let days_array = [];
21630                while (current.isBefore(end) || current.isSame(end, "day")) {
21631                    //create the day object
21632                    let day = {
21633                        date: current.clone(),
21634                        date_components: {
21635                            year: current.year(),
21636                            month: current.month(),
21637                            day: current.date(),
21638                        },
21639                        day: current.date(),
21640                        day_of_week: current.format("dddd"),
21641                        slots: Array.from({ length: 10 }, (_, i) => ({
21642                            slot: i,
21643                            event: null, // Initially no event
21644                        })),
21645                        events: []
21646                    };
21647                    //add the day object to the array
21648                    days_array.push(day);
21649                    current.add(1, "day");
21650                }
21651                return days_array;
21652            },
21653            days_array() {
21654                //leverage days_array_dates 
21655                //make sure previous things get addressed first and start by filling up the slots with any allday/multi day events that started before the calendar start date but take place and end within the current month/week
21656                if (this.view_preference === "Month" || this.view_preference == "Week") {
21657                    //always start at the beginning/end of the month, EVEN IN WEEK VIEW
21658                    //.. so that we can generate slots effectively
21659                    //... Then, we only push the day to the array in week view if it's in the current week
21660                   
21661                    const start = this.current_date.clone().startOf("month").startOf("week");
21662                    const end = this.current_date.clone().endOf("month").endOf("week");
21663                    
21664                    
21665                    let days_array = [];
21666                    let visible_slots = 10;
21667                    if( this.view_preference == "Week" ){
21668                        visible_slots = 100;
21669                    }
21670                    let reserved_slots = {};
21671                    let highest_reserved_slot_in_week = 0;
21672                    /*
21673                        //example of reserved_slots
21674                        {
21675                            0: {
21676                                event: Object,
21677                                days_remaining: 3
21678                            },
21679                            1: {
21680                                event: Object,
21681                                days_remaining: 1
21682                            },
21683                            2: {
21684                                event: Object,
21685                                days_remaining: 4
21686                            },
21687                        }
21688                    */
21689
21690                    var dates = this.dates_for_days_array;
21691                    dates.forEach(day => {
21692                        let current = day.date.clone();
21693                        let day_is_in_week = current.isSame( this.current_date, "week" );
21694                        let events_for_day = this.visible_events_by_day[current.format("YYYY-MM-DD")] || [];
21695                        let handled_events = []; //events we have already managed to slot in
21696                        //generate slots for the day
21697                        let slots = [];
21698                        for (let i = 0; i <= visible_slots - 1; i++) {
21699                            //check for a reserved slot already
21700                            if( reserved_slots[ i ] ){
21701                                //slot is reserved
21702                                slots.push({
21703                                    slot: i,
21704                                    event: reserved_slots[ i ].event
21705                                })
21706                                //update it
21707                                reserved_slots[ i ].days_remaining -= 1;
21708                            }else{
21709                                //slot is open
21710                                //.. check for an event
21711                                //.. if there is an event, reserve the slot
21712                                let event_to_use = null;
21713                                for (let e = 0; e < events_for_day.length; e++) {
21714                                    const this_event = events_for_day[ e ];
21715                                    if( this.view_preference == "Week" && ! this_event.isAllDay ){
21716                                        //we only slot all day events for the week view
21717                                        continue;
21718                                    }
21719                                    //keep going
21720                                    if( ! handled_events.includes( this_event.id ) ){
21721                                        //haven't used it yet
21722                                        if( ! this.is_multi_day_event( this_event ) ){
21723                                            //it's a single-day event
21724                                            //.. slot it right in!
21725                                            event_to_use = this_event;
21726                                            break;
21727                                        }else{
21728                                            //check if it's the first visible day of the month calendar 
21729                                            var is_first_of_month = current.isSame(this.current_date.clone().startOf("month").startOf("week"), 'day');
21730                                            //ok, it's a multi-day event
21731                                            //.. see if it's the first day of a new event
21732                                            var remaining_days = this.remaining_days_in_event( this_event, current );
21733                                            if(is_first_of_month){
21734                                                //check for events that started before the month started but are still ongoing
21735                                                //.. and slot them 
21736                                                if( remaining_days > 0 ){
21737                                                    //it's an ongoing event that started before the month started
21738                                                    event_to_use = this_event;
21739                                                    reserved_slots[ i ] = {
21740                                                        event: this_event,
21741                                                        days_remaining: Math.max(0, this.remaining_days_in_event(this_event, current) - 1)
21742                                                    }
21743                                                    break;
21744                                                }
21745                                            }else if( this.is_first_day_of_event( this_event, current ) ){
21746                                                //it's the first day of a multi-day event
21747                                                //.. reserve the slot
21748                                                event_to_use = this_event;
21749                                                reserved_slots[ i ] = {
21750                                                    event: this_event,
21751                                                    days_remaining: Math.max(0, this.remaining_days_in_event(this_event, current) - 1)
21752                                                }
21753                                                break;
21754                                            } 
21755                                        }
21756                                    }
21757                                }
21758                                if( event_to_use ){
21759                                    slots.push({
21760                                        slot: i,
21761                                        event: event_to_use
21762                                    })
21763                                    handled_events.push( event_to_use.id );
21764                                    if( day_is_in_week && i > highest_reserved_slot_in_week ){
21765                                        highest_reserved_slot_in_week = i;
21766                                    }
21767                                }else{
21768                                    //push it, even if we don't have an event
21769                                    slots.push({
21770                                        slot: i,
21771                                        event: null
21772                                    })
21773                                }
21774                            }
21775                            if( reserved_slots[ i ] && reserved_slots[ i ].days_remaining === 0 ){
21776                                //remove the slot
21777                                delete reserved_slots[ i ];
21778                            }
21779                        }
21780                        //end slots
21781                        day.slots = slots; //visible events for the day
21782                        day.events = events_for_day; //all events for the day
21783                        if( this.view_preference == "Month" || day_is_in_week){
21784                            days_array.push(day);
21785                        }
21786                    });
21787
21788                    /**
21789                    let current = start.clone();
21790                    while (current.isBefore(end) || current.isSame(end, "day")) {
21791                        let day_is_in_week = current.isSame( this.current_date, "week" );
21792                        //find all events for this day
21793                        let events_for_day = this.visible_events_by_day[current.format("YYYY-MM-DD")] || [];
21794                        let handled_events = []; //events we have already managed to slot in
21795                        //generate slots for the day
21796                        let slots = [];
21797                        for (let i = 0; i <= visible_slots - 1; i++) {
21798                            //check for a reserved slot already
21799                            if( reserved_slots[ i ] ){
21800                                //slot is reserved
21801                                slots.push({
21802                                    slot: i,
21803                                    event: reserved_slots[ i ].event
21804                                })
21805                                //update it
21806                                reserved_slots[ i ].days_remaining -= 1;
21807                            }else{
21808                                //slot is open
21809                                //.. check for an event
21810                                //.. if there is an event, reserve the slot
21811                                let event_to_use = null;
21812                                for (let e = 0; e < events_for_day.length; e++) {
21813                                    const this_event = events_for_day[ e ];
21814                                    if( this.view_preference == "Week" && ! this_event.isAllDay ){
21815                                        //we only slot all day events for the week view
21816                                        continue;
21817                                    }
21818                                    //keep going
21819                                    if( ! handled_events.includes( this_event.id ) ){
21820                                        //haven't used it yet
21821                                        if( ! this.is_multi_day_event( this_event ) ){
21822                                            //it's a single-day event
21823                                            //.. slot it right in!
21824                                            event_to_use = this_event;
21825                                            break;
21826                                        }else{
21827                                            //check if it's the first visible day of the month calendar 
21828                                            var is_first_of_month = current.isSame(this.current_date.clone().startOf("month").startOf("week"), 'day');
21829                                            //ok, it's a multi-day event
21830                                            //.. see if it's the first day of a new event
21831                                            var remaining_days = this.remaining_days_in_event( this_event, current );
21832                                            if(is_first_of_month){
21833                                                //check for events that started before the month 
21833started but are still ongoing
21834                                                //.. and slot them 
21835                                                if( remaining_days > 0 ){
21836                                                    //it's an ongoing event that started before the month started
21837                                                    event_to_use = this_event;
21838                                                    reserved_slots[ i ] = {
21839                                                        event: this_event,
21840                                                        days_remaining: remaining_days
21841                                                    }
21842                                                    break;
21843                                                }
21844                                            }else if( this.is_first_day_of_event( this_event, current ) ){
21845                                                //it's the first day of a multi-day event
21846                                                //.. reserve the slot
21847                                                event_to_use = this_event;
21848                                                reserved_slots[ i ] = {
21849                                                    event: this_event,
21850                                                    days_remaining: this.return_number_of_days_for_event( this_event )
21851                                                }
21852                                                break;
21853                                            } 
21854                                        }
21855                                    }
21856                                }
21857                                if( event_to_use ){
21858                                    slots.push({
21859                                        slot: i,
21860                                        event: event_to_use
21861                                    })
21862                                    handled_events.push( event_to_use.id );
21863                                    if( day_is_in_week && i > highest_reserved_slot_in_week ){
21864                                        highest_reserved_slot_in_week = i;
21865                                    }
21866                                }else{
21867                                    //push it, even if we don't have an event
21868                                    slots.push({
21869                                        slot: i,
21870                                        event: null
21871                                    })
21872                                }
21873                            }
21874                            if( reserved_slots[ i ] && reserved_slots[ i ].days_remaining === 0 ){
21875                                //remove the slot
21876                                delete reserved_slots[ i ];
21877                            }
21878                        }
21879                        //end slots
21880                        //see if we should push to the days array
21881                        if( this.view_preference == "Month" || day_is_in_week){
21882                            days_array.push({
21883                                date: current.clone(),
21884                                date_components: {
21885                                    year: current.year(),
21886                                    month: current.month(),
21887                                    day: current.date(),
21888                                },
21889                                day: current.date(),
21890                                day_of_week: current.format("dddd"),
21891                                slots: slots, //visible events for the day
21892                                events: events_for_day, //all events for the day
21893                            });
21894                        }
21895                        //increment it
21896                        current.add(1, "day");
21897                    }
21898
21899                    */
21900                    //remove slots that are greater than highest actually used slot
21901                    if (this.view_preference === "Week") {
21902                        let highest_used_slot_index = 0;
21903                        days_array.forEach(day => {
21904                            day.slots.forEach(slot => {
21905                                if (slot.event && slot.slot > highest_used_slot_index) {
21906                                    highest_used_slot_index = slot.slot;
21907                                }
21908                            });
21909                        });
21910                        days_array.forEach(day => {
21911                            day.slots = day.slots.filter(slot => {
21912                                return slot.slot <= highest_used_slot_index;
21913                            });
21914                        });
21915                    }
21916                    return days_array;
21917                } else if (this.view_preference === "Day") {
21918                    // Generate only the current day
21919                    return [
21920                        {
21921                            date: this.current_date.clone(),
21922                            date_components: {
21923                                year: this.current_date.year(),
21924                                month: this.current_date.month(),
21925                                day: this.current_date.date(),
21926                            },
21927                            day: this.current_date.date(),
21928                            day_of_week: this.current_date.format("dddd"),
21929                            events: this.visible_events_by_day[this.current_date.format("YYYY-MM-DD")] || [],
21930                        },
21931                    ];
21932                }
21933                return [];
21934            },
21935            calendarTitle() {
21936                if (this.view_preference === "Month") {
21937                    return this.current_date.format("MMMM YYYY");
21938                } else if (this.view_preference === "Week") {
21939                    // Set the title to the range of the week
21940                    const start = this.current_date.clone().startOf("week");
21941                    const end = this.current_date.clone().endOf("week");
21942                    return `${start.format("MMM D")} - ${end.format("MMM D")}`;
21943                } else if (this.view_preference === "Day") {
21944                    return this.current_date.format("MMMM D, YYYY");
21945                }
21946                return null;
21947            },
21948            calendarTitle_noYear(){
21949                  if (this.view_preference === "Month") {
21950                    return this.current_date.format("MMMM");
21951                } else if (this.view_preference === "Week") {
21952                    // Set the title to the range of the week
21953                    const start = this.current_date.clone().startOf("week");
21954                    const end = this.current_date.clone().endOf("week");
21955                    return `${start.format("MMM D")} - ${end.format("MMM D")}`;
21956                } else if (this.view_preference === "Day") {
21957                    return this.current_date.format("MMMM D");
21958                }
21959                return null;
21960            },
21961            events_by_day(){
21962                //create an object with keys as days and values as arrays of events
21963                let events_by_day = {};
21964                this.events.forEach(event => {
21965                    //get the start and end dates of the event
21966                    const start = moment.tz(event.ts, this.timezone).startOf("day");
21967                    const end = moment.tz(event.end_ts, this.timezone).endOf("day");
21968                    //iterate through the days from start to end
21969                    let current = start.clone();
21970                    while (current.isSameOrBefore(end)) {
21971                        const date_key = current.format("YYYY-MM-DD");
21972                        if (!events_by_day[date_key]) {
21973                            events_by_day[date_key] = [];
21974                        }
21975                        events_by_day[date_key].push(event);
21976                        current.add(1, "day");
21977                    }
21978                });
21979                //sort array for each day
21980                for (let date in events_by_day) {
21981                    if(date && events_by_day[date] && events_by_day[date].length > 0){
21982                        //sort the events for the day
21983                        events_by_day[date] = this.sort_events_for_day(events_by_day[date]);
21984                    }
21985                }
21986                return events_by_day;
21987            },
21988            visible_events_by_day() {
21989                const visible_events_by_day = {};
21990                const defaultFilterIDs = this.selected_tags?.default || [];
21991                const selectedCalendarTagIDs = this.selected_tags?.custom || [];
21992                
21993                for (const [dayKey, events] of Object.entries(this.events_by_day)) {
21994                    visible_events_by_day[dayKey] = events.filter(event => {
21995                        if(event.type !== 'booking'){ 
21996
21997                            var hasMatchingDefault = false;
21998                            var hasMatchingDefaultSecondary = true;
21999                            // Two default groups (e.g. lodging requests: room type + room with AND)
22000                            if (this.default_calendar_tags_secondary) {
22001                                var room_type_ids = event.room_type_ids || [];
22002                                var room_ids = event.room_ids || [];
22003                                if (room_type_ids.length || room_ids.length) {
22004                                    var is_day_trip_request_filter = this.calendar_type === 'lodging_requests' && room_type_ids.indexOf('day_trip') !== -1;
22005                                    if (this.default_calendar_tags && room_type_ids.length) {
22006                                        for (var ri = 0; ri < room_type_ids.length; ri++) {
22007                                            if (this.selected_filter_set.has(room_type_ids[ri])) {
22008                                                hasMatchingDefault = true;
22009                                                break;
22010                                            }
22011                                        }
22012                                    } else if (!this.default_calendar_tags || (this.selected_filter_set && this.selected_filter_set.size === 0)) {
22013                                        hasMatchingDefault = true;
22014                                    }
22015                                    if (is_day_trip_request_filter) {
22016                                        hasMatchingDefaultSecondary = true;
22017                                    } else {
22018                                        var secSet = this.selected_filter_set_secondary;
22019                                        if (secSet && secSet.size > 0) {
22020                                            hasMatchingDefaultSecondary = false;
22021                                            if (secSet.has('no_room') && room_ids.length === 0) {
22022                                                hasMatchingDefaultSecondary = true;
22023                                            }
22024                                            for (var rj = 0; rj < room_ids.length; rj++) {
22025                                                if (secSet.has(room_ids[rj])) {
22026                                                    hasMatchingDefaultSecondary = true;
22027                                                    break;
22028                                                }
22029                                            }
22030                                        }
22031                                    }
22032                                } else {
22033                                    hasMatchingDefault = true;
22034                                    hasMatchingDefaultSecondary = true;
22035                                }
22036                            } else if (this.default_calendar_tags) {
22037                                // Lodging special-case: if grouped, match ANY of the group's room ids (have to handle case where we group mult. rooms for one member)
22038                                if (
22039                                    (this.calendar_type === 'lodging' ||  (this.calendar_type === 'reservations' && event.type === "lodging_reservation")) &&
22040                                    event.filter_ids &&
22041                                    event.filter_ids.length > 0
22042                                ) {
22043                                    const filter_ids = event.filter_ids || [];
22044                                    for (const filter of filter_ids) {
22045                                        if (this.selected_filter_set.has(filter)) {
22046                                            hasMatchingDefault = true;
22047                                            if( this.filter_logic == "or" && hasMatchingDefault ){
22048                                                return true;
22049                                            }
22050                                        }
22051                                    }
22052                                } else {
22053                                    const filter_ids = [event.filter_id];
22054                                    for (const filter of filter_ids) {
22055                                        if (this.selected_filter_set.has(filter)) {
22056                                            hasMatchingDefault = true;
22057                                            if( this.filter_logic == "or" && hasMatchingDefault ){
22058                                                return true;
22059                                            }
22060                                        }
22061                                    }
22062                                }
22063                            }
22064                            if (this.default_calendar_tags_secondary && (!hasMatchingDefault || !hasMatchingDefaultSecondary)) {
22065                                return false;
22066                            }
22067                            //see if we should exit early, based on default
22068                            if( this.filter_logic == "or" && hasMatchingDefault ){
22069                                return true;
22070                            }
22071                            //now check if one of the events tags are selected
22072                            var hasMatchingCalendar = false;
22073                            const event_tag_ids = event.event_tag_ids || [];
22074                            if( 
22075                                this.show_catch_all_custom_tags_option && 
22076                                this.catch_all_custom_tag_selected && 
22077                                event_tag_ids.length === 0 
22078                            ){
22079                                //ok, they have the default (no tags) option selected
22080                                //.. AND this event isn't tagged
22081                                //... it's a match 
22082                                hasMatchingCalendar = true;
22083                            }else{
22084                                //ok, do a full check on the tags
22085                                if( this.calendar_tags && this.calendar_tags.length >= 1 ){
22086                                    //ok, we have tags
22087                                    //.. it actually needs to match one of the tags
22088                                    if( selectedCalendarTagIDs.length > 0 && selectedCalendarTagIDs.some(tagId => event_tag_ids.includes(tagId)) ){
22089                                        hasMatchingCalendar = true;
22090                                    }
22091                                }else if( this.filter_logic == "and" ){
22092                                    //if no tags, it's effectively a match
22093                                    hasMatchingCalendar = true;
22094                                }
22095                            }
22096                            //check logic
22097                            if( this.filter_logic == "and" ){
22098                                //AND logic
22099                                return hasMatchingDefault && hasMatchingCalendar;
22100                            }else{
22101                                //OR logic
22102                                return hasMatchingDefault || hasMatchingCalendar;
22103                            }
22104                        } else if (this.calendar_tags && this.calendar_tags.length > 0) {
22105
22106                            const event_tag_ids = event.event_tag_ids || [];
22107                            for (const tag_id of event_tag_ids) {
22108                                if (this.selected_tags_set.has(tag_id)) {
22109                                    return true; // exit early
22110                                }
22111                            }
22112                            return false;
22113
22114                        }
22115                        return false;
22116                    });
22117                }
22118                return visible_events_by_day;
22119            },
22120
22121        },
22122        watch: {
22123            view_preference(newVal) {
22124                this.v_model_view_preference = newVal;
22125            }, 
22126        }
22127    });
22128  </script>
22128    
22128<script type="text/html" id="template-booking-calendar-filters">
22129    <!-- DESKTOP VERSION -->
22130    <div 
22131        v-if="show_calendar_tags" 
22132        class="hideOnMobile calendar-tag-picker lightBorderLeft scrollable native-tab-bar-padding-bottom" 
22133        style="width: 240px;"
22134        :class="{'flexible-height-version scrollable': calendar_type === 'web_component'}"
22135    >
22136        <div class="relative flexNoWrapLeftAlign" style="align-items: baseline;">
22137               
22138            <div style="position: sticky;top: 0;background-color: #fff;z-index: 10;" >
22139                <div v-if="can_edit_tags"  class="flexWrapLeftAlign align-items-center  tinyPaddingBottom" style="padding-top: 10px;padding-bottom: 6px; margin-right: 10px;">
22140                        
22141                    <div class="smallPaddingLeft">
22142                        <icon-button
22143                            label="Add event"
22144                            icon_class="fa-regular fa-plus"
22145                            @clicked="add_event()"
22146                            style="max-height: fit-content;"
22147                        ></icon-button>
22148                    </div>
22149                    <div  class="flexGrowOne"></div>
22150                    <div class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop">
22151                        <h3 
22152                            class="calendar-list menu-icon pointer"
22153                            @click="change_show_calendar_tags()"
22154                        >
22155                            <i 
22156                                class="fa-regular fa-bars pointer"
22157                            ></i>
22158                        </h3>
22159                    </div>
22160                </div>
22161                <h3 
22162                    class="flexWrapLeftAlign align-items-center"
22163                    :class="{'lightBorderBottom': calendar_type !== 'web_component'}"
22164                    style="padding-top: 10px;">
22165                    Calendars
22166                    <template v-if="!can_edit_tags">
22167                        <div  class="flexGrowOne"></div>
22168                        <div class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop">
22169                            <h3 
22170                                class="calendar-list menu-icon pointer"
22171                                style="margin-right: 10px;"
22172                                @click="change_show_calendar_tags()"
22173                            >
22174                                <i 
22175                                    class="fa-regular fa-bars pointer"
22176                                ></i>
22177                            </h3>
22178                        </div>
22179                    </template>
22180                </h3>
22181            </div>
22182
22183            <div class="calendar-tag-picker-input bigPaddingLeftRight scrollable paddingBottom">
22184                <div v-if="default_calendar_tags && default_calendar_tags.title" class="smallMarginTop">
22185                    <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_primary = !collapsible_menus_open.default_primary">
22186                        {{ default_calendar_tags.title }}
22187                        <i 
22188                            class="fa fa-chevron-down rotate-chevron"
22189                            :class="{ down: collapsible_menus_open.default_primary }"
22190                            style="margin-left: 6px;"
22191                        ></i>
22192                    </h4>
22193                    <div v-if="collapsible_menus_open.default_primary && loading_parent.filter" class="coming-soon">xxx</div>
22194                    <div v-if="collapsible_menus_open.default_primary && !loading_parent.filter" class="calendar-filter-search-wrap tinyMarginBottom">
22195                        <div 
22196                            class="calendar-filter-search-container relative" 
22197                            :class="{ 'active': filter_search_default_focused }"
22198                        >
22199                            <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i>
22200                            <input 
22201                                class="calendar-filter-search-input" 
22202                                :placeholder="default_search_placeholder" 
22203                                v-model="filter_search_default" 
22204                                @focus="filter_search_default_focused = true" 
22205                                @blur="filter_search_default_focused = false"
22206                                autocomplete="off" 
22207                                autocorrect="off" 
22208                                autocapitalize="none" 
22209                                spellcheck="false"
22210                            >
22211                        </div>
22212                    </div>
22213                    <div class="flexWrapLeftAlign align-items-center">
22214                        <p 
22215                            v-if="collapsible_menus_open.default_primary && !loading_parent.filter" 
22216                            @click="select_default_tags()" 
22217                            class="smallBlockLetters tinyMarginBottom"
22218                            style="margin-right: 12px; font-size: 12px;"
22219                        >
22220                            <a>
22221                                <i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ default_select_all_label }}
22222                            </a>
22223                        </p>
22224                        <p 
22225                            v-if="collapsible_menus_open.default_primary && (has_default_search ? true : has_any_default_filters_selected)"
22226                            @click="clear_default_tags()" 
22227                            class="smallBlockLetters tinyMarginBottom"
22228                            style="margin-right: 12px; font-size: 12px;"
22229                        >
22230                            <a>
22231                                <i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ default_clear_label }}
22232                            </a>
22233                        </p>
22234                    </div>
22235                    <template
22236                        v-if="collapsible_menus_open.default_primary && filtered_default_options?.length > 0" 
22237                        v-for="tag in filtered_default_options" 
22238                        :key="tag.id"
22239                    >
22240                        <div 
22241                            @click="clicked_default_tag(tag)" 
22242                            class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 
22243                            style="display: flex;"
22244                        >
22245                            <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;">
22246                                <div 
22247                                    class="custom-checkbox" 
22248                                    :style="{
22249                                        backgroundColor: ui_tag_ids_default && ui_tag_ids_default[tag.id]
22250                                            ? (tag.color || '#5774f5')
22251                                            : '',
22252                                        border: '2px solid ' + (tag.color || '#5774f5')
22253                                    }"
22254                                >
22255                                    <i class="fa-regular fa-check" 
22256                                        style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"
22257                                    ></i>
22258                                </div>
22259                                <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">
22260                                    {{ tag.label }}
22261                                </span>
22262                            </label>
22263                            <!-- Default tag context menu trigger -->
22264                            <div 
22265                                class="calendar-tag-remove-icon" 
22266                                style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;"
22267                                @click.stop="toggleTagMenu(tag, $event)"
22268                            >
22269                                <i class="fa-regular fa-ellipsis-vertical"></i>
22270                            </div>
22271                            <!-- Default tag context menu dropdown (only isolate option) -->
22272                            <div
22273                                v-if="open_menu_for_tag_id === tag.id"
22274                                class="tag-context-menu"
22275                                @click.stop
22276                            >
22277                                <button
22278                                    type="button"
22279                                    class="tag-context-menu-item"
22280                                    @click.stop="closeTagMenu(); isolate_default_tag(tag)"
22281                                >
22282                                    Display this only
22283                                </button>
22284                            </div>
22285                        </div>
22286                    </template>
22287                </div>
22288                <!-- Secondary default group (e.g. Room for lodging requests: room type + room with AND) -->
22289                <div v-if="default_calendar_tags_secondary && default_calendar_tags_secondary.title" class="smallMarginTop">
22290                    <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_secondary = !collapsible_menus_open.default_secondary">
22291                        {{ default_calendar_tags_secondary.title }}
22292                        <i class="fa fa-chevron-down rotate-chevron" :class="{ down: collapsible_menus_open.default_secondary }" style="margin-left: 6px;"></i>
22293                    </h4>
22294                    <div v-if="collapsible_menus_open.default_secondary" class="calendar-filter-search-wrap tinyMarginBottom">
22295                        <div class="calendar-filter-search-container relative" :class="{ 'active': filter_search_default_secondary_focused }">
22296                            <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i>
22297                            <input class="calendar-filter-search-input" :placeholder="'Search ' + (default_calendar_tags_secondary.title || '').toLowerCase()" v-model="filter_search_default_secondary" @focus="filter_search_default_secondary_focused = true" @blur="filter_search_default_secondary_focused = false" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false">
22298                        </div>
22299                    </div>
22300                    <div class="flexWrapLeftAlign align-items-center">
22301                        <p v-if="collapsible_menus_open.default_secondary" @click="select_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>Select all</a></p>
22302                        <p v-if="collapsible_menus_open.default_secondary && (filter_search_default_secondary || '').trim() && filtered_default_secondary_options?.length" @click="clear_visible_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>Clear</a></p>
22303                        <p v-if="collapsible_menus_open.default_secondary && !(filter_search_default_secondary || '').trim() && has_any_default_secondary_filters_selected" @click="clear_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>Clear</a></p>
22304                    </div>
22305                    <template v-if="collapsible_menus_open.default_secondary && filtered_default_secondary_options?.length > 0" v-for="tag in filtered_default_secondary_options" :key="tag.id">
22306                        <div @click="clicked_default_tag_secondary(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" style="display: flex;">
22307                            <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;">
22308                                <div class="custom-checkbox" :style="{ backgroundColor: ui_tag_ids_default_secondary && ui_tag_ids_default_secondary[tag.id] ? (tag.color || '#5774f5') : '', border: '2px solid ' + (tag.color || '#5774f5') }">
22309                                    <i class="fa-regular fa-check" style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"></i>
22310                                </div>
22311                                <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">{{ tag.label }}</span>
22312                            </label>
22313                            <div class="calendar-tag-remove-icon" style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" @click.stop="toggleTagMenu(tag, $event)">
22314                                <i class="fa-regular fa-ellipsis-vertical"></i>
22315                            </div>
22316                            <div v-if="open_menu_for_tag_id === tag.id" class="tag-context-menu" @click.stop>
22317                                <button type="button" class="tag-context-menu-item" @click.stop="closeTagMenu(); isolate_default_tag_secondary(tag)">Display this only</button>
22318                            </div>
22319                        </div>
22320                    </template>
22321                </div>
22322                <!-- Other custom calendars (event tags) -->
22323                <div v-if="show_other_filters" class="marginTop">
22324                    <h4 class="smallBlockLetters leftText tinyMarginBottom pointer noWrap" @click="collapsible_menus_open.custom = !collapsible_menus_open.custom">
22325                        {{ calendar_tag_label }}
22326                        <i 
22327                            v-if="can_edit_tags"
22328                            class="fa-regular fa-plus pointer"
22329                            style="margin-left: 6px; margin-right: 3px;"
22330                            @click.stop="clicked_add_calendar_tag()"
22331                        ></i>
22332                        <i 
22333                            class="fa fa-chevron-down rotate-chevron"
22334                            :class="{ down: collapsible_menus_open.custom }"
22335                            style="margin-left: 6px;"
22336                        ></i>
22337                    </h4>
22338                    <div v-if="collapsible_menus_open.custom && loading_parent.calendar_tags" class="coming-soon">xxx</div>
22339                    <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="calendar-filter-search-wrap tinyMarginBottom">
22340                        <div 
22341                            class="calendar-filter-search-container relative" 
22342                            :class="{ 'active': filter_search_custom_focused }"
22343                        >
22344                            <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i>
22345                            <input 
22346                                class="calendar-filter-search-input" 
22347                                :placeholder="'Search tags'"
22348                                v-model="filter_search_custom" 
22349                                @focus="filter_search_custom_focused = true" 
22350                                @blur="filter_search_custom_focused = false"
22351                                autocomplete="off" 
22352                                autocorrect="off" 
22353                                autocapitalize="none" 
22354                                spellcheck="false"
22355                            >
22356                        </div>
22357                    </div>
22358                    <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="flexWrapLeftAlign align-items-center tinyMarginBottom">
22359                        <p @click="select_custom_tags()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;">
22360                            <a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ custom_select_all_label }}</a>
22361                        </p>
22362                        <p 
22363                            v-if="has_custom_search || has_any_custom_filters_selected"
22364                            @click="clear_custom_tags()" 
22365                            class="smallBlockLetters tinyMarginBottom"
22366                            style="margin-right: 12px; font-size: 12px;"
22367                        >
22368                            <a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ custom_clear_label }}</a>
22369                        </p>
22370                    </div>
22371
22372                    <!-- default (no tags) option -->
22373                    <div 
22374                        v-if="show_catch_all_custom_tags_option && collapsible_menus_open.custom && show_no_tags_in_custom" 
22375                        @click="clicked_catch_all_custom_tag()" 
22376                        class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 
22377                        style="display: flex;"
22378                    >
22379                        <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;">
22380                            <div 
22381                                class="custom-checkbox" 
22382                                :style="{
22383                                    backgroundColor: ui_catch_all_custom_tag_selected
22384                                    ? '#5774f5'
22385                                    : '',
22386                                    border: '2px solid #5774f5'
22387                                }"
22388                            >
22389                                <i class="fa-regular fa-check" 
22390                                    v-if="ui_catch_all_custom_tag_selected"
22391                                    style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"
22392                                ></i>
22393                            </div>
22394                            <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">
22395                                No tags
22396                            </span>
22397                        </label>
22398                         <!-- Tag context menu trigger -->
22399                        <div 
22400                            class="calendar-tag-remove-icon" 
22401                            style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;"
22402                            @click.stop="toggleTagMenu({id: 'catch_all_custom'}, $event)"
22403                        >
22404                            <i class="fa-regular fa-ellipsis-vertical"></i>
22405                        </div>
22406                        <!-- Tag context menu dropdown -->
22407                        <div
22408                            v-if="open_menu_for_tag_id === 'catch_all_custom'"
22409                            class="tag-context-menu"
22410                            @click.stop
22411                        >
22412                            <button
22413                                type="button"
22414                                class="tag-context-menu-item"
22415                                @click.stop="closeTagMenu(); isolate_catch_all_custom()"
22416                            >
22417                                Display this only
22418                            </button>
22419                        </div>
22420                    </div>
22421                    <!-- end default (no tags) option -->
22422
22423                    <template 
22424                        v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags && filtered_custom_tags && filtered_custom_tags.length > 0" 
22425                        v-for="tag in filtered_custom_tags" 
22426                        :key="tag.id"
22427                    >
22428                        <div 
22429                            v-if="tag" 
22430                            @click="clicked_calendar_tag(tag)" 
22431                            class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 
22432                            style="display: flex;"
22433                        >
22434                            <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;">
22435                                <div 
22436                                    class="custom-checkbox" 
22437                                    :style="{
22438                                        backgroundColor:  ui_tag_ids_custom && ui_tag_ids_custom[tag.id]
22439                                            ? (tag.get('hex_value') || '#5774f5')
22440                                            : '',
22441                                        border: '2px solid ' + (tag.get('hex_value') || '#5774f5')
22442                                    }"
22443                                >
22444                                    <i class="fa-regular fa-check" 
22445                                        v-if="ui_tag_ids_custom && ui_tag_ids_custom[tag.id]"
22446                                        style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"
22447                                    ></i>
22448                                </div>
22449                                <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">
22450                                    {{ tag.get('name') }}
22451                                </span>
22452                            </label>
22453                            <!-- Tag context menu trigger -->
22454                            <div 
22455                                class="calendar-tag-remove-icon" 
22456                                style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;"
22457                                @click.stop="toggleTagMenu(tag, $event)"
22458                            >
22459                                <i class="fa-regular fa-ellipsis-vertical"></i>
22460                            </div>
22461                            <!-- Tag context menu dropdown -->
22462                            <div
22463                                v-if="open_menu_for_tag_id === tag.id"
22464                                class="tag-context-menu"
22465                                :class="{'tag-context-menu--above': isLastTag(tag)}"
22466                                @click.stop
22467                            >
22468                                <button
22469                                    type="button"
22470                                    class="tag-context-menu-item"
22471                                    @click.stop="closeTagMenu(); isolate_calendar_tag(tag)"
22472                                >
22473                                    Display this only
22474                                </button>
22475                                <button
22476                                    v-if="can_edit_tags"
22477                                    type="button"
22478                                    class="tag-context-menu-item tag-context-menu-item-danger"
22479                                    @click.stop="closeTagMenu(); clicked_remove_tag(tag)"
22480                                >
22481                                    Remove tag
22482                                </button>
22483                            </div>
22484                        </div>
22485                    </template>
22486                </div>                        
22487            </div>
22488        </div>
22489    </div>
22490    <!-- MOBILE VERSION -->
22491     <!-- Teleport the modal to body -->
22492     <teleport to="#v-overlays">
22493        <div v-if="show_calendar_tags" class="showOnMobile">
22494                <transition name="slide-in-right">
22495                    <aside class="mobile-filter-overlay scrollable" role="dialog" aria-modal="true" aria-labelledby="calendar-filters-title">
22496                        <div class="calendar-tag-picker marginBottom">
22497                            <div class="relative flexNoWrapLeftAlign" style="align-items: baseline;">
22498                                <div 
22499                                    style="position: sticky;top: 0;background-color: #fff;z-index: 10;"
22500                                    class="lightBorderBottom smallPaddingTop smallPaddingBottom bigPaddingLeftRight"
22501                                >
22502                                    <div 
22503                                        v-if="can_edit_tags"
22504                                        class="tinyPaddingBottom"
22505                                    >
22506                                        <icon-button
22507                                            label="Add event"
22508                                            icon_class="fa-regular fa-plus"
22509                                            @clicked="add_event()"
22510                                            style="max-height: fit-content;"
22511                                        ></icon-button>
22512                                    </div>
22513                                    <div
22514                                        class="card-header-button-v3 pointer noWrap centeredText relative"
22515                                    >
22516                                        {{ v_model_view_preference }}
22517                                        <i class="fa-light fa-chevron-down" style="padding-left: 3px;"></i>
22518                                        <select
22519                                            v-model="v_model_view_preference"
22520                                            @change="changeViewPreference($event)"
22521                                            class="pointer"
22522                                            style="position:absolute;left:0px;top:0px;width:100%;height:100%;opacity:0;"
22523                                        >
22524                                            <option value="Month">Month</option>
22525                                            <option value="Week">Week</option>
22526                                            <option value="Day">Day</option>
22527                                        </select>
22528                                    </div>
22529                                    <h3 id="calendar-filters-title" class="flexWrapLeftAlign align-items-center smallMarginTop" style="padding-top: 0;">
22530                                        Calendars
22531                                        <div class="flexGrowOne"></div>
22532                                        <!-- <i class="fa-regular fa-xmark pointer" @click="$emit('close')" style="margin-right: 10px;"></i> -->
22533                                    </h3>
22534                                </div>
22535                                <div class="calendar-tag-picker-input bigPaddingLeftRight scrollable paddingBottom">
22536                                    <div v-if="default_calendar_tags && default_calendar_tags.title" class="smallMarginTop">
22537                                        <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_primary = !collapsible_menus_open.default_primary">
22538                                        {{ default_calendar_tags.title }}
22539                                            <i 
22540                                                class="fa fa-chevron-down rotate-chevron"
22541                                                :class="{ down: collapsible_menus_open.default_primary }"
22542                                                style="margin-left: 6px;"
22543                                            ></i>
22544                                        </h4>
22545                                        <div v-if="collapsible_menus_open.default_primary && loading_parent.filter" class="coming-soon">xxx</div>
22546                                        <div v-if="collapsible_menus_open.default_primary && !loading_parent.filter" class="calendar-filter-search-wrap tinyMarginBottom">
22547                                            <div 
22548                                                class="calendar-filter-search-container relative" 
22549                                                :class="{ 'active': filter_search_default_focused }"
22550                                            >
22551                                                <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 10;"></i>
22552                                                <input 
22553                                                    class="calendar-filter-search-input" 
22554                                                    :placeholder="default_search_placeholder" 
22555                                                    v-model="filter_search_default" 
22556                                                    @focus="filter_search_default_focused = true" 
22557                                                    @blur="filter_search_default_focused = false"
22558                                                    autocomplete="off" 
22559                                                    autocorrect="off" 
22560                                                    autocapitalize="none" 
22561                                                    spellcheck="false"
22562                                                >
22563                                            </div>
22564                                        </div>
22565                                        <div class="flexWrapLeftAlign align-items-center">
22566                                            <p 
22567                                                v-if="collapsible_menus_open.default_primary" 
22568                                                @click="select_default_tags()" 
22569                                                class="smallBlockLetters tinyMarginBottom"
22570                                                style="margin-right: 12px; font-size: 12px;"
22571                                            >
22572                                                <a>
22573                                                    <i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ default_select_all_label }}
22574                                                </a>
22575                                            </p>
22576                                            <p 
22577                                                v-if="collapsible_menus_open.default_primary && (has_default_search ? true : has_any_default_filters_selected)" 
22578                                                @click="clear_default_tags()" 
22579                                                class="smallBlockLetters tinyMarginBottom"
22580                                                style="margin-right: 12px; font-size: 12px;"
22581                                            >
22582                                                <a>
22583                                                    <i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ default_clear_label }}
22584                                                </a>
22585                                            </p>
22586                                        </div>
22587                                        <template 
22588                                            v-if="collapsible_menus_open.default_primary && filtered_default_options?.length > 0" 
22589                                            v-for="tag in filtered_default_options" 
22590                                            :key="tag.id"
22591                                        >
22592                                            <div 
22593                                                @click="clicked_default_tag(tag)" 
22594                                                class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" 
22595                                                style="display: flex; padding-top: 16px;"
22596                                            >
22597                                                <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;">
22598                                                    <div 
22599                                                        class="custom-checkbox" 
22600                                                        :style="{
22601                                                            backgroundColor: ui_tag_ids_default && ui_tag_ids_default[tag.id]
22602                                                                ? (tag.color || '#5774f5')
22603                                                                : '',
22604                                                            border: '2px solid ' + (tag.color || '#5774f5')
22605                                                        }"
22606                                                    >
22607                                                        <i class="fa-regular fa-check" 
22608                                                            style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"
22609                                                        ></i>
22610                                                    </div>
22611                                                    <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">
22612                                                        {{ tag.label }}
22613                                                    </span>
22614                                                </label>
22615                                                <!-- Default tag context menu trigger (mobile) -->
22616                                                <div 
22617                                                    class="calendar-tag-more mobile-version" 
22618                                                    style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;"
22619                                                    @click.stop="toggleTagMenu(tag, $event)"
22620                                                >
22621                                                    <i class="fa-regular fa-ellipsis-vertical"></i>
22622                                                </div>
22623                                                <!-- Default tag context menu dropdown (only isolate option) -->
22624                                                <div
22625                                                    v-if="open_menu_for_tag_id === tag.id"
22626                                                    class="tag-context-menu"
22627                                                    @click.stop
22628                                                >
22629                                                    <button
22630                                                        type="button"
22631                                                        class="tag-context-menu-item"
22632                                                        @click.stop="closeTagMenu(); isolate_default_tag(tag)"
22633                                                    >
22634                                                        Display this only
22635                                                    </button>
22636                                                </div>
22637                                            </div>
22638                                        </template>
22639                                    </div>
22640                                    <!-- Secondary default group (mobile) -->
22641                                    <div v-if="default_calendar_tags_secondary && default_calendar_tags_secondary.title" class="marginTop">
22642                                        <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_secondary = !collapsible_menus_open.default_secondary">
22643                                            {{ default_calendar_tags_secondary.title }}
22644                                            <i class="fa fa-chevron-down rotate-chevron" :class="{ down: collapsible_menus_open.default_secondary }" style="margin-left: 6px;"></i>
22645                                        </h4>
22646                                        <div v-if="collapsible_menus_open.default_secondary" class="flexWrapLeftAlign align-items-center tinyMarginBottom">
22647                                            <p @click="select_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>Select all</a></p>
22648                                            <p v-if="has_any_default_secondary_filters_selected" @click="clear_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>Clear</a></p>
22649                                        </div>
22650                                        <template v-if="collapsible_menus_open.default_secondary && filtered_default_secondary_options?.length > 0" v-for="tag in filtered_default_secondary_options" :key="tag.id">
22651                                            <div @click="clicked_default_tag_secondary(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" style="display: flex; padding-top: 16px;">
22652                                                <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;">
22653                                                    <div class="custom-checkbox" :style="{ backgroundColor: ui_tag_ids_default_secondary && ui_tag_ids_default_secondary[tag.id] ? (tag.color || '#5774f5') : '', border: '2px solid ' + (tag.color || '#5774f5') }">
22654                                                        <i class="fa-regular fa-check" style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"></i>
22655                                                    </div>
22656                                                    <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">{{ tag.label }}</span>
22657                                                </label>
22658                                                <div class="calendar-tag-more mobile-version" style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" @click.stop="toggleTagMenu(tag, $event)">
22659                                                    <i class="fa-regular fa-ellipsis-vertical"></i>
22660                                                </div>
22661                                                <div v-if="open_menu_for_tag_id === tag.id" class="tag-context-menu" @click.stop>
22662                                                    <button type="button" class="tag-context-menu-item" @click.stop="closeTagMenu(); isolate_default_tag_secondary(tag)">Display this only</button>
22663                                                </div>
22664                                            </div>
22665                                        </template>
22666                                    </div>
22667                                    <!-- Other custom calendars (event tags) -->
22668                                    <div v-if="has_other_calendar_filters" class="marginTop">
22669                                        <h4 class="smallBlockLetters leftText tinyMarginBottom pointer noWrap" @click="collapsible_menus_open.custom = !collapsible_menus_open.custom">
22670                                        {{ calendar_tag_label }}
22671                                        <i 
22672                                            v-if="can_edit_tags"
22673                                            class="fa-regular fa-plus pointer"
22674                                            style="margin-left: 6px; margin-right: 3px;"
22675                                            @click.stop="clicked_add_calendar_tag()"
22676                                        ></i>
22677                                        <i 
22678                                            class="fa fa-chevron-down rotate-chevron"
22679                                            :class="{ down: collapsible_menus_open.custom }"
22680                                            style="margin-left: 6px;"
22681                                        ></i>
22682                                        </h4>
22683                                        <div v-if="collapsible_menus_open.custom && loading_parent.calendar_tags" class="coming-soon">xxx</div>
22684                                        <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="calendar-filter-search-wrap tinyMarginBottom">
22685                                            <div 
22686                                                class="calendar-filter-search-container relative" 
22687                                                :class="{ 'active': filter_search_custom_focused }"
22688                                            >
22689                                                <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 10;"></i>
22690                                                <input 
22691                                                    class="calendar-filter-search-input" 
22692                                                    :placeholder="'Search tags'"
22693                                                    v-model="filter_search_custom" 
22694                                                    @focus="filter_search_custom_focused = true" 
22695                                                    @blur="filter_search_custom_focused = false"
22696                                                    autocomplete="off" 
22697                                                    autocorrect="off" 
22698                                                    autocapitalize="none" 
22699                                                    spellcheck="false"
22700                                                >
22701                                            </div>
22702                                        </div>
22703                                        <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="flexWrapLeftAlign align-items-center tinyMarginBottom">
22704                                            <p @click="select_custom_tags()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;">
22705                                                <a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ custom_select_all_label }}</a>
22706                                            </p>
22707                                            <p 
22708                                                v-if="has_custom_search || has_any_custom_filters_selected"
22709                                                @click="clear_custom_tags()" 
22710                                                class="smallBlockLetters tinyMarginBottom"
22711                                                style="margin-right: 12px; font-size: 12px;"
22712                                            >
22713                                                <a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ custom_clear_label }}</a>
22714                                            </p>
22715                                        </div>
22716                                        
22717                                        <!-- default (no tags) option -->
22718                                        <div 
22719                                            v-if="show_catch_all_custom_tags_option && collapsible_menus_open.custom && show_no_tags_in_custom" 
22720                                            @click="clicked_catch_all_custom_tag()" 
22721                                            class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 
22722                                            style="display: flex;"
22723                                        >
22724                                            <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;">
22725                                                <div 
22726                                                    class="custom-checkbox" 
22727                                                    :style="{
22728                                                        backgroundColor: ui_catch_all_custom_tag_selected
22729                                                        ? '#5774f5'
22730                                                        : '',
22731                                                        border: '2px solid #5774f5'
22732                                                    }"
22733                                                >
22734                                                    <i class="fa-regular fa-check" 
22735                                                        v-if="ui_catch_all_custom_tag_selected"
22736                                                        style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"
22737                                                    ></i>
22738                                                </div>
22739                                                <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">
22740                                                    No tags
22741                                                </span>
22742                                            </label>
22743                                            <!-- Tag context menu trigger -->
22744                                            <div 
22745                                                class="calendar-tag-catch-all mobile-version" 
22746                                                style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;"
22747                                                @click.stop="toggleTagMenu({id: 'catch_all_custom'}, $event)"
22748                                            >
22749                                                <i class="fa-regular fa-ellipsis-vertical"></i>
22750                                            </div>
22751                                            <!-- Tag context menu dropdown -->
22752                                            <div
22753                                                v-if="open_menu_for_tag_id === 'catch_all_custom'"
22754                                                class="tag-context-menu"
22755                                                @click.stop
22756                                            >
22757                                                <button
22758                                                    type="button"
22759                                                    class="tag-context-menu-item"
22760                                                    @click.stop="closeTagMenu(); isolate_catch_all_custom()"
22761                                                >
22762                                                    Display this only
22763                                                </button>
22764                                            </div>
22765                                        </div>
22766                                        <!-- end default (no tags) option -->
22767                                        
22768                                        <template 
22769                                            v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags && filtered_custom_tags && filtered_custom_tags.length > 0" 
22770                                            v-for="tag in filtered_custom_tags" 
22771                                            :key="tag.id"
22772                                            >
22773                                            <div v-if="tag" @click="clicked_calendar_tag(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" style="display: flex; padding-top: 16px">
22774                                                <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;">
22775                                                    <div 
22776                                                        class="custom-checkbox" 
22777                                                        :style="{
22778                                                        backgroundColor:  ui_tag_ids_custom && ui_tag_ids_custom[tag.id]
22779                                                            ? (tag.get('hex_value') || '#5774f5')
22780                                                            : '',
22781                                                        border: '2px solid ' + (tag.get('hex_value') || '#5774f5')
22782                                                        }"
22783                                                    >
22784                                                        <i class="fa-regular fa-check" 
22785                                                            v-if="ui_tag_ids_custom && ui_tag_ids_custom[tag.id]"
22786                                                            style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"
22787                                                        ></i>
22788                                                    </div>
22789                                                    <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">
22790                                                        {{ tag.get('name') }}
22791                                                    </span>
22792                                                </label>
22793                                                <!-- Tag context menu trigger (mobile)-->
22794                                                <div 
22795                                                    class="calendar-tag-more mobile-version" 
22796                                                    style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;"
22797                                                    @click.stop="toggleTagMenu(tag, $event)"
22798                                                >
22799                                                    <i class="fa-regular fa-ellipsis-vertical"></i>
22800                                                </div>
22801                                                <!-- Tag context menu dropdown -->
22802                                                <div
22803                                                    v-if="open_menu_for_tag_id === tag.id"
22804                                                    class="tag-context-menu"
22805                                                    :class="{'tag-context-menu--above': isLastTag(tag)}"
22806                                                    @click.stop
22807                                                >
22808                                                    <button
22809                                                        type="button"
22810                                                        class="tag-context-menu-item"
22811                                                        @click.stop="closeTagMenu(); isolate_calendar_tag(tag)"
22812                                                    >
22813                                                        Display this only
22814                                                    </button>
22815                                                    <button
22816                                                        v-if="can_edit_tags"
22817                                                        type="button"
22818                                                        class="tag-context-menu-item tag-context-menu-item-danger"
22819                                                        @click.stop="closeTagMenu(); clicked_remove_tag(tag)"
22820                                                    >
22821                                                        Remove tag
22822                                                    </button>
22823                                                </div>
22824                                            </div>
22825                                        </template>
22826
22827                                    </div>                        
22828                                </div>
22829                            </div>
22830                        </div>
22831                    </aside>
22832                </transition>
22833
22834                <div v-if="show_calendar_tags" class="overlay-backdrop showOnMobile" @click="$emit('close')"></div>
22835        </div>
22836    </teleport>
22837</script>
22837
22838
22839<script>
22840    app_content.component("booking-calendar-filters", {
22841        template: "#template-booking-calendar-filters",
22842        data(){
22843            return {
22844                collapsible_menus_open: {
22845                    default_primary: true,
22846                    default_secondary: true,
22847                    custom: true,
22848                },
22849                v_model_view_preference: this.view_preference || 'Month',
22850                open_menu_for_tag_id: null,
22851                filter_search_default: '',
22852                filter_search_custom: '',
22853                filter_search_default_secondary: '',
22854                filter_search_default_focused: false,
22855                filter_search_custom_focused: false,
22856                filter_search_default_secondary_focused: false,
22857            }
22858        }, 
22859        props: {
22860            show_calendar_tags: Boolean,
22861            default_calendar_tags: Object,
22862            calendar_tags: Array,
22863            selected_tags: Object,
22864            catch_all_custom_tag_selected: Boolean,
22865            ui_tag_ids_default: Object,
22866            ui_tag_ids_custom: Array,
22867            ui_catch_all_custom_tag_selected: Boolean,
22868            show_catch_all_custom_tags_option: {
22869                type: Boolean,
22870                default: false,
22871            },
22872            default_calendar_tags_secondary: Object,
22873            ui_tag_ids_default_secondary: Object,
22874            collapsible_menus_open: Object, 
22875            loading_parent: Object,
22876            calendar_type: String, 
22877            view_only: Boolean,
22878            view_preference: String,
22879        }, 
22880        mounted(){
22881            this.v_model_view_preference = this.view_preference || 'Month';
22882            document.addEventListener('click', this.handleClickOutside);
22883        },
22884        beforeDestroy(){
22885            document.removeEventListener('click', this.handleClickOutside);
22886        },
22887        methods: {
22888            add_event(){
22889                this.$emit('add_event');
22890            }, 
22891            isLastTag(tag){
22892                if (!this.calendar_tags || this.calendar_tags.length === 0 || !tag) {
22893                    return false;
22894                }
22895                const len = this.calendar_tags.length;
22896                const last = this.calendar_tags[len - 1];
22897                const secondLast = this.calendar_tags[len - 2];
22898                return (last && last.id === tag.id) || (secondLast && secondLast.id === tag.id);
22899            },
22900            toggleTagMenu(tag, event){
22901                if (event && event.stopPropagation) {
22902                    event.stopPropagation();
22903                }
22904                // Toggle the menu for this tag; close if it's already open
22905                this.open_menu_for_tag_id = this.open_menu_for_tag_id === tag.id ? null : tag.id;
22906            },
22907            closeTagMenu(){
22908                this.open_menu_for_tag_id = null;
22909            },
22910            handleClickOutside(){
22911                // Close any open menu when clicking outside
22912                if (this.open_menu_for_tag_id !== null) {
22913                    this.open_menu_for_tag_id = null;
22914                }
22915            },
22916            clicked_default_tag(tag) {
22917                this.$emit('clicked_default_tag', tag);
22918            },   
22919            clicked_catch_all_custom_tag(){
22920                this.$emit('clicked_catch_all_custom_tag');
22921            },
22922            clicked_calendar_tag(tag) {
22923                this.$emit('clicked_calendar_tag', tag);
22924            }, 
22925            clicked_add_calendar_tag(){
22926                this.$emit('clicked_add_calendar_tag');
22927            }, 
22928            clicked_remove_tag(tag) {
22929                this.$emit('clicked_remove_tag', tag);
22930            }, 
22931            changeViewPreference(event){
22932                this.$emit('change_view_preference', event );
22933            }, 
22934            change_show_calendar_tags(){
22935                this.$emit('change_show_calendar_tags');
22936            }, 
22937            isolate_default_tag(tag){
22938                this.$emit('isolate_default_tag', tag);
22939            },
22940            clicked_default_tag_secondary(tag) {
22941                this.$emit('clicked_default_tag_secondary', tag);
22942            },
22943            select_default_tags_secondary() {
22944                var q = (this.filter_search_default_secondary || '').trim();
22945                if (q && this.filtered_default_secondary_options && this.filtered_default_secondary_options.length) {
22946                    this.$emit('select_visible_default_tags_secondary', this.filtered_default_secondary_options.map((t)=>{ return t.id; }));
22947                } else {
22948                    this.$emit('select_all_default_tags_secondary');
22949                }
22950            },
22951            clear_default_tags_secondary() {
22952                var q = (this.filter_search_default_secondary || '').trim();
22953                if (q && this.filtered_default_secondary_options && this.filtered_default_secondary_options.length) {
22954                    this.$emit('clear_visible_default_tags_secondary', this.filtered_default_secondary_options.map((t)=>{ return t.id; }));
22955                } else {
22956                    this.$emit('clear_all_default_tags_secondary');
22957                }
22958            },
22959            clear_visible_default_tags_secondary() {
22960                var ids = (this.filtered_default_secondary_options || []).map((t)=>{ return t.id; });
22961                this.$emit('clear_visible_default_tags_secondary', ids);
22962            },
22963            isolate_default_tag_secondary(tag) {
22964                this.$emit('isolate_default_tag_secondary', tag);
22965            },
22966            isolate_calendar_tag(tag){
22967                this.$emit('isolate_calendar_tag', tag);
22968            }, 
22969            select_all_custom_tags(){
22970                this.$emit('select_all_custom_tags');
22971            },
22972            select_default_tags(){
22973                var q = (this.filter_search_default || '').trim();
22974                if (q && this.filtered_default_options && this.filtered_default_options.length) {
22975                    var visibleIds = this.filtered_default_options.map((t)=>{ return t.id; });
22976                    this.$emit('select_visible_default_tags', visibleIds);
22977                } else {
22978                    this.$emit('select_all_default_tags');
22979                }
22980            },
22981            clear_default_tags(){
22982                var q = (this.filter_search_default || '').trim();
22983                if (q && this.filtered_default_options && this.filtered_default_options.length) {
22984                    var visibleIds = this.filtered_default_options.map((t)=>{ return t.id; });
22985                    this.$emit('clear_visible_default_tags', visibleIds);
22986                } else {
22987                    this.$emit('clear_all_default_tags');
22988                }
22989            },
22990            select_custom_tags(){
22991                var q = (this.filter_search_custom || '').trim();
22992                if (q) {
22993                    var visibleIds = (this.filtered_custom_tags || []).map((t)=>{ return t.id; });
22994                    if (this.show_no_tags_in_custom) { visibleIds.push('catch_all_custom'); }
22995                    this.$emit('select_visible_custom_tags', visibleIds);
22996                } else {
22997                    this.$emit('select_all_custom_tags');
22998                }
22999            },
23000            clear_custom_tags(){
23001                var q = (this.filter_search_custom || '').trim();
23002                if (q) {
23003                    var visibleIds = (this.filtered_custom_tags || []).map((t)=>{ return t.id; });
23004                    if (this.show_no_tags_in_custom) { visibleIds.push('catch_all_custom'); }
23005                    this.$emit('clear_visible_custom_tags', visibleIds);
23006                } else {
23007                    this.$emit('clear_all_custom_tags');
23008                }
23009            },
23010            select_all_default_tags(){
23011                this.$emit('select_all_default_tags');
23012            },
23013            clear_all_default_tags(){
23014                this.$emit('clear_all_default_tags');
23015            },
23016            isolate_catch_all_custom(){
23017                this.$emit('isolate_catch_all_custom');
23018            }
23019        }, 
23020        computed: {
23021            show_other_filters(){
23022                // member facing calendars only show if we have tags 
23023                if(this.calendar_type && (this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' )){
23024                    //only show if we have tags
23025                    return this.has_other_calendar_filters;
23026                } 
23027                //admin calendars always show
23028                //...since they need to be able to add tags
23029                return true;
23030            },
23031            has_other_calendar_filters(){
23032                if(!this.calendar_tags || this.calendar_tags.length == 0){
23033                    return false;
23034                }
23035                return true;
23036            }, 
23037            can_edit_tags(){
23038                if(this.view_only){
23039                    return false;
23040                }
23041                if(this.calendar_type && ( this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' || this.calendar_type == 'events_primary' )){
23042                    //member facing calendars
23043                    // also the primary events calendar since it shows ALL event tags
23044                    return false;
23045                }
23046                return true;
23047            }, 
23048            calendar_tag_label(){
23049                if(this.calendar_type && (this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' || this.calendar_type == 'events_primary' )){
23050                    return "Event tags";
23051                }
23052                return "Other calendars";
23053            },
23054            default_search_placeholder(){
23055                var title = this.default_calendar_tags?.title || 'event types';
23056                if(title.toLowerCase().trim() === 'type'){
23057                    return 'Search event types';
23058                }else{
23059                    return 'Search ' + title.toLowerCase();
23060                }
23061            },
23062            has_any_default_filters_selected(){
23063                var defaultSelected = (this.ui_tag_ids_default && typeof this.ui_tag_ids_default === 'object') ? Object.keys(this.ui_tag_ids_default).length : 0;
23064                return defaultSelected > 0;
23065            },
23066            has_any_custom_filters_selected(){
23067                var customSelected = (this.ui_tag_ids_custom && typeof this.ui_tag_ids_custom === 'object') ? Object.keys(this.ui_tag_ids_custom).length : 0;
23068                if (customSelected > 0) { return true; }
23069                if (this.ui_catch_all_custom_tag_selected) { return true; }
23070                return false;
23071            },
23072            has_default_search(){
23073                return (this.filter_search_default || '').trim().length > 0;
23074            },
23075            has_custom_search(){
23076                return (this.filter_search_custom || '').trim().length > 0;
23077            },
23078            filtered_default_options(){
23079                var options = this.default_calendar_tags?.options || [];
23080                var q = (this.filter_search_default || '').trim().toLowerCase();
23081                if (!q) { return options; }
23082                if (typeof fuzzysort !== 'undefined') {
23083                    var matches = fuzzysort.go(q, options, { keys: ['label'], threshold: -1000, limit: 100, allowTypo: false });
23084                    return matches ? matches.map((m)=>{ return m.obj; }) : [];
23085                }
23086                return options.filter((opt)=>{ return (opt.label || '').toLowerCase().indexOf(q) !== -1; });
23087            },
23088            filtered_default_secondary_options(){
23089                var options = this.default_calendar_tags_secondary?.options || [];
23090                var q = (this.filter_search_default_secondary || '').trim().toLowerCase();
23091                if (!q) { return options; }
23092                if (typeof fuzzysort !== 'undefined') {
23093                    var matches = fuzzysort.go(q, options, { keys: ['label'], threshold: -1000, limit: 100, allowTypo: false });
23094                    return matches ? matches.map((m)=>{ return m.obj; }) : [];
23095                }
23096                return options.filter((opt)=>{ return (opt.label || '').toLowerCase().indexOf(q) !== -1; });
23097            },
23098            has_any_default_secondary_filters_selected(){
23099                var o = this.ui_tag_ids_default_secondary && typeof this.ui_tag_ids_default_secondary === 'object' ? this.ui_tag_ids_default_secondary : {};
23100                return Object.keys(o).filter((k)=>{ return o[k]; }).length > 0;
23101            },
23102            default_select_all_label(){
23103                if (!this.has_default_search || !this.filtered_default_options) { return 'Select all'; }
23104                var n = this.filtered_default_options.length;
23105                return n ? 'Select all (' + n + ')' : 'Select all';
23106            },
23107            default_clear_label(){
23108                if (!this.has_default_search || !this.filtered_default_options) { return 'Clear'; }
23109                var n = this.filtered_default_options.length;
23110                return n ? 'Clear (' + n + ')' : 'Clear';
23111            },
23112            _custom_searchables(){
23113                var list = [];
23114                if (this.calendar_tags && this.calendar_tags.length) {
23115                    this.calendar_tags.forEach((tag)=>{
23116                        list.push({ name: (tag.get ? tag.get('name') : (tag.name || '') || ''), tag: tag });
23117                    });
23118                }
23119                if (this.show_catch_all_custom_tags_option) {
23120                    list.push({ name: 'No tags', tag: null });
23121                }
23122                return list;
23123            },
23124            filtered_custom_tags(){
23125                var searchables = this._custom_searchables;
23126                var q = (this.filter_search_custom || '').trim().toLowerCase();
23127                if (!q) {
23128                    return this.calendar_tags || [];
23129                }
23130                if (typeof fuzzysort !== 'undefined') {
23131                    var matches = fuzzysort.go(q, searchables, { keys: ['name'], threshold: -1000, limit: 100, allowTypo: false });
23132                    if (!matches || !matches.length) { return []; }
23133                    return matches.map((m)=>{ return m.obj.tag; }).filter(Boolean);
23134                }
23135                return (this.calendar_tags || []).filter((tag)=>{
23136                    var name = (tag.get ? tag.get('name') : (tag.name || '')) || '' || '';
23137                    return name.toLowerCase().indexOf(q) !== -1;
23138                });
23139            },
23140            filtered_custom_display_count(){
23141                var n = (this.filtered_custom_tags || []).length;
23142                if (this.show_no_tags_in_custom) { n += 1; }
23143                return n;
23144            },
23145            custom_select_all_label(){
23146                if (!this.has_custom_search) { return 'Select all'; }
23147                var n = this.filtered_custom_display_count;
23148                return n ? 'Select all (' + n + ')' : 'Select all';
23149            },
23150            custom_clear_label(){
23151                if (!this.has_custom_search) { return 'Clear'; }
23152                var n = this.filtered_custom_display_count;
23153                return n ? 'Clear (' + n + ')' : 'Clear';
23154            },
23155            show_no_tags_in_custom(){
23156                if (!this.show_catch_all_custom_tags_option) { return false; }
23157                var q = (this.filter_search_custom || '').trim().toLowerCase();
23158                if (!q) { return true; }
23159                if (typeof fuzzysort !== 'undefined') {
23160                    var matches = fuzzysort.go(q, [{ name: 'No tags', tag: null }], { keys: ['name'], threshold: -1000, allowTypo: false });
23161                    return matches && matches.length > 0;
23162                }
23163                return 'no tags'.indexOf(q) !== -1 || q === 'no';
23164            }, 
23165        }, 
23166        watch: {
23167            view_preference(newVal) {
23168                this.v_model_view_preference = newVal;
23169
23170            }
23171        }
23172
23173
23174    });
23175</script>
23175
23176
23177<style scoped>
23178 /* Rotating chevrons */
23179    .rotate-chevron.down {
23180        transform: rotate(180deg);
23181    }
23182
23183    .calendar-filter-search-wrap {
23184        margin-top: 6px;
23185    }
23186    .calendar-filter-search-container {
23187        display: flex;
23188        align-items: center;
23189        border-radius: 5px;
23190        border: 1px solid #e5eaec;
23191        overflow: hidden;
23192        margin-bottom: 2px;
23193    }
23194    .calendar-filter-search-container.active {
23195        border: 1px solid #4a8be6;
23196    }
23197    .calendar-filter-search-input {
23198        flex: 1;
23199        padding: 8px 8px 8px 30px;
23200        border: none;
23201        outline: none;
23202    }
23203    .calendar-filter-search-icon {
23204        font-size: 12px;
23205        position: absolute;
23206        left: 10px;
23207        top: 50%;
23208        transform: translateY(-50%);
23209        color: #52596f;
23210    }
23211
23212    .calendar-tag-picker.flexible-height-version{
23213        max-height: 100vh;
23214    }
23215
23216    .custom-checkbox {
23217        width: 18px;
23218        height: 18px;
23219        display: inline-block;
23220        position: relative;
23221        align-items: center;
23222        border-radius: 4px;
23223        margin-right: 8px;
23224        box-sizing: border-box;
23225        vertical-align: middle;
23226        cursor: pointer;
23227    }
23228
23229    /* Improved calendar tag item for wrapping and left alignment */
23230    .calendar-tag-item {
23231        /* align-items: flex-start !important; */
23232        flex-direction: row;
23233        flex-wrap: wrap;
23234        white-space: normal;
23235        text-align: left;
23236        line-height: 1.4;
23237    }
23238
23239    .calendar-tag-name {
23240        display: inline-block;
23241        word-break: break-word;
23242        max-width: 80%;
23243    }
23244
23245    .calendar-tag-item:hover .calendar-tag-remove-icon {
23246        visibility: visible;
23247    }
23248
23249    .calendar-tag-remove-icon {
23250        visibility: hidden;
23251        cursor: pointer;
23252        color:#788097;
23253    }
23254
23255    /* MOBILE */
23256    .mobile-filter-overlay{
23257        position: fixed;
23258        top: 0;
23259        right: 0;
23260        height: 100vh;
23261        width: 65vw;
23262        min-width: 250px;
23263        max-width: 480px;
23264        background: #fff;
23265        z-index: 1002;
23266        box-shadow: 0 0 20px rgba(0,0,0,0.2);
23267        display: flex;
23268        flex-direction: column;
23269        border-radius: 5px 0 0 5px;
23270    }
23271    .overlay-backdrop{
23272        position: fixed;
23273        inset: 0;
23274        background: rgba(0,0,0,0.4);
23275        z-index: 1001;
23276    }
23277        /* Slide-in-from-right transition */
23278    .slide-in-right-enter-active, .slide-in-right-leave-active{
23279        transition: transform 200ms ease-out;
23280    }
23281    .slide-in-right-enter-from, .slide-in-right-leave-to{
23282        transform: translateX(100%);
23283    }
23284
23285      .tag-context-menu{
23286        position: absolute;
23287        top: 100%;
23288        right: 0;
23289        margin-top: 4px;
23290        background: #fff;
23291        border: 1px solid #e0e4ee;
23292        border-radius: 6px;
23293        box-shadow: 0 4px 12px rgba(0,0,0,0.08);
23294        min-width: 160px;
23295        z-index: 10;
23296        padding: 4px 0;
23297    }
23298
23299    .tag-context-menu--above{
23300        top: auto;
23301        bottom: 100%;
23302        margin-top: 0;
23303        margin-bottom: -5px;
23304    }
23305
23306    .tag-context-menu-item{
23307        width: 100%;
23308        padding: 6px 10px;
23309        background: transparent;
23310        border: none;
23311        text-align: left;
23312        font-size: 16px;
23313        cursor: pointer;
23314        color: #333;
23315        white-space: nowrap;
23316    }
23317
23318    .tag-context-menu-item:hover{
23319        background: #f3f5fb;
23320    }
23321
23322    .tag-context-menu-item-danger{
23323        color: #c0392b;
23324    }
23325</style>
23326      
23326<script type="text/html" id="template-booking-calendar-cell">
23327    <div
23328        class="calendar-cell grid-cell pointer booking-calendar"
23329        :class="{'notCurrentMonth': ! is_current_month, 'today': is_today}"
23330        @click="add_event"
23331    >
23332        <div class="day-info flexWrap">
23333            <div @click.stop="zoom_in_to_date">
23334                <div class="cell-day calendarObj">{{ day }}</div>
23335                <div class="day-name">{{ dayOfWeek }}</div>
23336            </div>
23337        </div>
23338        <div class="events">
23339            <!-- render content for each slot -->
23340            <template
23341                v-for="(slot, index) in slots"
23342                :key="index"
23343            >
23344                <!-- ok, we have an event for this slot-->
23345                <template v-if="slot.event">
23346                    <div
23347                        class="event"
23348                        :class="[{'all_day': slot.event.isAllDay}, {'partial-day': ! slot.event.isAllDay}]"
23349                        @click.stop="clicked_event( slot.event )"
23350                        :style="return_multi_day_event_style( slot.event )"
23351                    >
23352                        <div
23353                            class="event-title-v2 flexNoWrap align-items-center easeSuperFast"
23354                            style="font-size:12.5px;"
23355                        >
23356                            <template
23357                                v-if="! slot.event.isAllDay"
23358                            >
23359                                <!-- dot -->
23360                                <i
23361                                    style="padding-right:6px;padding-left:2px;font-size:8px;"
23362                                    class="fa-solid fa-circle hideOnMobile"
23363                                    :style="{color: slot.event.color}"
23364                                ></i>
23365                                <!-- start time -->
23366                                <span v-if="! slot.event.isAllDay" class="event-time-v2 hideOnMobile">{{ formatEventTime( slot.event.ts ) }}</span>
23367                            </template>
23368                            <!-- event title -->
23369                            {{ slot.event.title }}
23370                        </div>
23371                    </div>
23372                </template>
23373                <template v-else-if="should_hold_slot(slot, index)">
23374                    <!-- Leave it blank if this slot is used later in the week  -->
23375                    <div
23376                        class="event placeholder"
23377                    >
23378                        <!-- add a placeholder if there's an event in this slot but it doesn't start on this day -->
23379                        <div
23380                            class="event-title-v2 flexNoWrap align-items-center easeSuperFast"
23381                            style="font-size:12.5px; visibility: hidden;"
23382                        >&nbsp;</div>
23383                        <!-- &nbsp; adds a hidden, non-breaking space -->
23384                    </div>
23385                </template>
23386
23387                <!-- no event for this slot -->
23388                <template v-else>
23389                    <div
23390                        class="event partial-day"
23391                    ></div>
23392                </template>
23393
23394            </template>
23395
23396            <!-- "+ x more" Label for Overflow -->
23397            <div
23398                v-if="overflowCount > 0"
23399                class="more-events-label"
23400                @click.stop="clicked_show_more_events"
23401            >
23402                + {{ overflowCount }} more
23403            </div>
23404        </div>
23405    </div>
23406</script>
23406
23407
23408<style scoped>
23409
23410    .event-time-v2{
23411        font-size: 11px;
23412        padding-right: 4px;;
23413    }
23414
23415    .more-events-label {
23416        font-size: 12px;
23417        cursor: pointer;
23418        padding-left: 6px;
23419        padding-right: 6px;
23420        text-align: left;
23421        padding-top: 2px;
23422        padding-bottom: 2px;
23423        cursor: pointer;
23424        border-radius: 5px;
23425    }
23426
23427    .event-title-v2{
23428        font-size: 12.5px;
23429        white-space: nowrap;
23430        padding-top: 2px;
23431        padding-bottom: 2px;;
23432        padding-left: 6px;
23433        padding-right: 6px;
23434        cursor: pointer;
23435        border-radius: 5px;
23436    }
23437
23438    .more-events-label:hover,
23439    .partial-day .event-title-v2:hover{
23440        background-color: #e4ebf1;
23441    }
23442    .booking-calendar .event.partial-day {
23443        /* color: #fff; */
23444        border-radius: 8px;
23445    }
23446
23447    .cell-day.calendarObj{
23448        min-width: 22px;
23449        max-width: 22px;
23450        min-height: 22px;
23451        max-height: 22px;
23452        margin: 6px !important;
23453        font-size: 12px;
23454    }
23455    .grid-cell.today .cell-day{
23456        background-color: var(--custom-mp-accent, #4a56f8);
23457        color: rgb(255, 255, 255);
23458    }
23459
23460
23461    /* Event List Styling */
23462    .events {
23463        display: flex;
23464        flex-direction: column;
23465        gap: 4px; /* Space between events */
23466    }
23467
23468    .event{
23469        font-size: 12px;
23470        font-weight: 400;
23471        overflow: hidden;
23472        white-space: nowrap;
23473        text-align: left;
23474    }
23475
23476    /* Individual Event Styling */
23477    .event.all_day {
23478        background-color: var(--custom-mp-accent, #4a56f8);
23479        color: #fff;
23480        border-radius: 8px;
23481        width: calc(100% - 4px);
23482    }
23483
23484</style>
23485
23486<script>
23487
23488    /* ----- COMPONENT ----- */
23489    app_content.component("booking-calendar-cell", {
23490        template: "#template-booking-calendar-cell",
23491        data() {
23492            return {}
23493        },
23494        props: {
23495            date: Object,
23496            day: Number,
23497            day_of_week: String,
23498            date_components: Object,
23499            events: Array,
23500            slots: Array,
23501            timezone: String,
23502            is_current_month: Boolean,
23503            days_array: Array,
23504            calendar_type: String
23505        },
23506        mounted(){
23507        },
23508        methods: {
23509            add_event(){
23510                var date_components = return_date_components_v2({
23511                    date: this.date,
23512                    timezone: this.timezone
23513                })
23514                this.$emit("add_event", date_components);
23515            },
23516            zoom_in_to_date(){
23517                this.$emit("zoom_in_to_date", this.date);
23518            },
23519            formatEventTime(timestamp) {
23520                const time = moment.tz(timestamp, this.timezone);
23521                if (time.minute() === 0) {
23522                    return time.format("ha");
23523                }
23524                return time.format("h:mma");
23525            },
23526            is_multi_day_event( event ){
23527                return moment.tz(event.ts, this.timezone).isBefore(moment.tz(event.end_ts, this.timezone), 'day');
23528            },
23529            // Updated to fix last day rendering & timezone issues for multi-day events
23530            return_multi_day_event_style(event) {
23531                const cellDate = moment.tz(this.date, this.timezone).startOf('day');
23532                const eventStart = moment.tz(event.ts, this.timezone).startOf('day');
23533                const eventEnd = moment.tz(event.end_ts, this.timezone).startOf('day');
23534
23535                // Skip styling if the event doesn't span this day
23536                if (cellDate.isBefore(eventStart) || cellDate.isAfter(eventEnd)) {
23537                    return { display: 'none' };
23538                }
23539
23540                const isFirstDay = cellDate.isSame(eventStart, 'day');
23541                const isStartOfWeek = cellDate.day() === 0;
23542                const textColor = this.adaptiveTextColorFromColors(event.bgColors);
23543
23544                if (isFirstDay || isStartOfWeek) {
23545                    const remainingDays = eventEnd.diff(cellDate, 'days') + 1;
23546                    const span = Math.min(remainingDays, 7 - cellDate.day());
23547
23548                    return {
23549                        width: `calc(${span * 100}% - 4px)`,
23550                        zIndex: 1,
23551                        color: textColor,
23552                        background: event.bgColors?.length
23553                            ? `repeating-linear-gradient(
23554                                -45deg,
23555                                ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")}
23556                              )`
23557                            : event.bgColor || "#4a56f8"
23558                    };
23559                }
23560
23561                return {
23562                    width: 'calc(100% - 4px)',
23563                    background: 'transparent',
23564                    visibility: 'hidden'
23565                };
23566            },
23567            remaining_days_in_event(event) {
23568                const cellDate = moment.tz(this.date, this.timezone).startOf("day");
23569                const eventEnd = moment.tz(event.end_ts, this.timezone).startOf("day");
23570                if (cellDate.isAfter(eventEnd)) {
23571                    return 0;
23572                }
23573                return eventEnd.diff(cellDate, "days") + 1;
23574            },
23575            return_number_of_days_for_event( event ){
23576                let start = moment.tz(event.ts, this.timezone);
23577                let end = moment.tz(event.end_ts, this.timezone);
23578                //continue
23579                return end.diff(start, 'days') + 1;
23580            },
23581            is_first_day_of_event(event) {
23582                // Compare the event start date with the cell's date
23583                const eventStart = moment.tz(event.ts, this.timezone).startOf("day");
23584                const cellDate = moment.tz(this.date, this.timezone).startOf("day");
23585                return eventStart.isSame(cellDate, "day");
23586            },
23587            clicked_event(event) {
23588                // Handle event click
23589                this.$emit("clicked_event", event);
23590            },
23591            clicked_show_more_events() {
23592                // Handle "+ x more" click (e.g., show a modal or expand view
23592)
23593                this.$emit("clicked_show_more_events", this.date, this.day_of_week, this.events);
23594            },
23595            should_hold_slot(slot, index) {
23596                const usedSlots = this.return_slots_in_use_for_week(slot, index);
23597                return usedSlots.includes(index);
23598            },
23599            return_slots_in_use_for_week(slot, index) {
23600                const startOfWeek = moment.tz(this.date, this.timezone).startOf("week");
23601                const endOfWeek = moment.tz(this.date, this.timezone).endOf("week");
23602
23603                const slotsUsed = new Set();
23604
23605                this.days_array.forEach(day => {
23606                    const dayMoment = moment.tz(day.date, this.timezone).startOf("day");
23607
23608                    // Only consider days in the current week
23609                    if (dayMoment.isBetween(startOfWeek, endOfWeek, null, '[]')) {
23610                        if (Array.isArray(day.slots)) {
23611                            day.slots.forEach(slotObj => {
23612                                if (slotObj.event) {
23613                                    slotsUsed.add(slotObj.slot);
23614                                }
23615                            });
23616                        }
23617                    }
23618                });
23619                return Array.from(slotsUsed);
23620            },
23621            // --- text color helpers ---
23622            computeLuminance(hex) {
23623                if (!hex) return 1; // treat as light
23624                const m = String(hex).trim();
23625                let r, g, b;
23626                if (/^#?[0-9a-fA-F]{3}$/.test(m)) {
23627                    const s = m.replace('#','');
23628                    r = parseInt(s[0] + s[0], 16);
23629                    g = parseInt(s[1] + s[1], 16);
23630                    b = parseInt(s[2] + s[2], 16);
23631                } else {
23632                    const s = m.replace('#','').slice(0,6);
23633                    r = parseInt(s.substr(0,2), 16);
23634                    g = parseInt(s.substr(2,2), 16);
23635                    b = parseInt(s.substr(4,2), 16);
23636                }
23637                const toLin = (c) => {
23638                    c /= 255;
23639                    return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
23640                };
23641                return 0.2126*toLin(r) + 0.7152*toLin(g) + 0.0722*toLin(b); // 0=dark, 1=light
23642            },
23643            adaptiveTextColorFromColors(colors, lightText = '#ffffff', darkText = '#2c2e30') {
23644                const arr = (colors && colors.length) ? colors : [];
23645                if (!arr.length) {
23646                    const defaultBg = '#4a56f8';
23647                    return this.computeLuminance(defaultBg) < 0.5 ? lightText : darkText;
23648                }
23649                let darkCount = 0;
23650                for (let i = 0; i < arr.length; i++) {
23651                    const L = this.computeLuminance(arr[i]);
23652                    if (!isNaN(L) && L < 0.5) darkCount++;
23653                }
23654                const mostlyDark = darkCount >= Math.ceil(arr.length / 2);
23655                return mostlyDark ? lightText : darkText;
23656            },
23657        },        
23658        computed:{
23659            is_today(){
23660                return moment.tz(new Date(), this.timezone).isSame(moment.tz(this.date, this.timezone), 'day');
23661            },
23662            overflowCount() {
23663                // Calculate the number of hidden events
23664                let visible_events = 0;
23665                this.slots.forEach(slot => {
23666                    if( slot.event ){
23667                        visible_events ++;
23668                    }
23669                });
23670                return this.events.length - visible_events;
23671            },
23672            days_until_end_of_week(){
23673                let start = moment.tz(this.date, this.timezone);
23674                let days_until_end_of_week = 7 - start.day();
23675                return days_until_end_of_week;
23676            },
23677            is_first_day_of_week(){
23678                return moment.tz(this.date, this.timezone).day() === 0;
23679            },
23680        },
23681    })
23682</script>
23682    
23682<script type="text/html" id="template-booking-calendar-day-column">
23683    
23684    <div class="flexGrowOne">
23685        <!-- Time Slots Section -->
23686        <div class="time-slots">
23687            <!-- all day -->
23688            <div 
23689                v-if="view_preference == 'Day'"
23690                class="time-slot"
23691                style="height:auto;"
23692            >
23693                <div class="time-label">All day</div>
23694                <div 
23695                    class="time-content"
23696                    style="display: flex;flex-direction: column;gap: 4px; padding: 8px;"
23697                >
23698                    <!-- Filter and position events for this hour -->
23699                    
23700                    <div v-for="(event, index) in all_day_events" :key="index" class="event all_day">
23701                        <div 
23702                            class="event-title-v2 flexNoWrap align-items-center easeSuperFast"
23703                            :style="all_day_event_style(event)"
23704                            @click="clicked_event(event)"
23705                        >
23706                            {{ event.title }}
23707                        </div>
23708                    </div>
23709
23710                </div>
23711            </div>
23712            <!-- hours -->
23713            <div 
23714                v-for="hour in 24" 
23715                :key="hour" 
23716                class="time-slot pointer"
23717                @click="add_event( hour - 1 )"
23718            >
23719                <div 
23720                    class="time-label"
23721                    v-if="view_preference == 'Day'"
23722                >{{ formatHour( hour - 1 ) }}</div>
23723                <div class="time-content">
23724                    <!-- Filter and position events for this hour -->
23725                    <div
23726                        v-for="(event, index) in filterEventsForHour(hour - 1)"
23727                        :key="index"
23728                        class="event time-slot-event"
23729                        :style="calculateEventStyle(event, index)"
23730                        @click.stop="clicked_event( event )"
23731                    >
23732                        <div class="event-title-v2" style="white-space: initial;">{{ event.title }}</div>
23733                    </div>
23734                </div>
23735            </div>
23736        </div>
23737    </div>
23738    
23739</script>
23739
23740
23741<style scoped>
23742
23743    .event.time-slot-event {
23744        position: absolute;
23745        left: 5px; /* Align slightly inside the slot */
23746        right: 5px;
23747        color: white;
23748        border-radius: 4px;
23749        font-size: 12px;
23750        font-weight: 500;
23751        overflow: hidden;
23752        white-space: nowrap;
23753        text-overflow: ellipsis;
23754        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
23755        z-index: 1;
23756        padding-top: 0px;
23757        padding-bottom: 0px;
23758        padding-left: 8px;
23759        padding-right: 8px;
23760        cursor: pointer;
23761        background-color: #5774f5; /* Primary color */
23762    }
23763
23764</style>
23765
23766<script>
23767
23768  /* ----- COMPONENT ----- */
23769  app_content.component("booking-calendar-day-column", {
23770        template: "#template-booking-calendar-day-column",
23771        data() {
23772            return {}
23773        },
23774        props: {
23775            date_components: Object,
23776            events: Array,
23777            timezone: String,
23778            view_preference: String
23779        },
23780        mounted(){
23781        },
23782        methods: {
23783            add_event( hour ){
23784                let date_components = {... this.date_components};
23785                date_components.hour = hour;
23786                date_components.date = this.date_components.day;
23787                this.$emit('add_event', date_components);
23788            },
23789            clicked_event( event ){
23790                this.$emit('clicked_event', event);
23791            },
23792            formatHour(hour) {
23793                const baseDate = moment().startOf('day').hour(hour);
23794                return baseDate.format("h:mm A");
23795            },
23796            filterEventsForHour(hour) {
23797                const filtered = this.events.filter((event) => {
23798                    if (event.isAllDay) return false;
23799                    const eventStart = moment(event.ts).tz(this.timezone);
23800                    return eventStart.hour() === hour;
23801                });
23802                const count = filtered.length;
23803                return filtered.map(event => ({
23804                    ...event,
23805                    countForHour: count
23806                }));
23807            },
23808            all_day_event_style(event){
23809                return {
23810                    "background": event.bgColors && event.bgColors.length > 0
23811                            ? `repeating-linear-gradient(
23812                                    -45deg,
23813                                    ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")}
23814                                )`
23815                            : event.bgColor || "",
23816                };
23817            },
23818            calculateEventStyle(event, i) {
23819                const eventStart = moment( event.ts ).tz(this.timezone);
23820                const eventEnd = moment(event.end_ts).tz(this.timezone);
23821                const count = event.countForHour;
23822
23823                // Calculate start and end as a percentage of the hour
23824                const startMinutes = eventStart.minutes();
23825                const endMinutes = eventEnd.minutes();
23826                const duration = eventEnd.diff(eventStart, "minutes");
23827
23828                return {
23829                    top: `${(startMinutes / 60) * 100}%`, // Start position
23830                    height: `calc(${(duration / 60) * 100}% - 3px)`, // Subtracts 3px for padding
23831                    width: `calc(${(1/count) * 100}% - 3px)`, // Subtracts 3px for padding
23832                    'margin-left': `calc(${(i/count) * 100}%)`,
23833                    'padding-left': '0px',
23834                    'padding-right': '0px',
23835                    "background": event.bgColors && event.bgColors.length > 0
23836                            ? `repeating-linear-gradient(
23837                                    -45deg,
23838                                    ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")}
23839                                )`
23840                            : event.bgColor || "",
23841                };
23842            },
23843        },
23844        computed:{
23845            all_day_events(){
23846                let all_day =  this.events.filter( event => event.isAllDay === true );
23847                return all_day;
23848            },
23849        },
23850  })
23851</script>
23851
23852
23853
23854
23855
23856
23857
23858    <!-- native tab bar -->
23859    <!DOCTYPE html>
23860<html lang="en">
23861    
23861<script type="text/html" id="template-bottom-sheet-overlay">
23862
23863    <div 
23864        class="v-overlay scrollable hugePaddingBottom active" 
23865        @touchstart.self="touched_overlay()"
23866        @mousedown.self="touched_overlay()"
23867        @click.self="clicked_overlay()"
23868        :class="[{'noPointer': prevent_close}, {'bottom-sheet-blur': blur_background}]"
23869    >
23870        <div 
23871            class="standardOverlayCard centeredBlock bottom-sheet"
23872            :class="[
23873                {'short': height == 'short' || is_short}, 
23874                {'narrow': narrow},
23875                {'noMaxWidth': full_width}
23876            ]"
23877        >
23878
23879            <!-- generic header content -->
23880            <div class="bottom-sheet-overlay-header">
23881
23882                <div 
23883                    v-if="! prevent_close"
23884                    class="relative"
23885                >
23886                    <div 
23887                        class="overlay-header-handle-wrap centeredBlock"
23888                        @click.stop="toggle_height()" 
23889                    >
23890                        <div class="overlay-header-handle"></div>
23891                    </div>
23892                    <i 
23893                        class="overlay-x-button fa-regular fa-xmark pointer"
23894                        @click.stop="dismiss_overlay()"
23895                    ></i>
23896                </div>
23897
23898                <!-- header content injected here -->
23899                <slot name="header"></slot>
23900
23901            </div>
23902
23903            <!-- body content injected here -->
23904            <slot name="body"></slot>
23905
23906            <!-- footer content injected here -->
23907            <slot name="footer"></slot>
23908
23909        </div>
23910    </div>
23911
23912</script>
23912
23913
23914<style>
23915
23916    .standardOverlayCard.bottom-sheet.narrow{
23917        max-width: 600px;
23918    }
23919
23920    .standardOverlayCard.bottom-sheet.medium{
23921        max-width: 800px;
23922    }
23923
23924    .standardOverlayCard.bottom-sheet{
23925        position: fixed;
23926        bottom: 0px;
23927        top: 50px;
23928        left: 0px;
23929        right: 0px;
23930        z-index: 1001;
23931        background-color: #fff;
23932        box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
23933        transition: bottom 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
23934        border-top-left-radius: 10px;
23935        border-top-right-radius: 10px;
23936        padding-bottom: 10px;
23937        padding-top: 10px;
23938        max-width: 1100px;
23939        margin-top: 0px;
23940        border-bottom-left-radius: 0px;
23941        border-bottom-right-radius: 0px;
23942    }
23943
23944    .standardOverlayCard.bottom-sheet.short{
23945        top: 50%;
23946    }
23947
23948    @media (max-height: 600px) {
23949
23950        .standardOverlayCard.bottom-sheet.short{
23951            top: 150px;
23952        }
23953
23954    }
23955
23956    .standardOverlayCard.bottom-sheet h3{
23957        padding-top:0px;
23958    }
23959
23960    .standardOverlayCard.bottom-sheet{
23961        display: flex;
23962        flex-direction: column;
23963        padding-bottom:0px;
23964        padding-top: 0px;
23965    }
23966
23967    .standardOverlayCard.bottom-sheet .bottom-sheet-overlay-card-header
23968    {
23969        flex: 0 0 auto; /* Fixed height, doesn't grow or shrink */
23970    }
23971
23972    .standardOverlayCard.bottom-sheet .standardCardFooter{
23973        position: absolute;
23974        bottom: 0px;
23975        padding-bottom: var(--cs-safe-area-bottom, 10px);
23976        left: 0px;
23977        right: 0px;
23978        background-color: #fff;
23979        border-top: 1px solid #e4ebf1;
23980        z-index: 10;
23981    }
23982
23983    .standardOverlayCard.bottom-sheet .standardCardBody{
23984        flex: 1 1 auto; /* Flexible height, can grow and shrink */
23985        padding-bottom: 90px !important;
23986    }
23987
23988    .overlay-header-handle-wrap{
23989        padding: 10px;
23990        cursor: pointer;
23991        max-width: 120px;
23992    }
23993    .overlay-header-handle{
23994        width: 70px;
23995        height: 3px;
23996        border-radius: 10px;
23997        background-color: #acb3ba;
23998        margin-left: auto;
23999        margin-right: auto;
24000        margin-top: 8px;
24001        margin-bottom: 8px;
24002    }
24003    .overlay-x-button{
24004        position: absolute;
24005        right: 14px;
24006        color: #535e6c;
24007        font-size: 20px;
24008        top: 12px;
24009    }
24010</style>
24011
24012<script>
24013
24014    //define the component
24015    app_overlays.component("bottom-sheet-overlay", {
24016        template: "#template-bottom-sheet-overlay",
24017        data() {
24018            return {
24019                height: "tall"
24020            }
24021        },
24022        props: {
24023            blur_background: {
24024                type: Boolean,
24025                default: false
24026            },
24027            narrow: {
24028                type: Boolean,
24029                default: false
24030            },
24031            prevent_close: {
24032                type: Boolean,
24033                default: false
24034            },
24035            full_width: {
24036                type: Boolean,
24037                default: false
24038            },
24039            is_short: {
24040                type: Boolean,
24041                default: false
24042            }
24043        },
24044        mounted(){
24045            const universalEditOverlay = document.getElementById('overlay_universal-edit');
24046            if (universalEditOverlay) {
24047                universalEditOverlay.style.zIndex = "1000000";
24048            }
24049            const membershipValidationOverlay = document.getElementById('overlay_verifyMembership');
24050            if (membershipValidationOverlay) {
24051                membershipValidationOverlay.style.zIndex = "1000000";
24052            }
24053        },
24054        beforeUnmount() {
24055            //reset the z-index
24056            const universalEditOverlay = document.getElementById('overlay_universal-edit');
24057            if (universalEditOverlay) {
24058                universalEditOverlay.style.zIndex = ""; // Revert to default
24059            }
24060            const membershipValidationOverlay = document.getElementById('overlay_verifyMembership');
24061            if (membershipValidationOverlay) {
24062                membershipValidationOverlay.style.zIndex = ""; // Revert to default
24063            }
24064        },
24065        methods: {
24066            dismiss_overlay(){
24067                this.$emit('dismiss_overlay');
24068            },
24069            toggle_height(){
24070                if( this.height === "tall" ){
24071                    this.height = "short";
24072                }else{
24073                    this.height = "tall";
24074                }
24075            },
24076            clicked_overlay(){
24077                if( this.prevent_close ){
24078                    return;
24079                }
24080                if (should_close_overlay(this.last_touch)) {
24081                    this.dismiss_overlay();
24082                }
24083            },
24084            touched_overlay(){
24085                this.last_touch = new Date().getTime();
24086            },
24087        },
24088    });
24089
24090    
24091</script>
24091
24092
24093<style>
24094    .bottom-sheet-blur{
24095        backdrop-filter: blur(6px);
24096    }
24097
24098    @keyframes bottom-sheet-fade-in {
24099        from { opacity: 0; }
24100        to   { opacity: 1; }
24101    }
24102</style>    
24102<script type="text/html" id="template-show-push-notification">
24103	
24104    <bottom-sheet-overlay
24105        @dismiss_overlay="dismiss_overlay()"
24106    >
24107
24108        <!-- header content -->
24109        <template #header>
24110            <div class="flexNoWrap">
24111                <h3 
24112                    class="flexGrowOne" 
24113                    style="padding-bottom:0px;"
24114                >
24115                    <p>{{ title }}</p>
24116                    <template v-if="has_date">
24117                        <p 
24118                            class="card-header-subtext pointer"
24119                        >{{ date_string }}</p>
24120                    </template>
24121                </h3>
24122            </div>
24123        </template>
24124
24125        <!-- body content -->
24126        <template #body>
24127            <div 
24128                class="standardCardBody fff scrollable"
24129            >
24130                <p class="p-1" style="margin: 0px 8px;">{{ notification.get('data').alert }}</p>
24131            </div>
24132        </template>
24133
24134        <!-- footer content -->
24135        <template 
24136            #footer
24137        >
24138            <div 
24139                class="standardCardFooter flexNoWrap"
24140            >
24141                <div class="flexGrowOne"></div>
24142                <button class="marketing-header-button blue easeSuperFast flexWrapLeftAlign align-items-center" @click="dismiss_overlay()" >Done</button>
24143            </div>
24144        </template>
24145
24146    </bottom-sheet-overlay>
24147
24148</script>
24148
24149
24150<script>
24151
24152    function show_push_notification( params ){
24153        store.components.show_push_notification.notification = params.notification;
24154        store.overlays.show_push_notification = true;
24155    }
24156
24157    //add a value to our global store, specific to this component
24158    store.components["show_push_notification"] = {
24159        notification: null
24160    }
24161
24162    //define the component
24163    app_overlays.component("show-push-notification", {
24164        template: "#template-show-push-notification",
24165        data() {
24166            return store.components["show_push_notification"]
24167        },        
24168        methods: {
24169            dismiss_overlay(){
24170                store.overlays.show_push_notification = false;
24171            } 
24172        },
24173        computed: {
24174            title(){               
24175                let title = this.notification.get('data').title;
24176                if (title && title.length > 0){
24177                    return title;
24178                }
24179                return "Notification";
24180            },
24181            has_date(){
24182                return this.notification.createdAt !== undefined;
24183            },
24184            date_string(){
24185                const createdAt = this.notification.createdAt;
24186
24187                if (! createdAt){
24188                    return createdAt;
24189                }
24190
24191                if ( is_utc_date_today( createdAt ) ){
24192                    return format_utc_date_v2({
24193                        "utcDate": createdAt,
24194                        "formatString": "[Today at] h:mm A"
24195                    });
24196                } else {
24197                    return format_utc_date_v2({
24198                        "utcDate": createdAt,
24199                        "formatString": "ddd M/D h:mm A"
24200                    });
24201                }
24202            }
24203        }
24204    });
24205
24206</script>
24206    
24206<script type="text/html" id="template-native-tab-bar">
24207
24208    <!-- first, make sure we're in the react native member app -->
24209    <template v-if="is_member_portal || is_react_native_member_app || is_admin_preview">
24210
24211        <!-- ── Floating glass tab bar ─────────── -->
24212        <div
24213            v-if="show_tab_bar && use_floating_tab_bar"
24214            class="mp-float-tab-bar"
24215            :class="{
24216                'mp-float-tab-bar--preview': is_admin_preview,
24217                'mp-float-tab-bar--settings-preview': settings_preview_mode
24218            }"
24219        >
24220            <!-- Single sliding pill — moves between tabs via CSS transition -->
24221            <div class="mp-float-tab-pill" :style="float_pill_style"></div>
24222
24223            <template v-if="visible_tabs">
24224                <div
24225                    v-for="tab in visible_tabs"
24226                    :key="tab.key"
24227                    class="mp-float-tab-item"
24228                    :class="{ 'mp-float-tab-item--active': tab_is_active(tab) }"
24229                    @touchstart.prevent="tapped_tab_bar_item(tab)"
24230                    @click="tapped_tab_bar_item(tab)"
24231                >
24232                    <template v-if="show_member_details_avatar(tab)">
24233                        <span
24234                            class="mp-float-tab-avatar"
24235                            :class="{ 'mp-float-tab-avatar--active': tab_is_active(tab) }"
24236                            :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }"
24237                        ></span>
24238                    </template>
24239                    <template v-else>
24240                        <i class="mp-float-tab-icon" :class="float_icon_class(tab, tab_is_active(tab))"></i>
24241                    </template>
24242                </div>
24243                <!-- More item -->
24244                <div
24245                    class="mp-float-tab-item"
24246                    :class="{ 'mp-float-tab-item--active': more_is_active }"
24247                    @touchstart.prevent="tapped_more()"
24248                    @click="tapped_more()"
24249                >
24250                    <i :class="(more_is_active ? 'fa-solid' : 'fa-regular') + ' fa-ellipsis mp-float-tab-icon'"></i>
24251                </div>
24252            </template>
24253        </div>
24254
24255        <!-- Classic tab bar ─────────────── -->
24256        <div 
24257            v-else-if="show_tab_bar"
24258            class="native-tab-bar-outer flexNoWrap"
24259            :style="[{ 
24260                backgroundColor: tab_bar_colors.background || 'rgba(249, 251, 251, 1)' 
24261            }, {
24262                backdropFilter: tab_bar_colors.blur ? 'blur(' + tab_bar_colors.blur + ')' : 'none'
24263            }]"
24264            :class="{ 'noBorder': tab_bar_colors.background, 'preview': is_admin_preview }"
24265        >
24266            <template v-if="visible_tabs">
24267                <div 
24268                    v-for="tab in visible_tabs"
24269                    :key="tab.key"
24270                    class="native-tab-bar-item"
24271                    :class="{ active : tab_is_active( tab ) }"
24272                    :style=" tab_is_active( tab ) ? { color: tab_bar_colors.active || '#4a56f8' } : { color: tab_bar_colors.text || '#697684' } "
24273                    @touchstart.prevent="tapped_tab_bar_item( tab )"
24274                    @click="tapped_tab_bar_item( tab )"
24275                >
24276                    <div class="centeredText centeredBlock">
24277                        <p>
24278                            <template v-if="show_member_details_avatar(tab)">
24279                                <span
24280                                    class="native-tab-bar-avatar"
24281                                    :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }"
24282                                ></span>
24283                            </template>
24284                            <template v-else>
24285                                <i class="native-tab-bar-icon" :class="tab.icon_class"></i>
24286                            </template>
24287                        </p>
24288                        <p class="native-tab-bar-label">{{ tab.label }}</p>
24289                    </div>
24290                </div>
24291                <div 
24292                    class="native-tab-bar-item"
24293                    @touchstart.prevent="tapped_more()"
24294                    @click="tapped_more()"
24295                    :class="{ 'active': more_is_active }"
24296                    :style=" more_is_active ? { color: tab_bar_colors.active || '#4a56f8' } : { color: tab_bar_colors.text || '#697684' } "
24297                >
24298                    <div class="centeredText centeredBlock">
24299                        <p><i class="fa-regular fa-ellipsis native-tab-bar-icon"></i></p>
24300                        <p class="native-tab-bar-label">More</p>
24301                    </div>
24302                </div>
24303            </template>
24304        </div>
24305
24306        <!-- compact more-section tab bar (floating) -->
24307        <div
24308            v-else-if="show_more_section_tab_bar && use_floating_tab_bar"
24309            class="mp-float-tab-bar"
24310        >
24311            <div class="mp-float-tab-pill" :style="compact_float_pill_style"></div>
24312            <div
24313                class="mp-float-tab-item"
24314                @touchstart.prevent="tapped_back_immediate()"
24315                @click="tapped_back_immediate()"
24316            >
24317                <i class="fa-regular fa-chevron-left mp-float-tab-icon"></i>
24318            </div>
24319            <div
24320                class="mp-float-tab-item"
24321                @touchstart.prevent="tapped_home()"
24322                @click="tapped_home()"
24323            >
24324                <i class="mp-float-tab-icon" :class="compact_home_icon_class"></i>
24325            </div>
24326            <div
24327                class="mp-float-tab-item mp-float-tab-item--active"
24328                @touchstart.prevent="tapped_tab_bar_item(compact_selected_tab)"
24329                @click="tapped_tab_bar_item(compact_selected_tab)"
24330            >
24331                <template v-if="show_member_details_avatar(compact_selected_tab)">
24332                    <span
24333                        class="mp-float-tab-avatar mp-float-tab-avatar--active"
24334                        :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }"
24335                    ></span>
24336                </template>
24337                <template v-else>
24338                    <i class="mp-float-tab-icon" :class="compact_selected_tab_icon_class"></i>
24339                </template>
24340            </div>
24341            <div
24342                class="mp-float-tab-item"
24343                @touchstart.prevent="tapped_more()"
24344                @click="tapped_more()"
24345            >
24346                <i class="fa-regular fa-ellipsis mp-float-tab-icon"></i>
24347            </div>
24348        </div>
24349
24350        <!-- compact more-section tab bar (classic) -->
24351        <div
24352            v-else-if="show_more_section_tab_bar"
24353            class="native-tab-bar-outer flexNoWrap"
24354            :style="[{ 
24355                backgroundColor: tab_bar_colors.background || 'rgba(249, 251, 251, 1)' 
24356            }, {
24357                backdropFilter: tab_bar_colors.blur ? 'blur(' + tab_bar_colors.blur + ')' : 'none'
24358            }]"
24359            :class="{ 'noBorder': tab_bar_colors.background, 'preview': is_admin_preview }"
24360        >
24361            <div
24362                class="native-tab-bar-item"
24363                :style="{ color: tab_bar_colors.text || '#697684' }"
24364                @touchstart.prevent="tapped_back_immediate()"
24365                @click="tapped_back_immediate()"
24366            >
24367                <div class="centeredText centeredBlock">
24368                    <p><i class="fa-regular fa-chevron-left native-tab-bar-icon"></i></p>
24369                    <p class="native-tab-bar-label">Back</p>
24370                </div>
24371            </div>
24372            <div
24373                class="native-tab-bar-item"
24374                :style="{ color: tab_bar_colors.text || '#697684' }"
24375                @touchstart.prevent="tapped_home()"
24376                @click="tapped_home()"
24377            >
24378                <div class="centeredText centeredBlock">
24379                    <p><i class="native-tab-bar-icon" :class="compact_home_icon_class"></i></p>
24380                    <p class="native-tab-bar-label">{{ compact_home_tab_label }}</p>
24381                </div>
24382            </div>
24383            <div
24384                class="native-tab-bar-item active"
24385                :style="{ color: tab_bar_colors.active || '#4a56f8' }"
24386                @touchstart.prevent="tapped_tab_bar_item(compact_selected_tab)"
24387                @click="tapped_tab_bar_item(compact_selected_tab)"
24388            >
24389                <div class="centeredText centeredBlock">
24390                    <p>
24391                        <template v-if="show_member_details_avatar(compact_selected_tab)">
24392                            <span
24393                                class="native-tab-bar-avatar"
24394                                :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }"
24395                            ></span>
24396                        </template>
24397                        <template v-else>
24398                            <i class="native-tab-bar-icon" :class="compact_selected_tab_icon_class"></i>
24399                        </template>
24400                    </p>
24401                    <p class="native-tab-bar-label">{{ compact_selected_tab.label }}</p>
24402                </div>
24403            </div>
24404            <div
24405                class="native-tab-bar-item"
24406                :style="{ color: tab_bar_colors.text || '#697684' }"
24407                @touchstart.prevent="tapped_more()"
24408                @click="tapped_more()"
24409            >
24410                <div class="centeredText centeredBlock">
24411                    <p><i class="fa-regular fa-ellipsis native-tab-bar-icon"></i></p>
24412                    <p class="native-tab-bar-label">More</p>
24413                </div>
24414            </div>
24415        </div>
24416
24417        <!-- back button (global) -->
24418        <div v-else-if="show_back_button">
24419
24420            <!-- overlay -->
24421            <div 
24422                class="native-tab-bar-back-button-overlay pointer"
24423                @touchstart.self.prevent="back_button_expanded = false"
24424                @click.self="back_button_expanded = false"
24425                :class="{ 'active': back_button_expanded }"
24426            ></div>
24427
24428            <!-- home button -->
24429            <div 
24430                class="native-tab-bar-floating-home-button boxShadowBottom moreBlur pointer easeFast"
24431                @click="tapped_home()"
24432                :class="{ 'active': back_button_expanded }"
24433            >
24434                <div class="centeredText centeredBlock">
24435                    <p><i class="fa-solid fa-house native-tab-bar-icon" style="font-size:20px;"></i><span v-if="back_button_expanded" style="padding-left:6px;font-size:18px;">Home</span></p>
24436                </div>
24437            </div>
24438
24439            <!-- back button -->
24440            <div 
24441                class="native-tab-bar-floating-back-button boxShadowBottom moreBlur pointer easeFast"
24442                @click="tapped_back()"
24443                :class="{ 'expanded': back_button_expanded }"
24444            >
24445                <div class="centeredText centeredBlock">
24446                    <p><i class="fa-regular fa-chevron-left native-tab-bar-icon" style="font-size:20px;"></i><span v-if="back_button_expanded" style="padding-left:6px;font-size:18px;">Back</span></p>
24447                </div>
24448            </div>
24449
24450        </div>
24451
24452    </template>
24453    
24454</script>
24454
24455
24456
24457<style>
24458
24459    .native-tab-bar-outer{
24460        position: fixed;
24461        bottom: 0px;   
24462        padding-bottom: max(0px, calc(var(--cs-safe-area-bottom, 0px) - 10px));
24463        left: 0px;
24464        right: 0px;
24465        background-color: rgba(249, 251, 251, 1);
24466        border-top: 1px solid #e4ebf1;
24467        z-index: 1000;
24468        transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1);
24469    }
24470
24471    .native-tab-bar-padding-bottom {
24472        padding-bottom: var(--native-tab-bar-padding-bottom, 0px);
24473    }
24474
24475    /* In admin preview mode this is just a sample, so keep z-index low and let real UI layers sit above it. */
24476    .native-tab-bar-outer.preview{
24477        position: relative;
24478        z-index: 1;
24479    }
24480
24481    .native-tab-bar-item{
24482        padding: 16px 11px;
24483        display: flex;
24484        flex: 1 1 0; /* flex-grow: 1; flex-shrink: 1; flex-basis: 0; */
24485        color: #697684;
24486        cursor: pointer;
24487    }
24488    .native-tab-bar-item.active{
24489        color: #4a56f8;
24490    }
24491    .native-tab-bar-label{
24492        font-size: 12px;
24493        padding-top: 4px;
24494        white-space: nowrap;
24495    }
24496    .native-tab-bar-icon{
24497        font-size: 22px;
24498    }
24499    .native-tab-bar-avatar{
24500        width: 23px;
24501        height: 23px;
24502        border-radius: 100%;
24503        background-size: cover;
24504        background-position: center center;
24505        background-repeat: no-repeat;
24506        display: inline-block;
24507        vertical-align: middle;
24508    }
24509
24510    /*------  back button  -------*/
24511
24512    .native-tab-bar-back-button-overlay{
24513        position: fixed;
24514        top: 0;
24515        left: 0;
24516        width: 100%;
24517        height: 100%;
24518        /*
24519        background-color: rgba(111,117,122,0.5);
24520        */
24521        background-color: rgba(249, 251, 251, 0.8);
24522        opacity: 0;
24523        visibility: hidden;
24524        transition: opacity 0.15s ease;
24525        z-index: 999;
24526    }
24527
24528    .native-tab-bar-back-button-overlay.active{
24529        opacity: 1;
24530        visibility: visible;
24531    }
24532
24533    .native-tab-bar-floating-back-button{
24534        position: fixed;
24535        bottom: 22px;
24536        left: 22px;
24537        right: auto;
24538        z-index: 1000;
24539        transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1);
24540        border-radius: 100%;
24541        width: 50px;
24542        height: 50px;
24543        background-color: rgba(255, 255, 255, 0.28);
24544        backdrop-filter: blur(15px);
24545        -webkit-backdrop-filter: blur(15px);
24546        line-height: 50px;
24547        font-size: 16px;
24548        white-space: nowrap;
24549        min-width: 50px;
24550    }
24551
24552    .native-tab-bar-floating-back-button.expanded{
24553        border-radius: 25px;
24554        padding-left: 16px;
24555        padding-right: 16px;
24556        width: auto;
24557    }
24558
24559    /*------  home button  -------*/
24560
24561    .native-tab-bar-floating-home-button.active{
24562        border-radius: 25px;
24563        padding-left: 16px;
24564        padding-right: 16px;
24565        width: auto;
24566        visibility: visible;
24567        opacity: 1;
24568        animation: expand 0.15s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
24569        background-color: #4a56f8;
24570        color: #fff;
24571    }
24572
24573    .native-tab-bar-floating-home-button{
24574        position: fixed;
24575        bottom: 82px;
24576        left: 22px;
24577        right: auto;
24578        visibility: hidden;
24579        opacity: 0;
24580        background-color: rgba(249, 251, 251, 1);
24581        /* border-top: 1px solid #e4ebf1; */
24582        z-index: 1000;
24583        transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1);
24584        border-radius: 20px;
24585        width: auto;
24586        height: 50px;
24587        background-color: #fff;
24588        line-height: 50px;
24589        font-size: 16px;
24590        white-space: nowrap;
24591
24592        /* Initial state for animation */
24593        transform: scale(0);
24594        animation: none;
24595    }
24596
24597    /* Keyframes for the expansion effect */
24598    @keyframes expand {
24599        from {
24600            transform: scale(0);
24601            opacity: 0;
24602        }
24603        to {
24604            transform: scale(1);
24605            opacity: 1;
24606        }
24607    }
24608
24609    /* ── Floating glass tab bar (club XVgOrNASDY only) ─────────────────── */
24610
24611    .mp-float-tab-bar {
24612        position: fixed;
24613        /* sit above the safe-area bottom edge with a small gap */
24614        bottom: max(12px, var(--cs-safe-area-bottom, 12px));
24615        /* center the pill horizontally; max-width keeps it compact on tablets */
24616        left: 0;
24617        right: 0;
24618        margin-left: auto;
24619        margin-right: auto;
24620        max-width: 360px;
24621        width: calc(100% - 48px);
24622        /* light frosted glass pill */
24623        border-radius: 30px;
24624        background: rgba(255, 255, 255, 0.28);
24625        backdrop-filter: blur(15px);
24626        -webkit-backdrop-filter: blur(15px);
24627        border: 1px solid rgba(0, 0, 0, 0.06);
24628        box-shadow: 0 4px 28px rgba(0, 0, 0, 0.12);
24629        /* layout */
24630        display: flex;
24631        align-items: center;
24632        z-index: 1000;
24633        overflow: hidden;
24634    }
24635
24636    .mp-float-tab-bar--preview {
24637        position: relative;
24638        bottom: auto;
24639        margin: 12px auto;
24640        /* In preview mode we do not want the production-level z-index to overlap the left menu peek. */
24641        z-index: 1;
24642    }
24643
24644    .mp-float-tab-bar--settings-preview {
24645        margin: 0;
24646    }
24647
24648    .mp-float-tab-item {
24649        flex: 1 1 0;
24650        display: flex;
24651        align-items: center;
24652        justify-content: center;
24653        position: relative;
24654        padding: 18px 0;
24655        cursor: pointer;
24656        /* tap highlight off */
24657        -webkit-tap-highlight-color: transparent;
24658    }
24659
24660    /* Single sliding active pill — left and width are driven by float_pill_style computed */
24661    .mp-float-tab-pill {
24662        position: absolute;
24663        top: 7px;
24664        bottom: 7px;
24665        /* left + width set inline; transition slides the pill between tabs */
24666        border-radius: 22px;
24667        background: rgba(0, 0, 0, 0.08);
24668        pointer-events: none;
24669        transition: left 0.28s cubic-bezier(0.25, 0.1, 0.25, 1),
24670                    width 0.28s cubic-bezier(0.25, 0.1, 0.25, 1);
24671    }
24672
24673    .mp-float-tab-icon {
24674        font-size: 22px;
24675        color: rgba(0, 0, 0, 0.7);
24676        position: relative; /* above the pill */
24677        z-index: 1;
24678    }
24679
24680    .mp-float-tab-avatar {
24681        width: 26px;
24682        height: 26px;
24683        border-radius: 50%;
24684        background-size: cover;
24685        background-position: center;
24686        background-repeat: no-repeat;
24687        display: inline-block;
24688        position: relative;
24689        z-index: 1;
24690        opacity: 1;
24691        box-shadow: 0 0 0 2px transparent;
24692        transition: box-shadow 0.2s ease;
24693    }
24694
24695    .mp-float-tab-avatar--active {
24696        box-shadow: 0 0 0 2px #fff;
24697    }
24698
24699</style>
24700
24701<script>
24702
24703    let native_tab_bar_config = {
24704        template: "#template-native-tab-bar",
24705        data() {
24706            return {
24707                back_button_expanded: false
24708            }
24709        },
24710        props: {
24711            primary_view: String,
24712            tabs: Array, 
24713            hide_back_button: Boolean,
24714            is_admin_preview: {
24715                type: Boolean,
24716                default: false
24717            },
24718            preview_colors: {
24719                type: Object,
24720                default: null
24721            },
24722            preview_tab_bar_style: {
24723                type: String,
24724                default: null
24725            },
24726            member_portal_config: {
24727                type: Object,
24728                default: null
24729            },
24730            settings_preview_mode: {
24731                type: Boolean,
24732                default: false
24733            },
24734            is_member_portal: {
24735                type: Boolean,
24736                default: false
24737            }
24738        },
24739        mounted(){
24740            console.log("Mounted native tab bar");
24741        },
24742        methods: {
24743            tab_is_active( tab ){
24744                if( this.primary_view ){
24745                    return tab.key === this.primary_view;
24746                }
24747                return false;
24748            },
24749            tapped_home(){
24750                if(this.is_admin_preview){
24751                    return;
24752                }
24753                if( this.is_member_portal ){
24754                    this.back_button_expanded = false;
24755                    updateURL('home');
24756                    displayViewForUrlPath();
24757                }else{
24758                    window.location.href = '/account/home';
24759                }
24760            },
24761            tapped_more(){
24762                if(this.is_admin_preview){
24763                    return;
24764                }
24765                toggle_left_menu();
24766            },
24767            tapped_tab_bar_item( tab ){
24768                if(this.is_admin_preview){
24769                    return;
24770                }
24771                if( tab && tab.type === 'custom' && tab.link ){
24772                    window.location.href = tab.link;
24773                    return;
24774                }
24775                updateURL( tab.key );
24776                displayViewForUrlPath();
24777            },
24778            tapped_back(){
24779                if(this.is_admin_preview){
24780                    return;
24781                }
24782                if( this.back_button_expanded ){
24783                    this.back_button_expanded = false;
24784                    window.history.back();
24785                } else {
24786                    this.back_button_expanded = true;
24787                }
24788            },
24789            tapped_back_immediate(){
24790                if(this.is_admin_preview){
24791                    return;
24792                }
24793                window.history.back();
24794            },
24795            float_icon_class( tab, is_active ){
24796                if (!tab || !tab.icon_class) {
24797                    return '';
24798                }
24799                if (is_active) {
24800                    return tab.icon_class.replace('fa-regular', 'fa-solid');
24801                }
24802                return tab.icon_class;
24803            },
24804            show_member_details_avatar( tab ){
24805                if( ! tab ){
24806                    return false;
24807                }
24808                //only show if its the account tab AND we have an avatar url
24809                return tab.key === "settings" && !!this.member_tab_avatar_url;
24810            },
24811        },
24812        computed: {
24813            member_tab_avatar_url(){
24814                const current_user = Parse.User.current();
24815                if( ! current_user ){
24816                    return null;
24817                }
24818                const member_object = current_user.get('memberObject');
24819                if( member_object && member_object.get('profPicURL') ){
24820                    return member_object.get('profPicURL');
24821                }
24822                if( current_user.get('profPicURL') ){
24823                    return current_user.get('profPicURL');
24824                }
24825                return null;
24826            },
24827            tab_bar_colors(){
24828                if( this.preview_colors ){
24829                    return this.preview_colors;
24830                }
24831                //this is  only supported in newer versions of the app (where we have the native_app_props set so we can calculate padding-bottom on the safe area)
24832                if( ! store.native_app_props || ! store.native_app_props.safeAreaInsets ){
24833                    if(!this.is_admin_preview){
24834                        return {};
24835                    }
24836                }
24837                //ok, it's a newer version of the app!
24838                if( store.clubObject && store.clubObject.get('web_settings').get('tab_bar_colors') ){
24839                    //validate the config
24840                    let tab_bar_colors = store.clubObject.get('web_settings').get('tab_bar_colors');
24841                    if( 
24842                        tab_bar_colors.background && 
24843                        tab_bar_colors.text && 
24844                        tab_bar_colors.active 
24845                    ){
24846                        return tab_bar_colors;
24847                    }
24848                }
24849                return {};
24850            },
24851            //we only show it when we're on one of the relevant primary views (contained in the tab bar)
24852            //.. otherwise we should display the back button bar
24853            tab_bar_keys(){
24854
24855                /*
24856                var keys = [];
24857                if( this.visible_tabs ){
24858                    this.visible_tabs.forEach(tab => {
24859                        keys.push( tab.key );
24860                    });
24861                }
24862                return keys;
24863                */
24864                
24865                let keys = [];
24866                if( this.tabs ){
24867                    this.tabs.forEach(tab => {
24868                        keys.push( tab.key );
24869                    });
24870                }
24871                return keys;
24872            
24873            },
24874            visible_tab_keys(){
24875                let keys = [];
24876                if( this.visible_tabs ){
24877                    this.visible_tabs.forEach(tab => {
24878                        keys.push( tab.key );
24879                    });
24880                }
24881                return keys;
24882            },
24883            more_is_active(){
24884                if( ! this.tab_bar_keys.includes( this.primary_view ) ){
24885                    return false;
24886                }
24887                //it's active if not in the visible tabs
24888                if( this.visible_tab_keys.includes( this.primary_view ) ){
24889                    return false;
24890                }
24891                return true;
24892            },
24893            show_more_section_tab_bar(){
24894                if( ! this.is_member_portal ){
24895                    return false;
24896                }
24897                if( this.primary_view === 'booking' && this.is_inside_booking_flow ){
24898                    return false;
24899                }
24900                if( ! this.tab_bar_keys.includes( this.primary_view ) ){
24901                    return false;
24902                }
24903                if( this.visible_tab_keys.includes( this.primary_view ) ){
24904                    return false;
24905                }
24906                return true;
24907            },
24908            show_tab_bar(){
24909                //1) hide the tab bar if a member has drilled into one ofthe in-portal booking flows (golf, courts, simulators,
24910                if( this.primary_view === 'booking' && this.is_inside_booking_flow ){
24911                    return false;
24912                }
24913                //2) see if it's a visible tab, else don't show
24914                if( this.visible_tab_keys.includes( this.primary_view ) ){
24915                    return true;
24916                }
24917                return false;
24918
24919
24920                /*
24921                // hide the tab bar once a member has drilled into one of
24922                // the in-portal booking flows (golf, courts, simulators,
24923                // group-classes, dining, staff-services, lodging). The hub
24924                // landing page itself has secondary_view == 'default', so
24925                // we keep the tab bar visible there.
24926                if( this.primary_view === 'booking' && this.is_inside_booking_flow ){
24927                    return false;
24928                }
24929                for (let t = 0; t < this.tab_bar_keys.length; t++) {
24930                    if( this.tab_bar_keys[t] === this.primary_view ){
24931                        return true
24932                    }   
24933                }
24934                return false;
24935                */
24936            },
24937            is_inside_booking_flow(){
24938                let secondary = store.secondary_view;
24939                if( ! secondary || secondary === 'default' ){
24940                    return false;
24941                }
24942                return true;
24943            },
24944            visible_tabs(){
24945                if( this.tabs ){
24946                    return this.tabs.slice(0,4)
24947                }
24948                return [];
24949            },
24950            show_back_button(){
24951                if( this.hide_back_button ){
24952                    return false;
24953                }
24954                if( this.show_more_section_tab_bar ){
24955                    return false;
24956                }
24957                //if we're on the home screen, show the back button
24958                if( ! this.show_tab_bar && window.history.length >= 1 ){
24959                    return true;
24960                }
24961                return false;
24962            },
24963            is_react_native_member_app(){
24964                return store.is_react_native_member_app;
24965            },
24966            use_floating_tab_bar(){
24967                // In the admin preview, respect the dropdown selection immediately.
24968                if( this.is_admin_preview ){
24969                    return this.preview_tab_bar_style === 'modern';
24970                }
24971                //end preview case
24972                if( this.member_portal_config ){
24973                    return this.member_portal_config.tab_bar_style === 'modern';
24974                }
24975                return false;
24976            },
24977            float_active_tab_index(){
24978                if (!this.visible_tabs) {
24979                    return 0;
24980                }
24981                for (let idx = 0; idx < this.visible_tabs.length; idx++) {
24982                    if (this.tab_is_active(this.visible_tabs[idx])) {
24983                        return idx;
24984                    }
24985                }
24986                // "More" is the last slot
24987                return this.visible_tabs.length;
24988            },
24989            float_pill_style(){
24990                let total = this.visible_tabs ? this.visible_tabs.length + 1 : 5;
24991                let idx = this.float_active_tab_index;
24992                // Each slot is (100% / total) wide. The pill is inset 8px on each side.
24993                return {
24994                    left: 'calc(' + idx + ' * (100% / ' + total + ') + 8px)',
24995                    width: 'calc(100% / ' + total + ' - 16px)',
24996                };
24997            },
24998            compact_float_pill_style(){
24999                // compact more-section bar is always 4 slots and selected tab is slot index 2
25000                let total = 4;
25001                let selected_tab_index = 2;
25002                return {
25003                    left: 'calc(' + selected_tab_index + ' * (100% / ' + total + ') + 8px)',
25004                    width: 'calc(100% / ' + total + ' - 16px)',
25005                };
25006            },
25007            compact_home_icon_class(){
25008                let home_tab = this.tabs.find(tab => tab.key === 'home');
25009                if( home_tab && home_tab.icon_class ){
25010                    return home_tab.icon_class;
25011                }
25012                return 'fa-regular fa-house';
25013            },
25014            compact_home_tab_label(){
25015                let home_tab = this.tabs.find(tab => tab.key === 'home');
25016                if( home_tab && home_tab.label ){
25017                    return home_tab.label;
25018                }
25019                return 'Home';
25020            },
25021            compact_selected_tab(){
25022                let current_tab = this.tabs.find(tab => tab.key === this.primary_view);
25023                if( current_tab ){
25024                    return current_tab;
25025                }
25026                return {
25027                    icon_class: 'fa-regular fa-circle',
25028                    label: 'Tab',
25029                };
25030            },
25031            compact_selected_tab_icon_class(){
25032                if( this.use_floating_tab_bar ){
25033                    return this.float_icon_class(this.compact_selected_tab, true);
25034                }
25035                if( this.compact_selected_tab && this.compact_selected_tab.icon_class ){
25036                    return this.compact_selected_tab.icon_class;
25037                }
25038                return 'fa-regular fa-circle';
25039            },
25040        },
25041    }
25042    app_content.component("native-tab-bar-component", native_tab_bar_config );
25043    app_overlays.component("native-tab-bar-component", native_tab_bar_config );
25044
25045</script>
25045</html>    
25046    
25046<script>
25047        //after we've included all the relevant views, mount the vue apps
25048        app_content.mount('#v-content');
25049        app_overlays.mount('#v-overlays');
25050
25051    </script>
25051
25052
25053    
25054
25055
25056</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.