@@ -106,6 +106,7 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
|
||||
$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) {
|
||||
@@ -130,6 +131,25 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
->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')
|
||||
@@ -149,18 +169,22 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
->output();
|
||||
?>
|
||||
|
||||
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
|
||||
<?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 = []): void {
|
||||
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') ?>,
|
||||
@@ -249,15 +273,63 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
};
|
||||
})(<?= $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'),
|
||||
elements: rsv_elements_source.get_all(),
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -468,8 +540,87 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
}
|
||||
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'].forEach((n) => {
|
||||
['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);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Forms\RsvFormBuilder;
|
||||
use Reservair\Layout\RsvColumnLayout;
|
||||
|
||||
class RsvMembershipProgramsPage 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 {
|
||||
?>
|
||||
<h1>Membership Programs</h1>
|
||||
<hr>
|
||||
<?php
|
||||
RsvColumnLayout::split('1:2')
|
||||
->column(function () {
|
||||
echo RsvFormBuilder::create('add_membership_program', get_rest_url(null, 'reservations/v1/membership-program'), 'POST', 'Membership program created.')
|
||||
->heading('Add Program')
|
||||
->nonce('my_action', 'add_membership_program_nonce')
|
||||
->text('name', 'Name')
|
||||
->render();
|
||||
?>
|
||||
<hr>
|
||||
<p class="submit">
|
||||
<button type="submit" form="add_membership_program" class="button button-primary">Add Program</button>
|
||||
</p>
|
||||
<?php })
|
||||
->column(function () { ?>
|
||||
<div id="programs_table"></div>
|
||||
<script>
|
||||
var programs_dt = RsvDataGrid.create_data_grid(programs_table,
|
||||
RsvMembershipProgramResource(), {
|
||||
'id': RsvDataGrid.column('ID', false, 30),
|
||||
'name': RsvDataGrid.action_column('Name', false, {
|
||||
'Edit': RsvDataGrid.link_action((data) =>
|
||||
`<?= menu_page_url('membership-programs', false) ?>&id=${data.id}&action=edit`
|
||||
),
|
||||
'Delete': RsvDataGrid.func_action(function(dt, row, data) {
|
||||
if (!confirm('Delete this program? This cannot be undone.')) return;
|
||||
dt.resource.delete(data.id).then(() => programs_dt.refresh()).catch(err => alert(err.message));
|
||||
}),
|
||||
}),
|
||||
'active': RsvDataGrid.column('Active', false),
|
||||
});
|
||||
programs_dt.refresh();
|
||||
</script>
|
||||
<?php })
|
||||
->output();
|
||||
?>
|
||||
<script>
|
||||
RsvAdminForm.bind(document.getElementById('add_membership_program'), {
|
||||
refresh: () => { if (typeof programs_dt !== 'undefined') programs_dt.refresh(); },
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
private function show_edit(int $id): void {
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
$program = $repo->get($id);
|
||||
|
||||
if ($program === null) {
|
||||
echo '<div class="notice notice-error"><p>Program not found.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
?>
|
||||
<h1>Edit Program: <?= esc_html($program['name']) ?></h1>
|
||||
<a href="<?= menu_page_url('membership-programs', false) ?>">← Back to Programs</a>
|
||||
<hr>
|
||||
|
||||
<?php
|
||||
echo RsvFormBuilder::create('edit_membership_program', get_rest_url(null, 'reservations/v1/membership-program/' . $id), 'PUT', 'Program updated.')
|
||||
->text('name', 'Name', '', true, $program['name'])
|
||||
->checkbox('active', 'Active', '', $program['active'] ?? true)
|
||||
->render();
|
||||
?>
|
||||
<script>
|
||||
RsvAdminForm.bind(document.getElementById('edit_membership_program'));
|
||||
</script>
|
||||
|
||||
<hr>
|
||||
<h2>Roster</h2>
|
||||
<p>Each member is identified by a single key. The key format depends on the active membership strategy.</p>
|
||||
|
||||
<?php
|
||||
RsvColumnLayout::split('1:2')
|
||||
->column(function () use ($id) {
|
||||
echo RsvFormBuilder::create('add_membership_key', get_rest_url(null, 'reservations/v1/membership-program/' . $id . '/keys'), 'POST', 'Member added.')
|
||||
->heading('Add Member')
|
||||
->text('key_value', 'Key')
|
||||
->render();
|
||||
?>
|
||||
<hr>
|
||||
<p class="submit">
|
||||
<button type="submit" form="add_membership_key" class="button button-primary">Add Member</button>
|
||||
</p>
|
||||
<?php })
|
||||
->column(function () use ($id) { ?>
|
||||
<div id="roster_table"></div>
|
||||
<script>
|
||||
var roster_dt = RsvDataGrid.create_data_grid(roster_table,
|
||||
RsvMembershipKeyResource(<?= (int) $id ?>), {
|
||||
'id': RsvDataGrid.column('ID', false, 30),
|
||||
'key_value': RsvDataGrid.action_column('Key', false, {
|
||||
'Delete': RsvDataGrid.func_action(function(dt, row, data) {
|
||||
if (!confirm('Delete this member? This cannot be undone.')) return;
|
||||
dt.resource.delete(data.id).then(() => roster_dt.refresh()).catch(err => alert(err.message));
|
||||
}),
|
||||
}),
|
||||
});
|
||||
roster_dt.refresh();
|
||||
</script>
|
||||
<?php })
|
||||
->output();
|
||||
?>
|
||||
<script>
|
||||
RsvAdminForm.bind(document.getElementById('add_membership_key'), {
|
||||
refresh: () => { if (typeof roster_dt !== 'undefined') roster_dt.refresh(); },
|
||||
onSuccess: () => { document.getElementById('add_membership_key')?.reset(); },
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user