#18 - membership #23
@@ -0,0 +1,4 @@
|
||||
import { RsvDataSource } from './RsvDataSource.js';
|
||||
|
||||
export const RsvMembershipKeyResource = (program_id) =>
|
||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/membership-program/' + program_id + '/keys');
|
||||
@@ -0,0 +1,4 @@
|
||||
import { RsvDataSource } from './RsvDataSource.js';
|
||||
|
||||
export const RsvMembershipProgramResource = () =>
|
||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/membership-program');
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
class RsvMembershipProgramController {
|
||||
use RsvPagedResponseTrait;
|
||||
private string $namespace = 'reservations/v1';
|
||||
private string $resource_name = 'membership-program';
|
||||
|
||||
private static function schema(): array {
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer', 'readonly' => true],
|
||||
'name' => ['type' => 'string', 'required' => true, 'minLength' => 1],
|
||||
'active' => ['type' => 'boolean', 'default' => true],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route($this->namespace, '/' . $this->resource_name, [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'index'],
|
||||
'permission_callback' => [RsvRestPolicy::class, 'admin'],
|
||||
],
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create'],
|
||||
'permission_callback' => [RsvRestPolicy::class, 'admin'],
|
||||
'args' => self::input_args(self::schema()),
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'show'],
|
||||
'permission_callback' => [RsvRestPolicy::class, 'admin'],
|
||||
],
|
||||
[
|
||||
'methods' => 'PUT',
|
||||
'callback' => [$this, 'update'],
|
||||
'permission_callback' => [RsvRestPolicy::class, 'admin'],
|
||||
'args' => self::input_args(self::schema()),
|
||||
],
|
||||
[
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [$this, 'delete'],
|
||||
'permission_callback' => [RsvRestPolicy::class, 'admin'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/keys', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'index_keys'],
|
||||
'permission_callback' => [RsvRestPolicy::class, 'admin'],
|
||||
],
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'add_key'],
|
||||
'permission_callback' => [RsvRestPolicy::class, 'admin'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/keys/(?P<key_id>\d+)', [
|
||||
[
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [$this, 'delete_key'],
|
||||
'permission_callback' => [RsvRestPolicy::class, 'admin'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(WP_REST_Request $request): WP_REST_Response {
|
||||
[$skip, $limit] = self::paging($request);
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
$programs = array_map(fn($p) => $p->to_array(), $repo->all($limit, $skip));
|
||||
return $this->paged_response($programs, $repo->count_all());
|
||||
}
|
||||
|
||||
public function create(WP_REST_Request $request): WP_REST_Response {
|
||||
$params = $request->get_json_params();
|
||||
$name = $params['name'] ?? '';
|
||||
$active = $params['active'] ?? true;
|
||||
|
||||
if (trim($name) === '') {
|
||||
throw new InvalidArgumentException('Name is required.');
|
||||
}
|
||||
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
$id = $repo->add($name, $active);
|
||||
return new WP_REST_Response(RsvMembershipProgram::from_array(['id' => $id, 'name' => $name, 'active' => $active])->to_array(), 201);
|
||||
}
|
||||
|
||||
public function show(WP_REST_Request $request): WP_REST_Response {
|
||||
$id = (int) $request->get_param('id');
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
$program = $repo->get($id);
|
||||
|
||||
if ($program === null) {
|
||||
return new WP_REST_Response(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
return new WP_REST_Response($program, 200);
|
||||
}
|
||||
|
||||
public function update(WP_REST_Request $request): WP_REST_Response {
|
||||
$id = (int) $request->get_param('id');
|
||||
$params = $request->get_json_params();
|
||||
$name = $params['name'] ?? '';
|
||||
$active = $params['active'] ?? true;
|
||||
|
||||
if (trim($name) === '') {
|
||||
throw new InvalidArgumentException('Name is required.');
|
||||
}
|
||||
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
if ($repo->get($id) === null) {
|
||||
return new WP_REST_Response(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$repo->update($id, $name, $active);
|
||||
return new WP_REST_Response(['id' => $id, 'name' => $name, 'active' => $active], 200);
|
||||
}
|
||||
|
||||
public function delete(WP_REST_Request $request): WP_REST_Response {
|
||||
$id = (int) $request->get_param('id');
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
|
||||
if ($repo->get($id) === null) {
|
||||
return new WP_REST_Response(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$repo->delete($id);
|
||||
return new WP_REST_Response(['ok' => true], 200);
|
||||
}
|
||||
|
||||
public function index_keys(WP_REST_Request $request): WP_REST_Response {
|
||||
[$skip, $limit] = self::paging($request);
|
||||
$program_id = (int) $request->get_param('id');
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
|
||||
if ($repo->get($program_id) === null) {
|
||||
return new WP_REST_Response(['error' => 'Program not found'], 404);
|
||||
}
|
||||
|
||||
$keys = array_map(fn($k) => $k->to_array(), $repo->keys($program_id, $limit, $skip));
|
||||
return $this->paged_response($keys, $repo->count_keys($program_id));
|
||||
}
|
||||
|
||||
public function add_key(WP_REST_Request $request): WP_REST_Response {
|
||||
$program_id = (int) $request->get_param('id');
|
||||
$params = $request->get_json_params();
|
||||
$key_value = $params['key_value'] ?? '';
|
||||
|
||||
if (trim($key_value) === '') {
|
||||
throw new InvalidArgumentException('Key value is required.');
|
||||
}
|
||||
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
if ($repo->get($program_id) === null) {
|
||||
return new WP_REST_Response(['error' => 'Program not found'], 404);
|
||||
}
|
||||
|
||||
$key_id = $repo->add_key($program_id, $key_value);
|
||||
return new WP_REST_Response(['id' => $key_id, 'program_id' => $program_id, 'key_value' => $key_value], 201);
|
||||
}
|
||||
|
||||
public function delete_key(WP_REST_Request $request): WP_REST_Response {
|
||||
$program_id = (int) $request->get_param('id');
|
||||
$key_id = (int) $request->get_param('key_id');
|
||||
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
if ($repo->get($program_id) === null) {
|
||||
return new WP_REST_Response(['error' => 'Program not found'], 404);
|
||||
}
|
||||
|
||||
$repo->delete_key($key_id);
|
||||
return new WP_REST_Response(['ok' => true], 200);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class RsvTimetableReservationCreatedEvent {
|
||||
public function __construct(
|
||||
public RsvTimetableReservation $reservation
|
||||
public RsvTimetableReservation $reservation,
|
||||
public RsvReservation $parent
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class RsvTimetableReservationPendingEvent {
|
||||
public function __construct(
|
||||
public int $reservation_id,
|
||||
public RsvTimetableReservation $reservation,
|
||||
public string $code,
|
||||
public ?string $code,
|
||||
public string $maintainer_email
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@ class RsvGoogleCalendarListener {
|
||||
|
||||
public static function on_created(RsvTimetableReservationCreatedEvent $event): void {
|
||||
try {
|
||||
$form_submit = (new RsvFormSubmitRepository())->get((int) $event->parent->form_submit_id);
|
||||
if ($form_submit === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$definition = (new RsvFormDefinitionRepository())->get((int) $form_submit['form_id']);
|
||||
if ($definition === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email_key = $definition['definition']['email_key'] ?? null;
|
||||
$user_email = is_string($email_key) && $email_key !== '' ? ($form_values[$email_key] ?? null) : null;
|
||||
|
||||
$gcal = new RsvGoogleCalendarService();
|
||||
if (!$gcal->is_google_connected()) {
|
||||
return;
|
||||
@@ -20,13 +33,14 @@ class RsvGoogleCalendarListener {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $event->reservation->requires_confirmation ? 'tentative' : 'confirmed';
|
||||
$status = $event->reservation->is_confirmed == null ? 'tentative' : 'confirmed';
|
||||
$gcal->add_event(
|
||||
$calendar_id,
|
||||
"Reservation #{$event->reservation->id}",
|
||||
$event->reservation->start_utc,
|
||||
$event->reservation->end_utc,
|
||||
$event->reservation->user_email,
|
||||
// format time as utc
|
||||
$event->reservation->start_utc->format('Y-m-d H:i:s'),
|
||||
$event->reservation->end_utc->format('Y-m-d H:i:s'),
|
||||
$user_email,
|
||||
$event->reservation->id,
|
||||
$status,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
class RsvMembershipKey {
|
||||
public ?int $id;
|
||||
|
||||
public int $program_id;
|
||||
|
||||
public string $key_value;
|
||||
|
||||
public static function schema(): array {
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer', 'readonly' => true],
|
||||
'program_id' => ['type' => 'integer', 'required' => true],
|
||||
'key_value' => ['type' => 'string', 'required' => true, 'minLength' => 1],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function from_array(array $data): self {
|
||||
return new self(
|
||||
intval($data['id'] ?? null),
|
||||
intval($data['program_id'] ?? 0),
|
||||
$data['key_value'] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
public function __construct(?int $id, int $program_id, string $key_value) {
|
||||
$this->id = $id;
|
||||
$this->program_id = $program_id;
|
||||
$this->key_value = $key_value;
|
||||
}
|
||||
|
||||
public function to_array() {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'program_id' => $this->program_id,
|
||||
'key_value' => $this->key_value,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
class RsvMembershipProgram {
|
||||
public ?int $id;
|
||||
|
||||
public string $name;
|
||||
|
||||
public bool $active;
|
||||
|
||||
public static function schema(): array {
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer', 'readonly' => true],
|
||||
'name' => ['type' => 'string', 'required' => true, 'minLength' => 1],
|
||||
'active' => ['type' => 'boolean', 'default' => true],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function from_array(array $data): self {
|
||||
return new self(
|
||||
intval($data['id'] ?? null),
|
||||
$data['name'] ?? '',
|
||||
boolval($data['active'] ?? true)
|
||||
);
|
||||
}
|
||||
|
||||
public function __construct(?int $id, string $name, bool $active = true) {
|
||||
$this->id = $id;
|
||||
$this->name = $name;
|
||||
$this->active = $active;
|
||||
}
|
||||
|
||||
public function to_array() {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'active' => $this->active,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ class RsvTimetableReservation {
|
||||
public DateTime $start_utc; // UTC, 'Y-m-d H:i:s'
|
||||
public DateTime $end_utc; // UTC, 'Y-m-d H:i:s'
|
||||
|
||||
public ?int $is_confirmed = null;
|
||||
|
||||
public static function schema(): array {
|
||||
return [
|
||||
'type' => 'object',
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Database\Db;
|
||||
|
||||
class RsvMembershipProgramRepository {
|
||||
private string $table_program;
|
||||
private string $table_key;
|
||||
|
||||
public function __construct() {
|
||||
$this->table_program = Db::prefix() . 'rsv_membership_program';
|
||||
$this->table_key = Db::prefix() . 'rsv_membership_key';
|
||||
}
|
||||
|
||||
public function all(?int $limit = null, int $skip = 0): array {
|
||||
if ($limit === null) {
|
||||
$rows = Db::get_results(
|
||||
"SELECT * FROM {$this->table_program} ORDER BY id DESC",
|
||||
[],
|
||||
ARRAY_A
|
||||
);
|
||||
} else {
|
||||
$rows = Db::get_results(
|
||||
"SELECT * FROM {$this->table_program} ORDER BY id DESC LIMIT %d OFFSET %d",
|
||||
[$limit, $skip],
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
return array_map(fn($row) => RsvMembershipProgram::from_array($row), $rows);
|
||||
}
|
||||
|
||||
public function count_all(): int {
|
||||
return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table_program}");
|
||||
}
|
||||
|
||||
public function get(int $id): ?array {
|
||||
return Db::get_row(
|
||||
"SELECT * FROM {$this->table_program} WHERE id = %d",
|
||||
[$id],
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
public function add(string $name, bool $active): int {
|
||||
return Db::insert($this->table_program, [
|
||||
'name' => $name,
|
||||
'active' => $active ? 1 : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id, string $name, bool $active): int {
|
||||
return Db::update(
|
||||
$this->table_program,
|
||||
[
|
||||
'name' => $name,
|
||||
'active' => $active ? 1 : 0,
|
||||
],
|
||||
['id' => $id]
|
||||
);
|
||||
}
|
||||
|
||||
public function delete(int $id): void {
|
||||
Db::delete($this->table_program, ['id' => $id]);
|
||||
}
|
||||
|
||||
public function keys(int $program_id, ?int $limit = null, int $skip = 0): array {
|
||||
if ($limit === null) {
|
||||
$rows = Db::get_results(
|
||||
"SELECT * FROM {$this->table_key} WHERE program_id = %d ORDER BY id",
|
||||
[$program_id],
|
||||
ARRAY_A
|
||||
);
|
||||
} else {
|
||||
$rows = Db::get_results(
|
||||
"SELECT * FROM {$this->table_key} WHERE program_id = %d ORDER BY id LIMIT %d OFFSET %d",
|
||||
[$program_id, $limit, $skip],
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
return array_map(fn($row) => RsvMembershipKey::from_array($row), $rows);
|
||||
}
|
||||
|
||||
public function count_keys(int $program_id): int {
|
||||
return (int) Db::get_var(
|
||||
"SELECT COUNT(*) FROM {$this->table_key} WHERE program_id = %d",
|
||||
[$program_id]
|
||||
);
|
||||
}
|
||||
|
||||
private function normalize_key(string $key_value): string {
|
||||
return preg_replace('/\s+/', ' ', strtolower(trim($key_value)));
|
||||
}
|
||||
|
||||
public function add_key(int $program_id, string $key_value): int {
|
||||
return Db::insert($this->table_key, [
|
||||
'program_id' => $program_id,
|
||||
'key_value' => $key_value,
|
||||
'key_normalized_value' => $this->normalize_key($key_value)
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete_key(int $key_id): void {
|
||||
Db::delete($this->table_key, ['id' => $key_id]);
|
||||
}
|
||||
|
||||
public function key_exists(int $program_id, string $key_value): bool {
|
||||
return Db::get_var(
|
||||
"SELECT 1 FROM {$this->table_key} k
|
||||
INNER JOIN {$this->table_program} p ON p.id = k.program_id
|
||||
WHERE p.active = 1 AND k.program_id = %d AND k.key_normalized_value = %s
|
||||
LIMIT 1",
|
||||
[$program_id, $this->normalize_key($key_value)]
|
||||
) !== null;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ function rsv_admin_menu_definition() {
|
||||
$forms = new RsvFormsPage();
|
||||
$timetable = new RsvTimetablePage();
|
||||
$google_cal = new RsvGoogleCalendarSettingsPage();
|
||||
$membership = new RsvMembershipProgramsPage();
|
||||
|
||||
add_menu_page(
|
||||
'Reservations Settings', // Page title
|
||||
@@ -28,6 +29,15 @@ function rsv_admin_menu_definition() {
|
||||
[$forms, 'render']
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'reservations-settings',
|
||||
'Membership Programs',
|
||||
'Membership Programs',
|
||||
RsvCapabilities::MANAGE,
|
||||
'membership-programs',
|
||||
[$membership, 'render']
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'reservations-settings',
|
||||
'Timetables',
|
||||
|
||||
@@ -93,6 +93,26 @@ class RsvInstaller {
|
||||
ON DELETE CASCADE
|
||||
) $charset_collate;");
|
||||
|
||||
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_membership_program (
|
||||
id bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
name TINYTEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
PRIMARY KEY (id)
|
||||
) $charset_collate;");
|
||||
|
||||
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_membership_key (
|
||||
id bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
program_id bigint unsigned NOT NULL,
|
||||
key_value VARCHAR(191) NOT NULL,
|
||||
key_normalized_value VARCHAR(191) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_program_key (program_id, key_normalized_value),
|
||||
KEY idx_key_value (key_normalized_value),
|
||||
CONSTRAINT fk_member_key_program
|
||||
FOREIGN KEY (program_id) REFERENCES {$wpdb->prefix}rsv_membership_program (id)
|
||||
ON DELETE CASCADE
|
||||
) $charset_collate;");
|
||||
|
||||
// Grant the custom capability that gates the admin REST endpoints.
|
||||
RsvCapabilities::ensure();
|
||||
}
|
||||
|
||||
@@ -84,4 +84,5 @@ function rsv_define_rest_api(): void {
|
||||
(new RsvTimetableReservationController())->register_routes();
|
||||
(new RsvFormController())->register_routes();
|
||||
(new RsvFormDefinitionController())->register_routes();
|
||||
(new RsvMembershipProgramController())->register_routes();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
/** Computes a form's total price as the sum of its elements' prices. */
|
||||
/** Computes a form's total price as the sum of its elements' prices, reduced by membership discount. */
|
||||
class RsvFormPriceCalculator {
|
||||
public function calculate(RsvFormDefinition $definition, RsvFormData $data): float {
|
||||
global $rsv_form_price_registry;
|
||||
@@ -16,6 +16,7 @@ class RsvFormPriceCalculator {
|
||||
$total += (float) $calculator($element, $data->getValue($element->getName()));
|
||||
}
|
||||
|
||||
return $total;
|
||||
$pct = (new RsvMembershipService())->discount_for($definition, $data);
|
||||
return $total * (1.0 - max(0.0, min(100.0, $pct)) / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,25 @@
|
||||
final class RsvFormCalculatedValues {
|
||||
/** @return array<string, mixed> */
|
||||
public function for(RsvFormDefinition $definition, RsvFormData $data): array {
|
||||
$calculator = new RsvFormPriceCalculator();
|
||||
global $rsv_form_price_registry;
|
||||
|
||||
$price_before_discount = 0.0;
|
||||
foreach ($definition->getElements() as $element) {
|
||||
$element_calculator = $rsv_form_price_registry->get($element->getType());
|
||||
if ($element_calculator === null) {
|
||||
continue;
|
||||
}
|
||||
$price_before_discount += (float) $element_calculator($element, $data->getValue($element->getName()));
|
||||
}
|
||||
|
||||
$discount_pct = (new RsvMembershipService())->discount_for($definition, $data);
|
||||
$final_price = $calculator->calculate($definition, $data);
|
||||
|
||||
return [
|
||||
'price' => (new RsvFormPriceCalculator())->calculate($definition, $data),
|
||||
'price' => $final_price,
|
||||
'price_before_discount' => $price_before_discount,
|
||||
'discount_percent' => $discount_pct,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,6 +31,6 @@ final class RsvFormCalculatedValues {
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function names(): array {
|
||||
return ['price'];
|
||||
return ['price', 'price_before_discount', 'discount_percent'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ class RsvFormDefinition {
|
||||
|
||||
public string $email_key = "";
|
||||
|
||||
public array $membership = [];
|
||||
|
||||
public string $success_message = "";
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $definition Full definition array including 'elements', 'email_key' and 'success_message'.
|
||||
* @param array<string,mixed> $definition Full definition array including 'elements', 'email_key' and 'success_message'.
|
||||
*/
|
||||
public function __construct(string $id, array $definition) {
|
||||
$this->_elements = [];
|
||||
@@ -23,6 +25,7 @@ class RsvFormDefinition {
|
||||
|
||||
$this->_id = $id;
|
||||
$this->email_key = $definition['email_key'] ?? '';
|
||||
$this->membership = $definition['membership'] ?? [];
|
||||
$this->success_message = $definition['success_message'] ?? '';
|
||||
}
|
||||
|
||||
@@ -34,6 +37,15 @@ class RsvFormDefinition {
|
||||
return $this->email_key;
|
||||
}
|
||||
|
||||
/** @return array<int,array{program_id:int,discount:float,field:string}> */
|
||||
public function getMembershipBindings(): array {
|
||||
return $this->membership['bindings'] ?? [];
|
||||
}
|
||||
|
||||
public function getMembershipCombine(): string {
|
||||
return $this->membership['combine'] ?? 'max';
|
||||
}
|
||||
|
||||
/** Template shown to the visitor after a successful submission. */
|
||||
public function getSuccessMessage(): string {
|
||||
return $this->success_message;
|
||||
|
||||
@@ -34,6 +34,9 @@ final class RsvFormDefinitionValidator {
|
||||
$errors[] = 'Form must contain a submit button.';
|
||||
}
|
||||
|
||||
// Validate membership bindings if present.
|
||||
$errors = array_merge($errors, $this->validate_membership_bindings($definition));
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
@@ -109,4 +112,41 @@ final class RsvFormDefinitionValidator {
|
||||
global $rsv_template_registry;
|
||||
return new RsvTemplateEngine(registry: $rsv_template_registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $definition
|
||||
* @return list<string>
|
||||
*/
|
||||
private function validate_membership_bindings(array $definition): array {
|
||||
$errors = [];
|
||||
$membership = $definition['membership'] ?? [];
|
||||
|
||||
if (!is_array($membership)) {
|
||||
return $errors;
|
||||
}
|
||||
|
||||
$bindings = $membership['bindings'] ?? [];
|
||||
if (!is_array($bindings)) {
|
||||
return $errors;
|
||||
}
|
||||
|
||||
foreach ($bindings as $idx => $binding) {
|
||||
if (!is_array($binding)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$program_id = intval($binding['program_id'] ?? 0);
|
||||
$discount = floatval($binding['discount'] ?? 0.0);
|
||||
|
||||
if ($program_id <= 0) {
|
||||
$errors[] = "Membership binding {$idx}: program_id must be a positive integer.";
|
||||
}
|
||||
|
||||
if ($discount < 0.0 || $discount > 100.0) {
|
||||
$errors[] = "Membership binding {$idx}: discount must be between 0 and 100.";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Membership
|
||||
|
||||
Forms can have a price calculated from it's definition and submitted data. For a discount, the administrator can create a membership program. In the system, that means a collection of strings. In the form definition, the administrator defines, which membership program applies what discount and how to extract the key into the membership program from the from submission.
|
||||
|
||||
## Matching the keys
|
||||
|
||||
The membership is a collection of keys. But some types of them are hard to match exactly. For example it might be important to ignore case or be culture invariant. For this reason, second column in the membership table is used, which is for the normalized key, that must be matched exactly against another normalized key.
|
||||
|
||||
For example, for full names, the process of normalization might remove diacritics or convert to lowercase. The same process must be applied when looking for the key.
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
class RsvMembershipService {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Total membership discount for a submission.
|
||||
*
|
||||
* Each binding names a form field whose submitted value must match a key
|
||||
* in the bound program. Matching bindings' discounts are combined per the
|
||||
* definition's combine mode: the best single discount, or all summed and
|
||||
* capped at 100%.
|
||||
*/
|
||||
public function discount_for(RsvFormDefinition $def, RsvFormData $data): float {
|
||||
$repo = new RsvMembershipProgramRepository();
|
||||
$matched_discounts = [];
|
||||
|
||||
foreach ($def->getMembershipBindings() as $binding) {
|
||||
$program_id = intval($binding['program_id'] ?? 0);
|
||||
$discount = floatval($binding['discount'] ?? 0.0);
|
||||
$field = strval($binding['field'] ?? '');
|
||||
|
||||
if ($program_id <= 0 || $field === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw = $data->getValue($field, '');
|
||||
$value = is_scalar($raw) ? trim((string) $raw) : '';
|
||||
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($repo->key_exists($program_id, $value)) {
|
||||
$matched_discounts[] = $discount;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($matched_discounts)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if ($def->getMembershipCombine() === 'sum') {
|
||||
return min(100.0, array_sum($matched_discounts));
|
||||
}
|
||||
|
||||
return max($matched_discounts);
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,10 @@ class RsvReservationService {
|
||||
|
||||
try {
|
||||
$reservation_id = $this->repo->insert($reservation->to_array());
|
||||
$reservation->id = $reservation_id;
|
||||
|
||||
foreach ($reservation->timetable_reservations as $timetable_reservation) {
|
||||
$timetable_reservation_service->create($reservation_id, $timetable_reservation);
|
||||
$timetable_reservation->id = $timetable_reservation_service->create($reservation_id, $timetable_reservation);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
@@ -67,7 +68,19 @@ class RsvReservationService {
|
||||
|
||||
// Only now that the rows are durably committed do we let listeners
|
||||
// (maintainer emails, calendar sync) observe the new reservation.
|
||||
$timetable_reservation_service->flush_deferred_events();
|
||||
foreach($reservation->timetable_reservations as $timetable_reservation) {
|
||||
if($timetable_reservation->is_confirmed === null) {
|
||||
$maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($timetable_reservation->timetable_id);
|
||||
RsvEventDispatcher::dispatch(new RsvTimetableReservationPendingEvent(
|
||||
$reservation_id,
|
||||
$timetable_reservation,
|
||||
$timetable_reservation_service->get_confirmation_code($timetable_reservation->id),
|
||||
$maintainer_email
|
||||
));
|
||||
}
|
||||
|
||||
RsvEventDispatcher::dispatch(new RsvTimetableReservationCreatedEvent($timetable_reservation, $reservation));
|
||||
}
|
||||
|
||||
$this->confirmation_state_changed($reservation_id);
|
||||
|
||||
|
||||
@@ -115,13 +115,9 @@ class RsvTimetableReservationService {
|
||||
return $this->repo->has_pending_confirmation($reservation_id);
|
||||
}
|
||||
|
||||
private function get_confirmation_code(int $reservation_id): string {
|
||||
public function get_confirmation_code(int $reservation_id): ?string {
|
||||
$code = $this->repo->get_confirmation_code($reservation_id);
|
||||
|
||||
if ($code === null) {
|
||||
throw new InvalidArgumentException('No pending confirmation for this reservation.');
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
@@ -157,20 +153,13 @@ class RsvTimetableReservationService {
|
||||
|
||||
if ($maintainer_email) {
|
||||
$code = $this->create_confirmation($reservation_id, $id);
|
||||
$this->deferred_events[] = new RsvTimetableReservationPendingEvent(
|
||||
$reservation_id,
|
||||
$reservation,
|
||||
$code,
|
||||
$maintainer_email
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$reservation->is_confirmed = 1;
|
||||
$reservation_service = new RsvReservationService();
|
||||
$reservation_service->confirmation_state_changed($reservation_id);
|
||||
}
|
||||
|
||||
$this->deferred_events[] = new RsvTimetableReservationCreatedEvent($reservation);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,14 +273,62 @@ 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'),
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -639,9 +639,6 @@
|
||||
<ClassMustBeFinal>
|
||||
<code><![CDATA[RsvFormDefinition]]></code>
|
||||
</ClassMustBeFinal>
|
||||
<InvalidArrayOffset>
|
||||
<code><![CDATA[$definition['email_key']]]></code>
|
||||
</InvalidArrayOffset>
|
||||
<MissingPropertyType>
|
||||
<code><![CDATA[$_elements]]></code>
|
||||
</MissingPropertyType>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { RsvFormDefinitionResource } from '../assets/js/datasource/RsvFormDefini
|
||||
import { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js';
|
||||
import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js';
|
||||
import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.js';
|
||||
import { RsvMembershipProgramResource } from '../assets/js/datasource/RsvMembershipProgramResource.js';
|
||||
import { RsvMembershipKeyResource } from '../assets/js/datasource/RsvMembershipKeyResource.js';
|
||||
import { RsvReservationClient } from '../assets/js/datasource/RsvReservationClient.js';
|
||||
|
||||
window.RsvDataGrid = RsvDataGrid;
|
||||
@@ -22,4 +24,6 @@ window.RsvFormDefinitionResource = RsvFormDefinitionResource;
|
||||
window.RsvTimetableResource = RsvTimetableResource;
|
||||
window.RsvTimetableCapacityResource = RsvTimetableCapacityResource;
|
||||
window.RsvTimetableReservationResource = RsvTimetableReservationResource;
|
||||
window.RsvMembershipProgramResource = RsvMembershipProgramResource;
|
||||
window.RsvMembershipKeyResource = RsvMembershipKeyResource;
|
||||
window.RsvReservationClient = RsvReservationClient;
|
||||
|
||||
Reference in New Issue
Block a user