Add support for reserved seating (#1228)

* Initial work on seating

* Add seat guids

* Add product_list_top

* CartAdd: Ignore item when a seat is passed

* Cart display

* product_list_top → render_seating_plan

* Render seating plan in voucher redemption

* Fix failing tests

* Add tests for extending cart positions with seats

* Add subevent_forms to docs

* Update schema, migrations

* Dealing with expired orders

* steps to order change

* Change order positions

* Allow to add seats

* tests for ocm

* Fix things after rebase

* Seating plans API

* Add more tests for cart behaviour

* Widget support

* Adjust widget tests

* Re-enable CSP

* Update schema

* Api: position.seat

* Add guid to word list

* API: (sub)event.seating_plan

* Vali fixes

* Fix api

* Fix reference in test

* Fix test for real
This commit is contained in:
Raphael Michel
2019-06-25 11:00:03 +02:00
committed by GitHub
parent f79d17cb6a
commit 93089d87e3
77 changed files with 3689 additions and 164 deletions

View File

@@ -1,6 +1,13 @@
.sidebar-nav li > a > .fa {
color: $navbar-inverse-bg;
}
.sidebar-nav li > a > svg {
position: relative;
top: 3px;
path {
fill: $navbar-inverse-bg;
}
}
.nav .testmode a {
background: $brand-warning;
color: black;
@@ -168,6 +175,11 @@
border: 1px solid #ddd;
}
svg.svg-icon {
position: relative;
top: 2px;
}
@include table-row-variant('success', lighten($brand-success, 40%));
@include table-row-variant('info', lighten($brand-info, 30%));
@include table-row-variant('warning', lighten($brand-warning, 40%));

View File

@@ -46,6 +46,58 @@ $(function () {
);
};
$itemvar.on("change", update_price);
$subevent.on("change", update_price);
$subevent.on("change", update_price).on("change", function () {
var seat = $(this).closest(".form-order-change").find("[id$=seat]");
if (seat.length) {
seat.prop("required", !!$subevent.val());
}
});
});
$('[data-model-select2=seat]').each(function () {
var $s = $(this);
$s.select2({
theme: "bootstrap",
delay: 100,
allowClear: !$s.prop("required"),
width: '100%',
language: $("body").attr("data-select2-locale"),
placeholder: $(this).attr("data-placeholder"),
ajax: {
url: function() {
var se = $(this).closest(".form-order-change, .form-horizontal").attr("data-subevent");
var url = $(this).attr('data-select2-url');
var changed = $(this).closest(".form-order-change, .form-horizontal").find("[id$=subevent]").val();
if (changed) {
return url + '?subevent=' + changed;
} else if (se) {
return url + '?subevent=' + se;
} else {
return url;
}
},
data: function (params) {
return {
query: params.term,
page: params.page || 1
}
}
},
templateResult: function (res) {
if (!res.id) {
return res.text;
}
var $ret = $("<span>").append(
$("<span>").addClass("primary").append($("<div>").text(res.text).html())
);
if (res.event) {
$ret.append(
$("<span>").addClass("secondary").append(
$("<span>").addClass("fa fa-calendar fa-fw")
).append(" ").append($("<div>").text(res.event).html())
);
}
return $ret;
},
});
});
});

View File

