PluginProbe
Booking Calendar / 11.2
Booking Calendar v11.2
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 10.11.2 All 203 releases
booking / includes / page-form-builder / _src / export / builder-exporter.js

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

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