PluginProbe
Booking Calendar / 11.8.4
Booking Calendar v11.8.4
11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 10.11 All 204 releases
booking / includes / page-form-builder / _src / export / builder-exporter.js

builder-exporter.js in Booking Calendar 11.8.4, at includes/page-form-builder/_src/export/builder-exporter.js

1,167 lines 42.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * @file: ../includes/page-form-builder/_out/export/builder-exporter.js
3 */
4 (function () {
5 "use strict";
6
7 const core = window.WPBC_BFB_Core || {};
8
9 // == Helpers — Shared helper API for field packs ==================================================================
10 // =================================================================================================
11 // == These are generic utilities that packs can call from their own exporters:
12 // == - compute_name(), id_option(), class_options(), size_max_token(), emit_time_select(), etc.
13 // == No field-type branching should live in the core exporter.
14 // =================================================================================================
15
16 /**
17 * Default skip list (can be extended/overridden at runtime).
18 * - Only attribute NAMES here (case-insensitive). Values are removed with them.
19 */
20 const wpbc_export_skip_attrs_default = [ 'data-colstyles-active' ];
21
22 /**
23 * Remove attributes by name from an HTML-like string.
24 * Matches:
25 * - name
26 * - name="..."/name='...'/name=value
27 * with any surrounding whitespace.
28 *
29 * @param {string} html
30 * @param {string[]} attrs_lowercase attribute names (lowercase)
31 * @return {string}
32 */
33 function strip_attributes_from_markup(html, attrs_lowercase) {
34 if (!html || !attrs_lowercase?.length) return html;
35 let out = String(html);
36 for (const rawName of attrs_lowercase) {
37 if (!rawName) continue;
38 const name = String(rawName).toLowerCase().trim();
39 // Escape for regex
40 const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41 // Match full attribute name only (next char is NOT a valid name char)
42 const re = new RegExp(
43 `\\s${esc}(?![A-Za-z0-9_:\\-])(?:=(?:"[^"]*"|'[^']*'|[^\\s>]*))?`,
44 'gi'
45 );
46 out = out.replace(re, '');
47 }
48 return out;
49 }
50
51 // == Helpers – column styles parsing & CSS vars builder ===========================================================
52
53 // Known keys we treat as real per-column style overrides.
54 function has_non_default_col_styles(obj) {
55 if ( !obj || typeof obj !== 'object' ) {
56 return false;
57 }
58 var keys = [ 'dir', 'wrap', 'jc', 'ai', 'gap', 'aself', 'ac' ];
59 for ( var i = 0; i < keys.length; i++ ) {
60 var k = keys[i];
61 if ( obj[k] != null && String( obj[k] ).trim() !== '' ) {
62 return true;
63 }
64 }
65 return false;
66 }
67
68 /**
69 * Parse `col_styles` coming from a Section.
70 * Accepts: JSON string or array of objects.
71 *
72 * @param {string|Array|undefined|null} raw
73 * @returns {Array<Object>} array aligned to columns (may be empty)
74 */
75 function parse_col_styles_json(raw) {
76 if ( !raw ) return [];
77 if ( Array.isArray( raw ) ) return raw.filter( function (x) {
78 return x && typeof x === 'object';
79 } );
80
81 if ( typeof raw === 'string' ) {
82 try {
83 var arr = JSON.parse( raw );
84 return Array.isArray( arr ) ? arr.filter( function (x) {
85 return x && typeof x === 'object';
86 } ) : [];
87 } catch ( _e ) {
88 return [];
89 }
90 }
91 return [];
92 }
93
94 /**
95 * Build CSS variable declarations string for a column style object.
96 * Known keys -> CSS vars:
97 * - dir -> --wpbc-bfb-col-dir
98 * - wrap -> --wpbc-bfb-col-wrap
99 * - jc -> --wpbc-bfb-col-jc
100 * - ai -> --wpbc-bfb-col-ai
101 * - gap -> --wpbc-bfb-col-gap
102 * - ac -> --wpbc-bfb-col-ac
103 * - aself-> --wpbc-bfb-col-aself
104 *
105 * Unknown keys are exported as `--wpbc-bfb-col-${key}`.
106 *
107 * @param {Object|null|undefined} obj
108 * @returns {string} e.g. "--wpbc-bfb-col-dir: row; --wpbc-bfb-col-wrap: wrap;"
109 */
110 function build_col_css_vars(obj) {
111 if ( !obj || typeof obj !== 'object' ) return '';
112
113 var map = {
114 dir : '--wpbc-bfb-col-dir',
115 wrap : '--wpbc-bfb-col-wrap',
116 jc : '--wpbc-bfb-col-jc',
117 ai : '--wpbc-bfb-col-ai',
118 gap : '--wpbc-bfb-col-gap',
119 ac : '--wpbc-bfb-col-ac',
120 aself: '--wpbc-bfb-col-aself'
121 };
122
123 var parts = [];
124
125 for ( var k in obj ) {
126 if ( !Object.prototype.hasOwnProperty.call( obj, k ) ) continue;
127 var v = obj[k];
128 if ( v == null || v === '' ) continue;
129
130 var var_name = map[k] || ('--wpbc-bfb-col-' + String( k ).replace( /[^a-z0-9_-]/gi, '' ).toLowerCase());
131 parts.push( var_name + ': ' + String( v ) );
132 }
133
134 // Always include explicit min guard (requested): --wpbc-col-min: 0px;
135 parts.push( '--wpbc-col-min: 0px' );
136
137 return parts.join( ';' ) + (parts.length ? ';' : '');
138 }
139
140 /**
141 * Resolve numeric percent from a width token like "48.5%".
142 * Falls back to `fallback_percent` if not in percent format.
143 *
144 * @param {string|number|undefined|null} width_token
145 * @param {number} fallback_percent
146 * @returns {number}
147 */
148 function resolve_flex_basis_percent(width_token, fallback_percent) {
149 if ( typeof width_token === 'string' ) {
150 var s = width_token.trim();
151 if ( s.endsWith( '%' ) ) {
152 var p = parseFloat( s );
153 if ( isFinite( p ) ) return p;
154 }
155 }
156 if ( typeof width_token === 'number' && isFinite( width_token ) ) {
157 return width_token;
158 }
159 return fallback_percent;
160 }
161
162 /**
163 * Compute effective flex-basis values that respect inter-column gap
164 *
165 * @param columns
166 * @param gap_percent
167 * @returns {*}
168 */
169 function compute_effective_bases(columns, gap_percent = 3) {
170
171 const n = columns && columns.length ? columns.length : 1;
172
173 const raw = columns.map( (col) => {
174 const w = col && col.width != null ? String( col.width ).trim() : '';
175 const p = w.endsWith( '%' ) ? parseFloat( w ) : w ? parseFloat( w ) : NaN;
176 return Number.isFinite( p ) ? p : 100 / n;
177 } );
178
179 const sum_raw = raw.reduce( (a, b) => a + b, 0 ) || 100;
180 const gp = Number.isFinite( +gap_percent ) ? +gap_percent : 3;
181 const total_gaps = Math.max( 0, n - 1 ) * gp;
182 const available = Math.max( 0, 100 - total_gaps );
183 const scale_ratio = available / sum_raw;
184
185 return raw.map( (p) => Math.max( 0, p * scale_ratio ) );
186 }
187
188 // == adapter: builder (array-of-pages) > exporter shape { pages: [ { items:[ {kind,data} ] } ] } ==================
189 function adapt_builder_structure_to_exporter(structure) {
190
191 // if ( !Array.isArray( structure ) ) return { pages: [] };
192
193 // Ensure at least one page exists, even when Builder structure is empty `[]`.
194 // This keeps exported Advanced Form valid (wizard step #1 exists).
195 if ( ! Array.isArray( structure ) || structure.length === 0 ) {
196 return { pages: [ { items: [] } ] };
197 }
198
199 const normalize_options = (opts) => {
200 if ( !Array.isArray( opts ) ) return [];
201 return opts.map( (o) => {
202 if ( typeof o === 'string' ) return { label: o, value: o, selected: false };
203 if ( o && typeof o === 'object' ) {
204 return {
205 label : String( o.label ?? o.value ?? '' ),
206 value : String( o.value ?? o.label ?? '' ),
207 selected: !!o.selected
208 };
209 }
210 return { label: String( o ), value: String( o ), selected: false };
211 } );
212 };
213
214 // =================================================================================================
215 // == Adapter – attach parsed per-column `col_styles` from Section into each column
216 // =================================================================================================
217 const walk_section = (sec) => {
218 const section_col_styles = parse_col_styles_json( sec && sec.col_styles );
219
220 return {
221 id : sec?.id,
222 html_id : sec?.html_id || '',
223 cssclass : sec?.cssclass || '',
224 columns : (sec?.columns || []).map( (col, col_index) => {
225 const items = Array.isArray( col?.items )
226 ? col.items
227 : [
228 ...(col?.fields || []).map( (f) => ({ type: 'field', data: f }) ),
229 ...(col?.sections || []).map( (s) => ({ type: 'section', data: s }) )
230 ];
231
232 const fields = items
233 .filter( (it) => it && it.type === 'field' )
234 .map( (it) => ({ ...it.data, options: normalize_options( it.data?.options ) }) );
235
236 const sections = items
237 .filter( (it) => it && it.type === 'section' )
238 .map( (it) => walk_section( it.data ) );
239
240 return {
241 width : col?.width || '100%',
242 style : col?.style || null,
243 col_styles : section_col_styles[ col_index ] || null, // <- attach style object per column
244 fields,
245 sections
246 };
247 } )
248 };
249 };
250
251
252 const pages = structure.map( (page) => {
253 const items = [];
254 (page?.content || []).forEach( (item) => {
255 if ( !item ) return;
256 if ( item.type === 'section' && item.data ) {
257 items.push( { kind: 'section', data: walk_section( item.data ) } );
258 } else if ( item.type === 'field' && item.data ) {
259 items.push( {
260 kind: 'field',
261 data: { ...item.data, options: normalize_options( item.data.options ) }
262 } );
263 }
264 } );
265 return { items };
266 } );
267
268 return { pages };
269 }
270
271
272 // == Booking From Exporter ========================================================================================
273 class WPBC_BFB_Exporter {
274
275 /**
276 * Mutable skip-list for attribute names (lowercase).
277 * You can override it via set_skip_attrs() or add with add_skip_attrs().
278 * @type {Set<string>}
279 */
280 static skip_attrs = new Set();
281
282 /**
283 * Replace the entire skip list.
284 * @param {string[]} arr
285 */
286 static set_skip_attrs( arr ) {
287 this.skip_attrs = new Set(
288 (Array.isArray( arr ) ? arr : []).map( (n) => String( n ).toLowerCase().trim() ).filter( Boolean )
289 );
290 }
291
292 /**
293 * Add one or many attributes to the skip list.
294 * @param {string|string[]} names
295 */
296 static add_skip_attrs( names ) {
297 ( Array.isArray( names ) ? names : [ names ] )
298 .map( (n) => String( n ).toLowerCase().trim() )
299 .filter( Boolean )
300 .forEach( (n) => this.skip_attrs.add( n ) );
301 }
302
303 /**
304 * Remove one attribute from the skip list.
305 * @param {string} name
306 */
307 static remove_skip_attr( name ) {
308 if ( ! name ) { return; }
309 this.skip_attrs.delete( String( name ).toLowerCase().trim() );
310 }
311
312 /**
313 * Apply attribute skipping to a final HTML string.
314 * @param {string} html
315 * @return {string}
316 */
317 static sanitize_export( html ) {
318 return strip_attributes_from_markup( html, Array.from( this.skip_attrs ) );
319 }
320
321
322 /**
323 * Export adapted structure to advanced form text (with <r>/<c> layout and wizard wrapper).
324 *
325 * @param {Object} adapted
326 * @param {Object} [options]
327 * @param {string} [options.newline="\n"]
328 * @param {boolean} [options.addLabels=true]
329 * @param {number} [options.gapPercent=3]
330 * @returns {string}
331 */
332 static export_form(adapted, options = {}) {
333 // indent: use real TAB by default (can be overridden via options.indent)
334 const cfg = { newline: '\n', addLabels: true, gapPercent: 3, indent: '\t', ...options };
335 const IND = (typeof cfg.indent === 'string') ? cfg.indent : '\t';
336
337 let depth = 0;
338 const lines = [];
339 const push = (s = '') => lines.push( IND.repeat( depth ) + String( s ) );
340 const open = (s = '') => {
341 push( s );
342 depth++;
343 };
344 const close = (s = '') => {
345 depth = Math.max( 0, depth - 1 );
346 push( s );
347 };
348 const blank = () => {
349 lines.push( '' );
350 };
351
352 if ( !adapted || !Array.isArray( adapted.pages ) ) return '';
353
354 // Always export at least one wizard step to keep Advanced Form structure valid.
355 const pages = adapted.pages.length ? adapted.pages : [ { items: [] } ];
356
357 const ctx = { usedIds: new Set() };
358
359 open( `<div class="wpbc_bfb_form wpbc_wizard__border_container">` );
360
361 // one-per-form guards (calendar is not gated here)
362 const once = { captcha: 0, country: 0, coupon: 0, cost_corrections: 0, submit: 0 };
363
364 pages.forEach( (page, page_index) => {
365 const is_first = page_index === 0;
366 const step_num = page_index + 1;
367
368 const hidden_class = is_first ? '' : ' wpbc_wizard_step_hidden';
369 const hidden_style = is_first ? '' : ' style="display:none;clear:both;"';
370 open( `<div class="wpbc_wizard_step wpbc__form__div wpbc_wizard_step${step_num}${hidden_class}"${hidden_style}>` );
371
372 (page.items || []).forEach( (item) => {
373 if ( item.kind === 'section' ) {
374 WPBC_BFB_Exporter.render_section( item.data, { open, close, push, blank }, cfg, once, ctx );
375 // blank();
376 } else if ( item.kind === 'field' ) {
377 open( `<r>` );
378 open( `<c>` );
379 WPBC_BFB_Exporter.render_field_node( item.data, { open, close, push, blank }, cfg, once, ctx );
380 close( `</c>` );
381 close( `</r>` );
382 // blank();
383 }
384 } );
385
386 close( `</div>` );
387 } );
388
389 close( `</div>` );
390 return WPBC_BFB_Exporter.sanitize_export( lines.join( cfg.newline ) );
391 }
392
393
394 /**
395 * High-level helper: export full package from raw Builder structure.
396 *
397 * - Adapts raw Builder structure (pages/sections/columns/items) for exporters.
398 * - Builds:
399 * • advanced_form -> “Advanced Form (export)” text.
400 * • fields_data -> “Content of booking fields data (export)” text.
401 *
402 * @param {Array} structure Raw Builder structure from wpbc_bfb.get_structure().
403 * @param {Object} [options]
404 * @param {number} [options.gapPercent=3] Column gap percent for layout math.
405 *
406 * @returns {{
407 * advanced_form: string,
408 * fields_data: string,
409 * structure: Array,
410 * adapted: Object
411 * }}
412 */
413 static export_all( structure, options = {} ) {
414
415 // 1) Adapt Builder JSON to exporter shape (pages[] -> items[]).
416 const adapted = adapt_builder_structure_to_exporter( structure || [] );
417
418 // 2) Advanced Form text (same logic as debug panel).
419 const gap_percent = ( options && typeof options.gapPercent === 'number' ) ? options.gapPercent : 3;
420 const advanced_form = WPBC_BFB_Exporter.export_form(
421 adapted,
422 {
423 addLabels : true,
424 gapPercent: gap_percent
425 }
426 );
427
428 // 3) Content of booking fields data (if content exporter is available).
429 let fields_data = '';
430 if (
431 window.WPBC_BFB_ContentExporter &&
432 typeof window.WPBC_BFB_ContentExporter.export_content === 'function'
433 ) {
434 fields_data = window.WPBC_BFB_ContentExporter.export_content(
435 adapted,
436 {
437 addLabels: true,
438 sep : ': '
439 }
440 );
441 }
442
443 return {
444 advanced_form: advanced_form || '',
445 fields_data : fields_data || '',
446 structure : structure || [],
447 adapted : adapted
448 };
449 }
450
451 // =================================================================================================
452 // == Exporter – render_section() now injects per-column CSS vars from `col_styles`
453 // =================================================================================================
454 static render_section(section, io, cfg, once, ctx) {
455
456 once = once || { captcha: 0, country: 0, coupon: 0, cost_corrections: 0, submit: 0 };
457 ctx = ctx || { usedIds: new Set() };
458
459 const { open, close } = io;
460
461 const cols = Array.isArray( section.columns ) && section.columns.length
462 ? section.columns
463 : [ { width: '100%', fields: [], sections: [] } ];
464
465 // Row is active if ANY column carries styles.
466 var row_is_active = cols.some( function (col) { return has_non_default_col_styles( col && col.col_styles ); } );
467 var row_attr_active = row_is_active ? ' data-colstyles-active="1"' : '';
468 var row_custom_attrs = WPBC_BFB_Exporter.item_wrapper_attrs( section, ctx );
469
470 open( `<r${row_custom_attrs}${row_attr_active}>` );
471
472 const bases = compute_effective_bases( cols, cfg.gapPercent );
473 const esc_attr = core.WPBC_BFB_Sanitize.escape_html;
474
475 cols.forEach( (col, idx) => {
476 // (1) Resolve flex-basis.
477 var eff_basis = resolve_flex_basis_percent( col && col.width, Number.isFinite( bases[idx] ) ? +bases[idx] : 100 );
478
479 // (2) Build inline style.
480 var style_parts = [];
481
482 if ( col && typeof col.style === 'string' && col.style.trim() ) {
483 style_parts.push( col.style.trim().replace( /;+\s*$/, '' ) );
484 }
485 style_parts.push( 'flex-basis: ' + ( Number.isFinite( eff_basis ) ? eff_basis.toString() : '100' ) + '%' );
486
487 var css_vars_str = build_col_css_vars( col && col.col_styles );
488 if ( css_vars_str ) {
489 style_parts.push( css_vars_str.replace( /^;|;$/g, '' ) );
490 }
491
492 var style_attr = ` style="${esc_attr( style_parts.join( '; ' ) )}"`;
493
494 // (3) Column-level activation (more precise scoping)
495 var col_is_active = has_non_default_col_styles( col && col.col_styles );
496 var col_attr_active = col_is_active ? ' data-colstyles-active="1"' : '';
497
498 open( `<c${col_attr_active}${style_attr}>` );
499
500 // Use the shared once/ctx objects so single-per-form guards work across the whole form.
501 (col.fields || []).forEach( (node) =>
502 WPBC_BFB_Exporter.render_field_node( node, io, cfg, once, ctx )
503 );
504
505 // Recurse with the same once/ctx as well.
506 (col.sections || []).forEach( (nested) =>
507 WPBC_BFB_Exporter.render_section( nested, io, cfg, once, ctx )
508 );
509
510 close( `</c>` );
511 } );
512
513 close( `</r>` );
514 }
515
516
517 /**
518 * Build a sanitized custom CSS class and HTML ID attribute string for an exported wrapper.
519 * Used by section row wrappers and fields whose attributes belong on the <item> wrapper.
520 * Also ensures uniqueness of the html_id across the export (uses ctx.usedIds).
521 *
522 * @param {Object} wrapper_data Object containing optional cssclass and html_id properties.
523 * @param {{usedIds:Set<string>}} ctx
524 * @returns {string} e.g. ' class="x y" id="myId"'
525 */
526 static item_wrapper_attrs(wrapper_data, ctx) {
527 if ( ! wrapper_data ) {
528 return '';
529 }
530 const esc_html = core.WPBC_BFB_Sanitize.escape_html;
531 const cls_sanit = core.WPBC_BFB_Sanitize.sanitize_css_classlist;
532 const sid = core.WPBC_BFB_Sanitize.sanitize_html_id;
533
534 let out = '';
535
536 const cls_raw = String( wrapper_data.cssclass_extra || wrapper_data.cssclass || wrapper_data.class || '' );
537 const cls = cls_sanit( cls_raw );
538 let html_id = wrapper_data.html_id ? sid( String( wrapper_data.html_id ) ) : '';
539 if ( html_id && ctx?.usedIds ) {
540 let unique = html_id, i = 2;
541 while ( ctx.usedIds.has( unique ) ) {
542 unique = `${html_id}_${i++}`;
543 }
544 ctx.usedIds.add( unique );
545 html_id = unique;
546 }
547 if ( cls ) {
548 out += ` class="${esc_html( cls )}"`;
549 }
550 if ( html_id ) {
551 out += ` id="${esc_html( html_id )}"`;
552 }
553
554 return out;
555 }
556
557 // =================================================================================================
558 // == Fields – pluggable, pack-driven export
559 // == Wrap every exported field inside <item>…</item> and delegate actual shortcode export
560 // == to per-pack callbacks registered via WPBC_BFB_Exporter.register(type, fn).
561 // =================================================================================================
562 static render_field_node(field, io, cfg, once, ctx) {
563
564 const { open, close, push } = io;
565 if ( ! field || ! field.type ) {
566 return;
567 }
568
569 // Shared context (usedIds, “once-per-form” guards, etc.).
570 once = once || {};
571 ctx = ctx || { usedIds: new Set() };
572
573 const type = String( field.type ).toLowerCase();
574
575 // Optional wrapper attrs for special types (currently only used by captcha).
576 let item_attrs = '';
577 if ( type === 'captcha' ) {
578 item_attrs = WPBC_BFB_Exporter.item_wrapper_attrs( field, ctx );
579 }
580
581 open( `<item${item_attrs}>` );
582
583 try {
584 // 1) Let the corresponding field pack handle export.
585 let handled = false;
586 if ( WPBC_BFB_Exporter.has_exporter( type ) ) {
587 handled = WPBC_BFB_Exporter.run_registered_exporter( field, io, cfg, once, ctx );
588 }
589
590 // 2) Fallback: show a clear TODO comment if no exporter is registered.
591 if ( ! handled ) {
592 const name = WPBC_BFB_Exporter.compute_name( type, field );
593 push( `<!-- TODO: map field type "${type}" name="${name}" in a pack exporter -->` );
594 }
595
596 // 3) Append help text consistently (packs shouldn’t duplicate this).
597 if ( field.help ) {
598 push(
599 `<div class="wpbc_field_description">${core.WPBC_BFB_Sanitize.escape_html(
600 String( field.help )
601 )}</div>`
602 );
603 }
604 } finally {
605 // Always close wrapper.
606 close( `</item>` );
607 }
608 }
609
610 // =================================================================================================
611 // == Helpers ==
612 // =================================================================================================
613 static is_required(field) {
614 const v = field && field.required;
615 return (
616 v === true ||
617 v === 'true' ||
618 v === 1 ||
619 v === '1' ||
620 v === 'required'
621 );
622 }
623
624
625 /**
626 * Shared label emitter used by per-pack exporters.
627 *
628 * Emits optional <l>Label</l> + <br> before the provided body,
629 * respecting cfg.addLabels. Help text is emitted centrally in
630 * render_field_node(), so it is intentionally NOT handled here.
631 *
632 * @param {Object} field
633 * @param {function(string): void} emit
634 * @param {string} body
635 * @param {{addLabels?: boolean}} [cfg]
636 */
637 static emit_label_then(field, emit, body, cfg) {
638 if ( typeof emit !== 'function' ) { return; }
639
640 cfg = cfg || {};
641 const addLabels = cfg.addLabels !== false;
642
643 const raw = (field && typeof field.label === 'string') ? field.label : '';
644 const label = raw.trim();
645
646 var is_req = this.is_required( field );
647 var req_mark = is_req ? '*' : '';
648
649 if ( label && addLabels ) {
650 const esc_html = core.WPBC_BFB_Sanitize.escape_html;
651 emit( '<l>' + esc_html( label ) + req_mark + '</l>' );
652 emit( '<br>' + body );
653 } else {
654 emit( body );
655 }
656 }
657
658
659 // =================================================================================================
660 // == Helpers ==
661 // =================================================================================================
662
663 // -- Time Select Helpers --------------------------------------------------------------------------------------
664 static is_timeslot_picker_enabled() {
665 try {
666 return !!(window._wpbc && typeof window._wpbc.get_other_param === 'function'
667 && window._wpbc.get_other_param('is_enabled_booking_timeslot_picker'));
668 } catch (_) { return false; }
669 }
670
671 static time_placeholder_for(name, field) {
672 // Prefer field-specific placeholder; else sensible default per field.
673 if (typeof field.placeholder === 'string' && field.placeholder.trim()) {
674 return field.placeholder.trim();
675 }
676 if (name === 'durationtime') return '--- Select duration ---';
677 return '--- Select time ---';
678 }
679
680 /**
681 * Build tokens/default for a time-like select (start/end/range/duration).
682 * - Adds an empty-value placeholder as the first option only when:
683 * • time picker is OFF, and
684 * • no option is selected by default, and
685 * • there isn't already an empty-value option.
686 */
687 static build_time_select_tokens(field, name) {
688 let tokens_str = this.option_tokens(field);
689 let def_str = this.default_option_suffix(field, tokens_str);
690
691 if (!this.is_timeslot_picker_enabled()) {
692 const opts = Array.isArray(field.options) ? field.options : [];
693
694 const has_selected_default = opts.some(o =>
695 o && (o.selected === true || o.selected === 'true' || o.selected === 1 || o.selected === '1')
696 );
697
698 if (!has_selected_default) {
699 const has_empty_value_option = opts.some(o =>
700 o && typeof o.value !== 'undefined' && String(o.value).trim() === ''
701 );
702
703 if (!has_empty_value_option) {
704 const phText = this.time_placeholder_for(name, field);
705 const phTokenStr = '"' + core.WPBC_BFB_Sanitize.escape_for_shortcode(phText + '@@') + '"';
706
707 const other = this.option_tokens(field).trim(); // recompute, trim leading space
708 tokens_str = ' ' + phTokenStr + (other ? (' ' + other) : '');
709
710 // Ensure first option (our placeholder) becomes the default implicitly
711 def_str = '';
712 }
713 }
714 }
715 return { tokens_str, def_str };
716 }
717
718 static emit_time_select(name, field, req_mark, id_opt, cls_opts, emit_label_then) {
719 const { tokens_str, def_str } = this.build_time_select_tokens(field, name);
720 // NOTE: No size/ph tokens here to mirror rangetime behavior exactly.
721 emit_label_then(`[selectbox${req_mark} ${name}${id_opt}${cls_opts}${def_str}${tokens_str}]`);
722 }
723
724 // -- Other Helpers --------------------------------------------------------------------------------------------
725 // Return a field's default value (supports both camelCase and snake_case).
726 static get_default_value(field) {
727 const v = field?.default_value ?? field?.defaultValue ?? '';
728 return (v == null) ? '' : String( v );
729 }
730
731 // For text-like fields, the default is a final quoted token in the shortcode.
732 static default_text_suffix(field) {
733 const v = this.get_default_value( field );
734 if ( !v ) return '';
735 return ` "${core.WPBC_BFB_Sanitize.escape_for_shortcode( v )}"`;
736 }
737
738 static class_options(field) {
739 const raw = field.class || field.className || field.cssclass || '';
740 const cls = core.WPBC_BFB_Sanitize.sanitize_css_classlist( String( raw ) );
741 if ( !cls ) return '';
742 return cls
743 .split( /\s+/ )
744 .filter( Boolean )
745 .map( (c) => ` class:${core.WPBC_BFB_Sanitize.to_token( c )}` )
746 .join( '' );
747 }
748
749 static id_option(field, ctx) {
750 const raw_id = field.html_id || field.id_attr;
751 if ( !raw_id ) return '';
752 const base = core.WPBC_BFB_Sanitize.to_token( raw_id );
753 if ( !base ) return '';
754 let unique = base, i = 2;
755 while ( ctx.usedIds.has( unique ) ) unique = `${base}_${i++}`;
756 ctx.usedIds.add( unique );
757 return ` id:${unique}`;
758 }
759
760 static ph_attr(v) {
761 if ( v == null || v === '' ) return '';
762 return ` placeholder:"${core.WPBC_BFB_Sanitize.escape_for_attr_quoted( v )}"`;
763 }
764
765 // text-like size/maxlength token: "40/255" (or "40/" or "/255")
766 static size_max_token(f) {
767 const size = parseInt( f.size, 10 );
768 const max = parseInt( f.maxlength, 10 );
769 if ( Number.isFinite( size ) && Number.isFinite( max ) ) return ` ${size}/${max}`;
770 if ( Number.isFinite( size ) ) return ` ${size}/`;
771 if ( Number.isFinite( max ) ) return ` /${max}`;
772 return '';
773 }
774
775 // textarea cols/rows token: "60x4" (or "60x" or "x4")
776 static cols_rows_token(f) {
777 const cols = parseInt( f.cols, 10 );
778 const rows = parseInt( f.rows, 10 );
779 if ( Number.isFinite( cols ) && Number.isFinite( rows ) ) return ` ${cols}x${rows}`;
780 if ( Number.isFinite( cols ) ) return ` ${cols}x`;
781 if ( Number.isFinite( rows ) ) return ` x${rows}`;
782 return '';
783 }
784
785 static option_tokens(field) {
786 const options = Array.isArray( field.options ) ? field.options : [];
787 if ( options.length === 0 ) return '';
788 const parts = options.map( (o) => {
789 const title = String( o.label ?? o.value ?? '' ).trim();
790 const value = String( o.value ?? o.label ?? '' ).trim();
791 return title && value && title !== value
792 ? `"${core.WPBC_BFB_Sanitize.escape_for_shortcode( `${title}@@${value}` )}"`
793 : `"${core.WPBC_BFB_Sanitize.escape_for_shortcode( title || value )}"`;
794 } );
795 return ' ' + parts.join( ' ' );
796 }
797
798 static default_option_suffix(field, tokens) {
799 const options = Array.isArray( field.options ) ? field.options : [];
800 const selected = options.find( (o) => o.selected );
801 const def_val = selected ? (selected.value ?? selected.label) : (field.default_value ?? field.defaultValue ?? '');
802 if ( !def_val ) return '';
803 return ` default="${core.WPBC_BFB_Sanitize.escape_value_for_attr( def_val )}"`;
804 }
805
806 /**
807 * SELECTBOX / RADIO - Build the final shortcode for choice-based fields.
808 *
809 * Responsibilities:
810 * - Delegates option/default encoding to:
811 * - WPBC_BFB_Exporter.option_tokens( field )
812 * - WPBC_BFB_Exporter.default_option_suffix( field, tokens )
813 * - For `radio`:
814 * - ALWAYS appends a bare `use_label_element` token.
815 * - For `selectbox`:
816 * - Adds a bare `multiple` token when `field.multiple` is truthy
817 * (true, "true", 1, "1", "multiple") -> `[selectbox services multiple "1" "2"]`.
818 * - When single-select AND there is no `default="..."` attribute AND
819 * a non-empty `field.placeholder` is present, encodes the placeholder
820 * as the FIRST option with empty value via the `@@` syntax:
821 * placeholder "---- Select ----" -> `"---- Select ----@@"`
822 * and clears any default attribute:
823 * [selectbox* services "--- Select ---@@" "Option 1" "Option 2"]
824 * - Respects `field.use_label_element` (adds bare `use_label_element` when true).
825 * - For both kinds:
826 * - Honors `field.label_first` by appending `label_first:"1"` when truthy.
827 * - Keeps the required star, id and cssclass tokens in the canonical order.
828 *
829 * Final shortcode layout (order is important):
830 * [kind req name id cls use_label_element multiple default tokens label_first]
831 *
832 * @jDoc
833 * @param {string} kind
834 * Shortcode kind; typically "radio" or "selectbox".
835 *
836 * @param {string} req_mark
837 * Required marker used by Contact Form 7 style shortcodes:
838 * either "" (not required) or "*" (required).
839 *
840 * @param {string} name
841 * Sanitized field name as exported into the shortcode, e.g. "services".
842 * Must already be computed via WPBC_BFB_Exporter.compute_name().
843 *
844 * @param {Object} field
845 * Normalized field data object as stored in the Builder structure. Common keys:
846 * - type {string} Field type, e.g. "radio" | "select".
847 * - options {Array} Option objects: { label, value, selected }.
848 * - placeholder {string} Placeholder text (single-select only).
849 * - multiple {boolean|string|number} Enables multi-select when truthy.
850 * - use_label_element {boolean} Request bare `use_label_element` token (non-radio).
851 * - label_first {boolean} If true, appends `label_first:"1"` token.
852 * - default_value {string} Optional default value (used by default_option_suffix()).
853 * - html_id / cssclass / class / className {string} Used upstream in id_opt/cls_opts.
854 *
855 * @param {string} id_opt
856 * Optional id token built by WPBC_BFB_Exporter.id_option(field, ctx),
857 * e.g. " id:my_id" or empty string.
858 *
859 * @param {string} cls_opts
860 * Class tokens built by WPBC_BFB_Exporter.class_options(field),
861 * e.g. " class:my_class class:other".
862 *
863 * @returns {string}
864 * Complete shortcode body for the choice field, for example:
865 * "[radio* services use_label_element \"A\" \"B\"]"
866 * "[selectbox services multiple \"1\" \"2\" \"3\"]"
867 * "[selectbox* services \"--- Select ---@@\" \"Option 1\" \"Option 2\"]"
868 */
869 static choice_tag(kind, req_mark, name, field, id_opt, cls_opts) {
870 // Start from the raw options/default as before.
871 let tokens = WPBC_BFB_Exporter.option_tokens( field );
872 let def = WPBC_BFB_Exporter.default_option_suffix( field, tokens );
873
874 // For RADIO we must ALWAYS include a bare `use_label_element` token (no value/quotes).
875 // For other kinds, keep backward compatibility: include only if explicitly set.
876 let ule = '';
877 if ( kind === 'radio' ) {
878 ule = ' use_label_element';
879 } else if ( field && field.use_label_element ) {
880 ule = ' use_label_element';
881 }
882
883 // SELECTBOX-specific extras:
884 // - "multiple" flag
885 // - placeholder exported as FIRST OPTION when single-select and no default.
886 let multiple_flag = '';
887
888 if ( kind === 'selectbox' && field ) {
889 const multiple =
890 field.multiple === true ||
891 field.multiple === 'true' ||
892 field.multiple === 1 ||
893 field.multiple === '1' ||
894 field.multiple === 'multiple';
895
896 if ( multiple ) {
897 // Export bare "multiple" token as in: [selectbox services multiple "1" "2" "3"].
898 multiple_flag = ' multiple';
899 } else if ( !def ) {
900 // Single-select + NO default selected:
901 // export placeholder as the FIRST OPTION with empty value:
902 // [selectbox* services "--- Select ---@@" "Option 1" "Option 2"]
903 const rawPh = field.placeholder;
904 const ph = (typeof rawPh === 'string') ? rawPh.trim() : '';
905
906 if ( ph ) {
907 const S = core.WPBC_BFB_Sanitize;
908 const esc_sc = (S && S.escape_for_shortcode) ? S.escape_for_shortcode : (v) => String( v );
909
910 const phToken = `"${esc_sc( ph + '@@' )}"`;
911
912 if ( tokens && tokens.length ) {
913 // tokens already starts with a leading space.
914 tokens = ' ' + phToken + tokens;
915 } else {
916 tokens = ' ' + phToken;
917 }
918
919 // Ensure there is still NO default attribute when using placeholder-as-option.
920 def = '';
921 }
922 }
923 }
924
925 // Optional: label_first stays as quoted flag when explicitly requested.
926 const lf = (field && field.label_first) ? ' label_first:"1"' : '';
927
928 // IMPORTANT ORDER (per request):
929 // [kind req name id cls use_label_element multiple default tokens label_first]
930 // i.e. `use_label_element` (and select extras) come BEFORE default/tokens.
931 return `[${kind}${req_mark} ${name}${id_opt}${cls_opts}${ule}${multiple_flag}${def}${tokens}${lf}]`;
932 }
933
934 static compute_name(type, field) {
935 // Names are fully validated when the field is added to the canvas.
936 // The exporter must therefore preserve them (apart from idempotent sanitization), otherwise existing forms can break.
937 const Sanit = core.WPBC_BFB_Sanitize;
938
939 const raw = (field && (field.name || field.id)) ? String(field.name || field.id) : String(type || 'field');
940
941 // Idempotent sanitization only – no auto-prefixing or renaming.
942 const name = Sanit.sanitize_html_name( raw );
943
944 // In the unlikely case sanitization returns an empty string, fall back to a sanitized type-based token.
945 return name || Sanit.sanitize_html_name( String(type || 'field') );
946 }
947
948 /**
949 * Register a per-field exporter.
950 *
951 * This is the ONLY place where field-specific shortcode markup should live.
952 * Core stays generic; packs provide tiny plugins, for example:
953 *
954 * WPBC_BFB_Exporter.register( 'text', (field, emit, extras) => { ... } );
955 *
956 * @jDoc
957 * @param {string} type Field type key, e.g. 'steps_timeline'
958 * @param {(field:any, emit:(code:string)=>void, extras?:{io?:any,cfg?:any,once?:any,ctx?:any,core?:any})=>void}
959 * fn
960 * @returns {void}
961 */
962 static register(type, fn) {
963 if ( ! type || typeof fn !== 'function' ) { return; }
964 if ( ! this.__registry ) { this.__registry = new Map(); }
965 this.__registry.set( String( type ).toLowerCase(), fn );
966 }
967
968 /**
969 * Unregister a previously registered exporter.
970 *
971 * @jDoc
972 * @param {string} type
973 * @returns {void}
974 */
975 static unregister(type) {
976 if ( ! this.__registry || ! type ) { return; }
977 this.__registry.delete( String( type ).toLowerCase() );
978 }
979
980 /**
981 * Check if an exporter exists for a given field type.
982 *
983 * @jDoc
984 * @param {string} type
985 * @returns {boolean}
986 */
987 static has_exporter(type) {
988 return !!( this.__registry && this.__registry.has( String( type ).toLowerCase() ) );
989 }
990
991 /**
992 * Run a registered exporter for a field, if present.
993 * Returns true if a registered exporter handled it.
994 *
995 * @jDoc
996 * @param {any} field
997 * @param {{open:Function,close:Function,push:Function,blank:Function}} io
998 * @param {any} cfg
999 * @param {any} once
1000 * @param {any} ctx
1001 * @returns {boolean}
1002 */
1003 static run_registered_exporter(field, io, cfg, once, ctx) {
1004 if ( ! field || ! field.type || ! this.__registry ) { return false; }
1005 const key = String( field.type ).toLowerCase();
1006 const fn = this.__registry.get( key );
1007 if ( typeof fn !== 'function' ) { return false; }
1008
1009 try {
1010 // Minimal, consistent emit() bridge into our line buffer:
1011 const emit = (code) => { if ( typeof code === 'string' ) { io.push( code ); } };
1012 fn( field, emit, { io, cfg, once, ctx, core } );
1013 return true;
1014 } catch (e) {
1015 _wpbc?.dev?.error?.( 'WPBC_BFB_Exporter.run_registered_exporter', e );
1016 return false;
1017 }
1018 }
1019
1020 }
1021
1022 // expose globally for packs (if not already).
1023 window.WPBC_BFB_Exporter = window.WPBC_BFB_Exporter || WPBC_BFB_Exporter;
1024 wpbc_bfb__dispatch_event_safe( 'wpbc:bfb:exporter-ready', {} );
1025
1026 // Initialize default skip list; allow a global override array before export runs.
1027 WPBC_BFB_Exporter.set_skip_attrs( window.WPBC_BFB_EXPORT_SKIP_ATTRS || wpbc_export_skip_attrs_default );
1028
1029 // == "Content of booking fields data" Exporter ====================================================================
1030
1031 // – pack-extensible generator for "Content of booking fields data" ============================================
1032 // == Produces markup like: "<div class=\"standard-content-form\"><b>Title</b>: <f>[shortcode]</f><br> ... </div>"
1033 // == Packs can override per type via: WPBC_BFB_ContentExporter.register('calendar', (field, emit, ctx)=>{...})
1034 // =================================================================================================
1035 class WPBC_BFB_ContentExporter {
1036
1037 static register(type, fn) {
1038 if ( !type || typeof fn !== 'function' ) return;
1039 if ( !this.__registry ) this.__registry = new Map();
1040 this.__registry.set( String( type ).toLowerCase(), fn );
1041 }
1042
1043 static unregister(type) {
1044 if ( !this.__registry || !type ) return;
1045 this.__registry.delete( String( type ).toLowerCase() );
1046 }
1047
1048 static has_exporter(type) {
1049 return !!(this.__registry && this.__registry.has( String( type ).toLowerCase() ));
1050 }
1051
1052 static run_registered_exporter(field, emit, ctx) {
1053 if ( !field || !field.type || !this.__registry ) return false;
1054 const key = String( field.type ).toLowerCase();
1055 const fn = this.__registry.get( key );
1056 if ( typeof fn !== 'function' ) return false;
1057 try {
1058 fn( field, emit, ctx || {} );
1059 return true;
1060 } catch ( e ) {
1061 _wpbc?.dev?.error?.( 'WPBC_BFB_ContentExporter.run_registered_exporter', e );
1062 return false;
1063 }
1064 }
1065
1066 // === NEW: shared line formatter for "Content of booking fields data" ===
1067 static emit_line_bold_field(emit, label, token, cfg) {
1068 const S = core.WPBC_BFB_Sanitize;
1069 const sep = (cfg && typeof cfg.sep === 'string') ? cfg.sep : ': ';
1070 const addLabels = (cfg && 'addLabels' in cfg) ? !!cfg.addLabels : true;
1071
1072 const title = (addLabels && label) ? `<b>${S.escape_html(label)}</b>${sep}` : '';
1073
1074 emit(`${title}<f>[${token}]</f><br>`);
1075 }
1076
1077 /**
1078 * Export adapted structure to “content of booking fields data”.
1079 * @param {{pages:Array}} adapted result of adapt_builder_structure_to_exporter()
1080 * @param {{newline?:string, addLabels?:boolean, sep?:string}} options
1081 * @returns {string}
1082 */
1083 static export_content(adapted, options = {}) {
1084
1085 const cfg = { newline: '\n', addLabels: true, sep: ': ', indent: '\t', ...options };
1086 const IND = (typeof cfg.indent === 'string') ? cfg.indent : '\t';
1087 let depth = 0;
1088 const lines = [];
1089
1090 const push = (s = '') => lines.push( IND.repeat( depth ) + String( s ) );
1091 const open = (s = '') => { push( s ); depth++; };
1092 const close = (s = '') => { depth = Math.max( 0, depth - 1 ); push( s ); };
1093
1094 const emit = (s) => {
1095 if ( typeof s !== 'string' ) { return; }
1096 String( s ).split( /\r?\n/ ).forEach( (line) => push( line ) );
1097 };
1098
1099 if ( !adapted || !Array.isArray( adapted.pages ) ) return '';
1100
1101 const skipTypes = new Set( [ 'captcha', 'submit', 'divider', 'wizard_nav', 'cost_corrections' ] );
1102
1103 const fallbackLine = (field) => {
1104 const type = String( field.type || '' ).toLowerCase();
1105 const name = WPBC_BFB_Exporter.compute_name( type, field );
1106 const label = (typeof field.label === 'string' && field.label.trim()) ? field.label.trim() : name;
1107 if ( !name ) return;
1108 WPBC_BFB_ContentExporter.emit_line_bold_field( emit, label, name, cfg );
1109 };
1110
1111 // Per-type sensible defaults (can be overridden by packs via register())
1112 const defaultContentFor = (field) => {
1113 const type = String( field.type || '' ).toLowerCase();
1114 if ( skipTypes.has( type ) ) return;
1115 // Special cases out of the box:
1116 if ( type === 'calendar' ) {
1117 const label = (typeof field.label === 'string' && field.label.trim()) ? field.label.trim() : 'Dates';
1118 WPBC_BFB_ContentExporter.emit_line_bold_field( emit, label, 'dates', cfg );
1119 return;
1120 }
1121 // time-like reserved names -> keep placeholder token equal to name
1122 const reserved = String( field.name || field.id || '' ).toLowerCase();
1123 if ( [ 'rangetime', 'starttime', 'endtime', 'durationtime' ].includes( reserved ) ) {
1124 const label = (typeof field.label === 'string' && field.label.trim()) ? field.label.trim() : reserved;
1125 // Keep your special token for duration time in content: [durationtime_val]
1126 const token = (reserved === 'durationtime') ? 'durationtime_val' : reserved;
1127 WPBC_BFB_ContentExporter.emit_line_bold_field( emit, label, token, cfg );
1128 return;
1129 }
1130 // Fallback (text/email/tel/number/textarea/select/checkbox/radio etc.)
1131 fallbackLine( field );
1132 };
1133
1134 // Walk pages/sections/columns/fields (same order as form)
1135 const walkSection = (sec) => {
1136 (sec.columns || []).forEach( (col) => {
1137 (col.fields || []).forEach( (f) => processField( f ) );
1138 (col.sections || []).forEach( (s) => walkSection( s ) );
1139 } );
1140 };
1141 const processItem = (item) => {
1142 if ( !item ) return;
1143 if ( item.kind === 'field' ) processField( item.data );
1144 if ( item.kind === 'section' ) walkSection( item.data );
1145 };
1146 const processField = (field) => {
1147 if ( !field ) return;
1148 // allow packs to override:
1149 if ( WPBC_BFB_ContentExporter.run_registered_exporter( field, emit, { cfg, core } ) ) return;
1150 defaultContentFor( field );
1151 };
1152
1153 // Wrapper first -> inner lines will be TAB-indented
1154 open( `<div class="standard-content-form">` );
1155 adapted.pages.forEach( (page) => (page.items || []).forEach( processItem ) );
1156 close( `</div>` );
1157
1158 return lines.join( cfg.newline );
1159 }
1160
1161 }
1162
1163 // expose + ready event for packs to register their content exporters.
1164 window.WPBC_BFB_ContentExporter = window.WPBC_BFB_ContentExporter || WPBC_BFB_ContentExporter;
1165 wpbc_bfb__dispatch_event_safe( 'wpbc:bfb:content-exporter-ready', {} );
1166 })();
1167