@@ -197,6 +197,11 @@ $(function () {
is_enabled = true;
}
});
$(".input-seat-selection option").each(function() {
if ($(this).val() && $(this).val() !== "" && $(this).prop('selected')) {
is_enabled = true;
}
});
}
if (!is_enabled) {
$("#btn-add-to-cart").prop("disabled", !is_enabled).popover({'content': gettext("Please enter a quantity for one of the ticket types."), 'placement': 'top', 'trigger': 'hover focus'});
@@ -205,7 +210,8 @@ $(function () {
}
};
update_cart_form();
$(".product-row input[type=checkbox], .variations input[type=checkbox], .product-row input[type=radio], .variations input[type=radio], .input-item-count").on("change mouseup keyup", update_cart_form);
$(".product-row input[type=checkbox], .variations input[type=checkbox], .product-row input[type=radio], .variations input[type=radio], .input-item-count, .input-seat-selection")
.on("change mouseup keyup", update_cart_form);
$(".table-calendar td.has-events").click(function () {
var $tr = $(this).closest(".table-calendar").find(".selected-day");

View File

@@ -44,6 +44,7 @@ var strings = {
'back': django.pgettext('widget', 'Back'),
'next_month': django.pgettext('widget', 'Next month'),
'previous_month': django.pgettext('widget', 'Previous month'),
'show_seating': django.pgettext('widget', 'Open seat selection'),
'days': {
'MO': django.gettext('Mo'),
'TU': django.gettext('Tu'),
@@ -557,6 +558,26 @@ var shared_methods = {
window.open(redirect_url);
}
},
startseating: function () {
var redirect_url = this.$root.target_url + 'w/' + widget_id;
if (this.$root.subevent){
redirect_url += '/' + this.$root.subevent;
}
redirect_url += '/seatingframe/?iframe=1&locale=' + lang;
if (this.$root.cart_id) {
redirect_url += '&take_cart_id=' + this.$root.cart_id;
}
if (this.$root.widget_data) {
redirect_url += '&widget_data=' + escape(this.$root.widget_data_json);
}
if (this.$root.useIframe) {
var iframe = this.$root.overlay.$children[0].$refs['frame-container'].children[0];
this.$root.overlay.frame_loading = true;
iframe.src = redirect_url;
} else {
window.open(redirect_url);
}
},
handleResize: function () {
this.mobile = this.$refs.wrapper.clientWidth <= 800;
}
@@ -678,6 +699,11 @@ Vue.component('pretix-widget-event-form', {
+ strings['cart_exists']
+ '<div class="pretix-widget-clear"></div>'
+ '</div>'
+ '<div class="pretix-widget-seating-link-wrapper" v-if="this.$root.has_seating_plan">'
+ '<button class="pretix-widget-seating-link" @click.prevent="$parent.startseating">'
+ strings['show_seating']
+ '</button>'
+ '</div>'
+ '<category v-for="category in this.$root.categories" :category="category" :key="category.id"></category>'
+ '<div class="pretix-widget-action" v-if="$root.display_add_to_cart">'
+ '<button @click="$parent.buy" type="submit">' + strings.buy + '</button>'
@@ -1061,6 +1087,7 @@ var shared_root_methods = {
root.cart_id = cart_id;
root.cart_exists = data.cart_exists;
root.vouchers_exist = data.vouchers_exist;
root.has_seating_plan = data.has_seating_plan;
root.itemnum = data.itemnum;
}
if (root.loading > 0) {
@@ -1226,7 +1253,8 @@ var create_widget = function (element) {
disable_vouchers: disable_vouchers,
cart_exists: false,
itemcount: 0,
overlay: null
overlay: null,
has_seating_plan: false
}
},
created: function () {

View File

@@ -478,6 +478,15 @@
font-weight: bold;
}
}
.pretix-widget-seating-link-wrapper {
padding: 0 15px;
margin: 15px 0 10px;
}
.pretix-widget-seating-link {
display: block;
width: 100%;
}
}
@keyframes pretix-widget-bounce-in {

View File

@@ -0,0 +1,225 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"version": "0.0.1",
"title": "Seating Plan",
"description": "Seating plan for venues",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"zones": {
"type": "array",
"description": "List of zones",
"items": { "$ref": "#/definitions/zone" }
},
"categories": {
"type": "array",
"description": "List of categories",
"items": { "$ref": "#/definitions/category" }
},
"size": {
"type": "object",
"description": "Size of the entire plan (in pixels)",
"properties": {
"width": { "type": "integer" },
"height": { "type": "integer" }
},
"required": ["width", "height"],
"additionalProperties": false
}
},
"required": ["zones", "name", "categories"],
"additionalProperties": false,
"definitions": {
"zone": {
"type": "object",
"description": "Zone represents the parent entity that groups all other entities. The zone itself can be hidden or displayed. Examples of different zones would be 'main area', 'balcony', etc.",
"properties": {
"name": {
"type": "string",
"description": "Name of the zone"
},
"displayed": {
"type": "boolean",
"description": "Should the zone outlines be displayed or not?"
},
"position": { "$ref": "#/definitions/position" },
"rows": {
"type": "array",
"description": "List of rows",
"items": { "$ref": "#/definitions/row" }
},
"areas": {
"type": "array",
"description": "List of areas",
"items": { "$ref": "#/definitions/area" }
},
"row_number_position": {
"type": ["string", "null"],
"enum": ["left", "right", null],
"description": "Should the row numbers be hidden?"
}
},
"required": ["position", "rows"],
"additionalProperties": false
},
"row": {
"type": "object",
"description": "Row is simply a collection of seats with some additional information that simplifies working with seats.",
"$comment": "Might need more (editor) infromation like direction, angle, curvature",
"properties": {
"row_number": {
"type": "string",
"description": "Row number or name by which it can be identified"
},
"seats": {
"type": "array",
"description": "List of seats in this row",
"items": { "$ref": "#/definitions/seat" }
},
"number_of_seats": {
"type": "integer",
"$comment": "This property might be redundant. Since the seats are nested within the row, it's easy to just count them."
},
"seats_spacing": {
"type": "integer",
"description": "How far apart should the seats be positioned?"
},
"row_number_position": {
"type": ["string", "null"],
"enum": ["left", "right", null],
"description": "Should the row numbers be hidden? (Overrides the zone setting)"
},
"position": {
"$ref": "#/definitions/position"
}
},
"required": ["seats", "row_number"],
"additionalProperties": false
},
"seat": {
"type": "object",
"description": "Individual seat that can be reserved.",
"properties": {
"seat_guid": {
"type": "string",
"description": "Seat global ID by which it can be identified. It should be globally unique (not just per row). It doesn't have to be pretty since it won't be shown to the user."
},
"seat_number": {
"type": "string",
"description": "Human-readable seat number."
},
"position": { "$ref": "#/definitions/position" },
"category": {
"type": "string",
"description": "What category does this seat belong to? This needs to refer to the name of a category defined on the top level of the file. Keep in mind that there is no way to enfore this requirement with this version of JSON schema."
}
},
"required": ["seat_guid", "seat_number", "position", "category"],
"additionalProperties": false
},
"area": {
"type": "object",
"description": "Area can represent anything from general admission area, to stage, bar or even tables.",
"$comment": "TODO needs a definition: should it be defined with parameters or with a free-form svg?",
"properties": {
"color": { "type": "string" },
"border_color": { "type": "string" },
"rotation": { "type": "number" },
"position": {
"$ref": "#/definitions/position"
},
"shape": {
"type": "string",
"enum": ["polygon", "rectangle", "ellipse", "circle", "text"]
},
"polygon": {
"type": "object",
"properties": {
"points": {
"type": "array",
"items": { "$ref": "#/definitions/position" }
}
},
"required": ["points"],
"additionalProperties": false
},
"rectangle": {
"type": "object",
"properties": {
"width": {
"type": "integer"
},
"height": {
"type": "integer"
}
},
"required": ["width", "height"],
"additionalProperties": false
},
"ellipse": {
"type": "object",
"properties": {
"radius": { "$ref": "#/definitions/position" }
},
"required": ["radius"],
"additionalProperties": false
},
"circle": {
"type": "object",
"properties": {
"radius": {
"type": "integer"
}
},
"required": ["radius"],
"additionalProperties": false
},
"text": {
"type": "object",
"properties": {
"text": { "type": "string" },
"color": { "type": "string" },
"position": {
"$ref": "#/definitions/position"
}
},
"required": ["text", "position"],
"additionalProperties": false
}
},
"additionalProperties": false
},
"position": {
"type": "object",
"description": "Position of the element",
"properties": {
"x": {
"type": "integer"
},
"y": {
"type": "integer"
}
},
"required": ["x", "y"],
"additionalProperties": false
},
"category": {
"type": "object",
"description": "A category of seats, e.g. a price level.",
"properties": {
"name": {
"type": "string",
"description": "Internal name of the seats, e.g. 'stalls'. This should be used to map this to actual shop products."
},
"color": {
"type": "string",
"description": "The color used to draw seats of this category."
}
},
"required": ["name"],
"additionalProperties": false
}
}
}