Files
Martin Slachta c754e18a82 #18 - membership
2026-06-17 11:15:09 +02:00

639 lines
32 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
class RsvFormsPage extends RsvAdminPage {
protected function render_content(): void {
if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_edit(intval($_GET['id']));
return;
}
$this->show_list();
}
private function show_list(): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = [];
$next_id = 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Formuláře</h1>
<hr>
<?php
RsvColumnLayout::split('1:2')
->column(function () {
echo RsvFormBuilder::create('add_form_definition', get_rest_url(null, 'reservations/v1/form-definition'), 'POST', 'Form definition created.')
->heading('Přidat formulář')
->nonce('my_action', 'my_nonce')
->text('name', 'Název')
->render();
?>
<hr>
<p class="submit">
<button type="submit" form="add_form_definition" class="button button-primary">Add Form Definition</button>
</p>
<?php })
->column(function () { ?>
<div id="forms_table"></div>
<script>
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
RsvFormDefinitionResource(), {
'form_id': RsvDataGrid.column('ID', false, 30),
'name': RsvDataGrid.action_column('Název', false, {
'Edit': RsvDataGrid.link_action((data) =>
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
),
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
if (!confirm('Delete this form? This cannot be undone.')) return;
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
}),
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/form-definition') ?>`;
fetch(`${base_url}/${data.form_id}`, {
headers: { 'Accept': 'application/json' },
})
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Fetch failed'); });
return r.json();
})
.then(form_def => fetch(base_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
name: 'Copy of ' + form_def.name,
definition: form_def.definition,
}),
}))
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Clone failed'); });
forms_dt.refresh();
})
.catch(err => alert(err.message));
}),
}),
});
forms_dt.refresh();
</script>
<?php })
->output();
?>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
private function show_edit(int $id): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$repo = new RsvFormDefinitionRepository();
$form_def = $repo->get($id);
if ($form_def === null) {
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
return;
}
$definition = $form_def['definition'] ?? [];
$raw_elements = array_values($definition['elements'] ?? []);
$elements_with_ids = array_map(function (array $el, int $idx): array {
return array_merge($el, ['id' => $idx + 1]);
}, $raw_elements, array_keys($raw_elements));
$next_id = count($elements_with_ids) + 1;
$timetables = (new RsvTimetableService())->get_all();
$programs = array_map(fn($p) => $p->to_array(), (new RsvMembershipProgramRepository())->all());
$email_key_options = ['' => '— select field —'];
foreach ($raw_elements as $el) {
$el_name = $el['name'] ?? '';
if ($el_name === '') {
continue;
}
$el_label = $el['label'] ?? '';
$email_key_options[$el_name] = $el_label !== '' ? "$el_label ($el_name)" : $el_name;
}
?>
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
<hr>
<?php
echo RsvFormBuilder::create('edit_form_definition', get_rest_url(null, 'reservations/v1/form-definition/' . $id), 'PUT', 'Form definition updated.')
->text('name', 'Name', '', true, $form_def['name'])
->select('definition.email_key', 'Email Key', $email_key_options, "Form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
->code('definition.success_message', 'Success message', 'Shown to the visitor after a successful submission. HTML is allowed. Use <reservation-summary></reservation-summary> to display the selected reservations. Leave blank for the default message.', $definition['success_message'] ?? '')
->render();
?>
<hr>
<h2>Membership Discounts</h2>
<p>Map a membership program to a discount percentage. The chosen field's value must match a key in that program for the discount to apply.</p>
<?php
$membership = $definition['membership'] ?? [];
$bindings = $membership['bindings'] ?? [];
?>
<div id="rsv_membership_bindings_table"></div>
<p>
<button type="button" class="button" id="rsv_add_binding_btn">+ Add Binding</button>
</p>
<label>
Combine mode:
<select name="definition.membership_combine">
<option value="max" <?= ($membership['combine'] ?? 'max') === 'max' ? 'selected' : '' ?>>Max (best discount wins)</option>
<option value="sum" <?= ($membership['combine'] ?? 'max') === 'sum' ? 'selected' : '' ?>>Sum (capped at 100%)</option>
</select>
</label>
<hr>
<?php
RsvColumnLayout::split('3:2')
->column(function (): void { ?>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php })
->column(function (): void { ?>
<h3>Live preview</h3>
<div id="rsv_form_preview" class="rsv-form-preview" style="position: sticky; top: 40px;"></div>
<?php })
->output();
?>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables, $programs, $bindings); ?>
<?php
}
private function elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = [], array $programs = [], array $bindings = []): void {
$elements_json = json_encode($elements_with_ids);
$types_json = json_encode(array_values($element_types));
$timetables_json = json_encode(array_values($timetables));
$programs_json = json_encode(array_values($programs));
$bindings_json = json_encode(array_values($bindings));
$bindings_next = count($bindings) + 1;
?>
<script>
const rsv_element_types = <?= $types_json ?>;
const rsv_timetables = <?= $timetables_json ?>;
const rsv_membership_programs = <?= $programs_json ?>;
const RSV_EMAIL_DEFAULTS = {
accepted_subject: <?= json_encode('Rezervace přijata') ?>,
accepted_body: <?= json_encode("<h1>Vaše rezervace byla přijata</h1>\n<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>") ?>,
refused_subject: <?= json_encode('Rezervace zamítnuta') ?>,
refused_body: <?= json_encode("<h1>Vaše rezervace byla zamítnuta</h1>\n<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>") ?>,
maintainer_subject: <?= json_encode('Nová rezervace čeká na schválení') ?>,
maintainer_body: <?= json_encode("<h1>Nový požadavek o rezervaci</h1>\n<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>\n<p><strong>Datum:</strong> {{date}}</p>\n<p><strong>Čas:</strong> {{start}} {{end}}</p>\n<p><reservation-actions></reservation-actions></p>") ?>,
};
const rsv_elements_source = (function(initial_items, next_id_start) {
const items = initial_items;
let next_id = next_id_start;
return {
get_page(skip, limit) {
skip = skip ?? 0;
limit = limit ?? 20;
return Promise.resolve({ total: items.length, data: items.slice(skip, skip + limit) });
},
put(id, data) {
const idx = items.findIndex(e => e.id === id);
if (idx === -1) return Promise.reject(new Error('Element not found'));
// Destructure reservation-specific fields so they don't bleed into extra_attrs.
const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r,
price_per_block: _p, email_templates: _et, timetable_id: _ti, tag: _tag, ...extra_attrs } = items[idx];
items[idx] = {
...extra_attrs,
id,
name: data.name ?? '',
label: data.label ?? '',
type: data.type ?? 'text',
desc: data.desc ?? '',
required: data.required === 'on',
...(data.type === 'input-text' ? {
validation: data.validation ?? '',
pattern: data.pattern ?? '',
pattern_message: data.pattern_message ?? '',
} : {}),
...(data.type === 'output-text' ? {
tag: data.tag ?? 'p',
} : {}),
...(data.type === 'reservation' ? {
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
email_templates: {
maintainer: {
subject: data.email_maintainer_subject ?? RSV_EMAIL_DEFAULTS.maintainer_subject,
body: data.email_maintainer_body ?? RSV_EMAIL_DEFAULTS.maintainer_body,
},
on_accepted: {
enabled: !!data.email_accepted_enabled,
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
body: data.email_accepted_body ?? RSV_EMAIL_DEFAULTS.accepted_body,
},
on_refused: {
enabled: !!data.email_refused_enabled,
subject: data.email_refused_subject ?? RSV_EMAIL_DEFAULTS.refused_subject,
body: data.email_refused_body ?? RSV_EMAIL_DEFAULTS.refused_body,
},
},
} : {}),
};
return Promise.resolve(items[idx]);
},
add() {
const item = { id: next_id++, name: '', label: '', type: 'text', desc: '', required: false };
items.push(item);
return item;
},
move_up(id) {
const idx = items.findIndex(e => e.id === id);
if (idx > 0) [items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
},
move_down(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1 && idx < items.length - 1) [items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
},
remove(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1) items.splice(idx, 1);
},
get_all() {
return items.map(({ id, ...rest }) => rest);
},
};
})(<?= $elements_json ?>, <?= $next_id ?>);
const rsv_bindings_source = (function(initial_items, next_id_start) {
const items = (initial_items || []).map((b, i) => ({
id: i + 1,
program_id: parseInt(b.program_id) || 0,
discount: parseFloat(b.discount) || 0,
field: b.field ?? '',
}));
let next_id = next_id_start;
return {
get_page(skip, limit) {
skip = skip ?? 0;
limit = limit ?? 20;
return Promise.resolve({ total: items.length, data: items.slice(skip, skip + limit) });
},
put(id, data) {
const idx = items.findIndex(e => e.id === id);
if (idx === -1) return Promise.reject(new Error('Binding not found'));
items[idx] = {
id,
program_id: parseInt(data.program_id) || 0,
discount: parseFloat(data.discount) || 0,
field: data.field ?? '',
};
return Promise.resolve(items[idx]);
},
add() {
const item = { id: next_id++, program_id: 0, discount: 0, field: '' };
items.push(item);
return item;
},
remove(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1) items.splice(idx, 1);
},
get_all() {
return items
.filter(b => b.program_id > 0)
.map(({ id, ...rest }) => rest);
},
};
})(<?= $bindings_json ?>, <?= $bindings_next ?>);
function rsv_collect_definition() {
const form = document.getElementById('<?= $form_id ?>');
const get = (n) => form?.querySelector(`[name="${n}"]`)?.value ?? '';
return {
name: get('name'),
definition: {
email_key: get('definition.email_key'),
success_message: get('definition.success_message'),
membership: {
bindings: rsv_bindings_source.get_all(),
combine: get('definition.membership_combine') || 'max',
},
elements: rsv_elements_source.get_all(),
},
};
}
const rsv_preview_el = document.getElementById('rsv_form_preview');
let rsv_preview_timer = null;
function rsv_schedule_preview() {
if (!rsv_preview_el) return;
clearTimeout(rsv_preview_timer);
rsv_preview_timer = setTimeout(rsv_render_preview, 300);
}
function rsv_render_preview() {
if (!rsv_preview_el) return;
fetch('<?= get_rest_url(null, 'reservations/v1/form-definition/preview') ?>', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-WP-Nonce': ReservairServiceAPI.nonce,
},
body: JSON.stringify(rsv_collect_definition()),
})
.then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error || 'Preview failed'); }))
.then(data => {
rsv_preview_el.innerHTML = data.html || '<p class="rsv-preview-empty">No fields to preview yet.</p>';
})
.catch(() => { rsv_preview_el.innerHTML = '<p class="rsv-preview-empty">Preview unavailable.</p>'; });
}
// The preview form is inert: block submission (capture so it works after re-render).
rsv_preview_el?.addEventListener('submit', (e) => e.preventDefault(), true);
function rsv_render_element_inline_form(dt, row, data) {
const builder = RsvInlineFormBuilder.create(rsv_elements_source)
.fieldset('Element', '50%')
.input_text('name', 'Slug', data?.name ?? '')
.input_text('label', 'Label', data?.label ?? '')
.input_select('type', 'Type', rsv_element_types, data?.type ?? rsv_element_types[0])
.fieldset('Options', '50%')
.input_text('desc', 'Description', data?.desc ?? '')
.input_checkbox('required', 'Required', data?.required ?? false);
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
const maintainer = data?.email_templates?.maintainer ?? {};
const accepted = data?.email_templates?.on_accepted ?? {};
const refused = data?.email_templates?.on_refused ?? {};
const timetable_options = [
{ value: '', label: '— none —' },
...rsv_timetables.map(t => ({ value: t.id, label: t.name })),
];
builder
.input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '')
.input_number('price_per_block', 'Price per block', data?.price_per_block ?? 0)
.fieldset('Email - for maintainer', '100%')
.input_text('email_maintainer_subject', 'Subject', maintainer.subject ?? RSV_EMAIL_DEFAULTS.maintainer_subject)
.input_textarea('email_maintainer_body', 'Body', maintainer.body ?? RSV_EMAIL_DEFAULTS.maintainer_body)
.fieldset('Email — accepted', '100%')
.input_checkbox('email_accepted_enabled', 'Send email when accepted', accepted.enabled ?? true)
.input_text('email_accepted_subject', 'Subject', accepted.subject ?? RSV_EMAIL_DEFAULTS.accepted_subject)
.input_textarea('email_accepted_body', 'Body', accepted.body ?? RSV_EMAIL_DEFAULTS.accepted_body)
.fieldset('Email — refused', '100%')
.input_checkbox('email_refused_enabled', 'Send email when refused', refused.enabled ?? true)
.input_text('email_refused_subject', 'Subject', refused.subject ?? RSV_EMAIL_DEFAULTS.refused_subject)
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
}
if ((data?.type ?? rsv_element_types[0]) === 'output-text') {
builder
.input_select('tag', 'Tag', [
{ value: 'p', label: 'Paragraph (p)' },
{ value: 'h1', label: 'Heading 1 (h1)' },
{ value: 'h2', label: 'Heading 2 (h2)' },
{ value: 'h3', label: 'Heading 3 (h3)' },
{ value: 'h4', label: 'Heading 4 (h4)' },
{ value: 'h5', label: 'Heading 5 (h5)' },
{ value: 'h6', label: 'Heading 6 (h6)' },
], data?.tag ?? 'p');
}
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
builder
.input_select('validation', 'Validation', [
{ value: '', label: '— none —' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'digits', label: 'Digits only' },
{ value: 'pattern', label: 'Custom pattern' },
], data?.validation ?? '')
.input_text('pattern', 'Custom pattern (regex)', data?.pattern ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'))
.input_text('pattern_message', 'Pattern error message', data?.pattern_message ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'));
}
const node = builder.build({
id: data?.id,
colspan: 6,
save_label: 'Save',
on_success: () => { elements_dt.refresh(); rsv_schedule_preview(); },
on_cancel: () => { elements_dt.refresh(); rsv_schedule_preview(); },
});
// Type swaps whole fieldsets, so re-render the inline form on change.
// Common fields are carried over; type-specific fields reset to stored data.
const type_select = node.querySelector('select[name="type"]');
if (type_select) {
type_select.addEventListener('change', () => {
const current = Object.fromEntries(new FormData(node.querySelector('form')));
const merged = {
...data,
name: current.name ?? data?.name,
label: current.label ?? data?.label,
desc: current.desc ?? data?.desc,
required: 'required' in current,
type: type_select.value,
};
row.replaceChildren(rsv_render_element_inline_form(dt, row, merged));
});
}
return node;
}
// The elements data grid only exists on the edit page. Guard it so a missing
// container can't abort the script and strip the form's submit handler.
const elements_table_el = document.getElementById('form_elements_table');
if (elements_table_el) {
var elements_dt = RsvDataGrid.create_data_grid(
elements_table_el,
rsv_elements_source,
{
'name': RsvDataGrid.action_column('Name', false, {
'Edit': RsvDataGrid.edit_action(function(dt, row, data) {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_element_inline_form(dt, row, data));
}),
'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_up(data.id);
dt.refresh();
rsv_schedule_preview();
}),
'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_down(data.id);
dt.refresh();
rsv_schedule_preview();
}),
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.remove(data.id);
dt.refresh();
rsv_schedule_preview();
}),
}),
'label': RsvDataGrid.column('Label', false),
'type': RsvDataGrid.column('Type', false),
'desc': RsvDataGrid.column('Description', false),
'required': RsvDataGrid.column('Required', false),
'details': RsvDataGrid.column('Details', false),
}
);
elements_dt.map_column('details', (dt, row, data) => {
const td = document.createElement('td');
if (data.type === 'reservation') {
const parts = [];
if (data.timetable_id) {
const t = rsv_timetables.find(t => t.id === data.timetable_id);
parts.push(`Timetable: ${t ? t.name : data.timetable_id}`);
}
if (data.price_per_block != null) parts.push(`Price/block: ${data.price_per_block}`);
const et = data.email_templates ?? {};
const emails = [];
if (et.on_accepted?.enabled) emails.push('accepted');
if (et.on_refused?.enabled) emails.push('refused');
if (emails.length) parts.push(`Emails: ${emails.join(', ')}`);
td.innerText = parts.join(' · ');
}
return td;
});
elements_dt.refresh();
document.getElementById('rsv_add_element_btn').onclick = function() {
rsv_elements_source.add();
elements_dt.refresh();
rsv_schedule_preview();
};
}
// The Email Key select offers the form's fields. Elements are edited in
// the grid above and only persisted on save, so refresh the options from
// the live source each time the select is opened.
const rsv_email_key_select = document.querySelector('#edit_form_definition select[name="definition.email_key"]');
function rsv_sync_email_key_options() {
if (!rsv_email_key_select) return;
const selected = rsv_email_key_select.value;
const options = [new Option('— select field —', '')];
for (const el of rsv_elements_source.get_all()) {
if (!el.name) continue;
options.push(new Option(el.label ? `${el.label} (${el.name})` : el.name, el.name));
}
rsv_email_key_select.replaceChildren(...options);
rsv_email_key_select.value = selected;
}
rsv_email_key_select?.addEventListener('focus', rsv_sync_email_key_options);
// Membership discount bindings — edited in a data grid backed by the
// in-memory source above; persisted with the rest of the definition.
function rsv_binding_program_options() {
return [
{ value: '', label: '— select program —' },
...rsv_membership_programs.map(p => ({ value: p.id, label: p.name })),
];
}
function rsv_binding_field_options() {
const options = [{ value: '', label: '— select field —' }];
for (const el of rsv_elements_source.get_all()) {
if (!el.name) continue;
options.push({ value: el.name, label: el.label ? `${el.label} (${el.name})` : el.name });
}
return options;
}
function rsv_render_binding_inline_form(dt, row, data) {
return RsvInlineFormBuilder.create(rsv_bindings_source)
.fieldset('Discount binding', '100%')
.input_select('program_id', 'Program', rsv_binding_program_options(), data?.program_id ?? '')
.input_number('discount', 'Discount %', data?.discount ?? 0)
.input_select('field', 'Field', rsv_binding_field_options(), data?.field ?? '')
.build({
id: data?.id,
colspan: 3,
save_label: 'Save',
on_success: () => { rsv_bindings_dt.refresh(); rsv_schedule_preview(); },
on_cancel: () => { rsv_bindings_dt.refresh(); rsv_schedule_preview(); },
});
}
const rsv_bindings_table_el = document.getElementById('rsv_membership_bindings_table');
if (rsv_bindings_table_el) {
var rsv_bindings_dt = RsvDataGrid.create_data_grid(
rsv_bindings_table_el,
rsv_bindings_source,
{
'program_id': RsvDataGrid.action_column('Program', false, {
'Edit': RsvDataGrid.edit_action(function(dt, row, data) {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_binding_inline_form(dt, row, data));
}),
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
rsv_bindings_source.remove(data.id);
dt.refresh();
rsv_schedule_preview();
}),
}),
'discount': RsvDataGrid.column('Discount %', false),
'field': RsvDataGrid.column('Field', false),
}
);
rsv_bindings_dt.map_column('program_id', (dt, row, data) => {
const td = document.createElement('td');
const p = rsv_membership_programs.find(p => String(p.id) === String(data.program_id));
td.innerText = p ? p.name : (data.program_id ? `#${data.program_id}` : '—');
return td;
});
rsv_bindings_dt.map_column('field', (dt, row, data) => {
const td = document.createElement('td');
const el = rsv_elements_source.get_all().find(e => e.name === data.field);
td.innerText = data.field ? (el && el.label ? `${el.label} (${el.name})` : data.field) : '—';
return td;
});
rsv_bindings_dt.refresh();
document.getElementById('rsv_add_binding_btn')?.addEventListener('click', function() {
rsv_bindings_source.add();
rsv_bindings_dt.refresh();
rsv_schedule_preview();
});
}
const rsv_meta_form = document.getElementById('<?= $form_id ?>');
['name', 'definition.email_key', 'definition.success_message', 'definition.membership_combine'].forEach((n) => {
const el = rsv_meta_form?.querySelector(`[name="${n}"]`);
el?.addEventListener('input', rsv_schedule_preview);
el?.addEventListener('change', rsv_schedule_preview);
});
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
transform: () => rsv_collect_definition(),
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
});
rsv_render_preview();
</script>
<?php
}
}