diff --git a/doc/development/api/cookieconsent.rst b/doc/development/api/cookieconsent.rst new file mode 100644 index 000000000..64e67f5ba --- /dev/null +++ b/doc/development/api/cookieconsent.rst @@ -0,0 +1,119 @@ +.. highlight:: python + :linenothreshold: 5 + +.. _`cookieconsent`: + +Handling cookie consent +======================= + +pretix includes an optional feature to handle cookie consent explicitly to comply with EU regulations. +If your plugin sets non-essential cookies or includes a third-party service that does so, you should +integrate with this feature. + +Server-side integration +----------------------- + +First, you need to declare that you are using non-essential cookies by responding to the following +signal: + +.. automodule:: pretix.presale.signals + :members: register_cookie_providers + +You are expected to return a list of ``CookieProvider`` objects instantiated from the following class: + +.. class:: pretix.presale.cookies.CookieProvider + + .. py:attribute:: CookieProvider.identifier + + A short and unique identifier used to distinguish this cookie provider form others (required). + + .. py:attribute:: CookieProvider.provider_name + + A human-readable name of the entity of feature responsible for setting the cookie (required). + + .. py:attribute:: CookieProvider.usage_classes + + A list of enum values from the ``pretix.presale.cookies.UsageClass`` enumeration class, such as + ``UsageClass.ANALYTICS``, ``UsageClass.MARKETING``, or ``UsageClass.SOCIAL`` (required). + + .. py:attribute:: CookieProvider.privacy_url + + A link to a privacy policy (optional). + +Here is an example of such a receiver: + +.. code-block:: python + + @receiver(register_cookie_providers) + def recv_cookie_providers(sender, request, **kwargs): + return [ + CookieProvider( + identifier='google_analytics', + provider_name='Google Analytics', + usage_classes=[UsageClass.ANALYTICS], + ) + ] + +JavaScript-side integration +--------------------------- + +The server-side integration only causes the cookie provider to show up in the cookie dialog. You still +need to care about actually enforcing the consent state. + +You can access the consent state through the ``window.pretix.cookie_consent`` variable. Whenever the +value changes, a ``pretix:cookie-consent:change`` event is fired on the ``document`` object. + +The variable will generally have one of the following states: + +.. rst-class:: rest-resource-table + +================================================================ ===================================================== +State Interpretation +================================================================ ===================================================== +``pretix === undefined || pretix.cookie_consent === undefined`` Your JavaScript has loaded before the cookie consent + script. Wait for the event to be fired, then try again, + do not yet set a cookie. +``pretix.cookie_consent === null`` The cookie consent mechanism has not been enabled. This + usually means that you can set cookies however you like. +``pretix.cookie_consent[identifier] === undefined`` The cookie consent mechanism is loaded, but has no data + on your cookie yet, wait for the event to be fired, do not + yet set a cookie. +``pretix.cookie_consent[identifier] === true`` The user has consented to your cookie. +``pretix.cookie_consent[identifier] === false`` The user has actively rejected your cookie. +================================================================ ===================================================== + +If you are integrating e.g. a tracking provider with native cookie consent support such +as Facebook's Pixel, you can integrate it like this: + +.. code-block:: javascript + + var consent = (window.pretix || {}).cookie_consent; + if (consent !== null && !(consent || {}).facebook) { + fbq('consent', 'revoke'); + } + fbq('init', ...); + document.addEventListener('pretix:cookie-consent:change', function (e) { + fbq('consent', (e.detail || {}).facebook ? 'grant' : 'revoke'); + }) + +If you have a JavaScript function that you only want to load if consent for a specific ``identifier`` +is given, you can wrap it like this: + +.. code-block:: javascript + + var consent_identifier = "youridentifier"; + var consent = (window.pretix || {}).cookie_consent; + if (consent === null || (consent || {})[consent_identifier] === true) { + // Cookie consent tool is either disabled or consent is given + addScriptElement(src); + return; + } + + // Either cookie consent tool has not loaded yet or consent is not given + document.addEventListener('pretix:cookie-consent:change', function onChange(e) { + var consent = e.detail || {}; + if (consent === null || consent[consent_identifier] === true) { + addScriptElement(src); + document.removeEventListener('pretix:cookie-consent:change', onChange); + } + }) diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst index bf4d2352b..5ccf0596a 100644 --- a/doc/development/api/index.rst +++ b/doc/development/api/index.rst @@ -17,6 +17,7 @@ Contents: shredder import customview + cookieconsent auth general quality diff --git a/doc/user/events/widget.rst b/doc/user/events/widget.rst index 6cda0b599..bf816dd38 100644 --- a/doc/user/events/widget.rst +++ b/doc/user/events/widget.rst @@ -309,6 +309,10 @@ Currently, the following attributes are understood by pretix itself: always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely on this for authentication. +* If ``data-consent="…"`` is given, the cookie consent mechanism will be initialized with consent for the given cookie + providers. All other providers will be disabled, no consent dialog will be shown. This is useful if you already + asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"`` + Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix Hosted or pretix Enterprise are active, you can pass the following fields: diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 2e49e44ee..b2c86ff56 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -296,7 +296,14 @@ class OrganizerSettingsSerializer(SettingsSerializer): 'theme_round_borders', 'primary_font', 'organizer_logo_image_inherit', - 'organizer_logo_image' + 'organizer_logo_image', + 'privacy_url', + 'cookie_consent', + 'cookie_consent_dialog_title', + 'cookie_consent_dialog_text', + 'cookie_consent_dialog_text_secondary', + 'cookie_consent_dialog_button_yes', + 'cookie_consent_dialog_button_no', ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index b5c72ba62..f1f85a4b4 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -97,10 +97,21 @@ class Organizer(LoggedModel): return self.name def save(self, *args, **kwargs): + is_new = not self.pk obj = super().save(*args, **kwargs) - self.get_cache().clear() + if is_new: + self.set_defaults() + else: + self.get_cache().clear() return obj + def set_defaults(self): + """ + This will be called after organizer creation. + This way, we can use this to introduce new default settings to pretix that do not affect existing organizers. + """ + self.settings.cookie_consent = True + def get_cache(self): """ Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index bd6ca615b..a314874ed 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1512,6 +1512,17 @@ DEFAULTS = { ), 'serializer_class': serializers.URLField, }, + 'privacy_url': { + 'default': None, + 'type': str, + 'form_class': forms.URLField, + 'form_kwargs': dict( + label=_("Privacy Policy URL"), + help_text=_("This should point e.g. to a part of your website that explains how you use data gathered in " + "your ticket shop."), + ), + 'serializer_class': serializers.URLField, + }, 'confirm_texts': { 'default': LazyI18nStringList(), 'type': LazyI18nStringList, @@ -2489,6 +2500,77 @@ Your {organizer} team""")) 'many years. If you keep it empty, gift cards do not have an explicit expiry date.'), ) }, + 'cookie_consent': { + 'default': 'False', + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Enable cookie consent management features"), + ), + 'type': bool, + }, + 'cookie_consent_dialog_text': { + 'default': LazyI18nString.from_gettext(gettext_noop( + 'By clicking "Accept all cookies", you agree to the storing of cookies and use of similar technologies on ' + 'your device.' + )), + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_("Dialog text"), + widget=I18nTextarea, + widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}}, + ) + }, + 'cookie_consent_dialog_text_secondary': { + 'default': LazyI18nString.from_gettext(gettext_noop( + 'We use cookies and similar technologies to gather data that allows us to improve this website and our ' + 'offerings. If you do not agree, we will only use cookies if they are essential to providing the services ' + 'this website offers.' + )), + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_("Secondary dialog text"), + widget=I18nTextarea, + widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}}, + ) + }, + 'cookie_consent_dialog_title': { + 'default': LazyI18nString.from_gettext(gettext_noop('Privacy settings')), + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_('Dialog title'), + widget=I18nTextInput, + widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}}, + ) + }, + 'cookie_consent_dialog_button_yes': { + 'default': LazyI18nString.from_gettext(gettext_noop('Accept all cookies')), + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_('"Accept" button description'), + widget=I18nTextInput, + widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}}, + ) + }, + 'cookie_consent_dialog_button_no': { + 'default': LazyI18nString.from_gettext(gettext_noop('Required cookies only')), + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_('"Reject" button description'), + widget=I18nTextInput, + widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}}, + ) + }, 'seating_choice': { 'default': 'True', 'form_class': forms.BooleanField, diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 158db11b8..82afb94a4 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -307,8 +307,14 @@ class OrganizerSettingsForm(SettingsForm): 'theme_color_danger', 'theme_color_background', 'theme_round_borders', - 'primary_font' - + 'primary_font', + 'privacy_url', + 'cookie_consent', + 'cookie_consent_dialog_title', + 'cookie_consent_dialog_text', + 'cookie_consent_dialog_text_secondary', + 'cookie_consent_dialog_button_yes', + 'cookie_consent_dialog_button_no', ] organizer_logo_image = ExtFileField( diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index 7f5a082f7..34bc2520e 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -82,6 +82,49 @@ {% bootstrap_field sform.giftcard_expiry_years layout="control" %} {% bootstrap_field sform.giftcard_length layout="control" %} +
+ {% trans "Privacy" %} + {% bootstrap_field sform.privacy_url layout="control" %} + + {% bootstrap_field sform.cookie_consent layout="control" %} + {% bootstrap_field sform.cookie_consent_dialog_title layout="control" %} + {% bootstrap_field sform.cookie_consent_dialog_text layout="control" %} + {% bootstrap_field sform.cookie_consent_dialog_text_secondary layout="control" %} + {% bootstrap_field sform.cookie_consent_dialog_button_yes layout="control" %} + {% bootstrap_field sform.cookie_consent_dialog_button_no layout="control" %} +
{% trans "Invoices" %} {% bootstrap_field sform.invoice_regenerate_allowed layout="control" %} diff --git a/src/pretix/presale/context.py b/src/pretix/presale/context.py index 7ab386647..3a6d94da5 100644 --- a/src/pretix/presale/context.py +++ b/src/pretix/presale/context.py @@ -31,7 +31,6 @@ # Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. - import logging from django.conf import settings @@ -47,10 +46,12 @@ from pretix.helpers.i18n import ( ) from ..base.i18n import get_language_without_region +from .cookies import get_cookie_providers from .signals import ( footer_link, global_footer_link, global_html_footer, global_html_head, global_html_page_header, html_footer, html_head, html_page_header, ) +from .views.cart import cart_session, get_or_create_cart_id logger = logging.getLogger(__name__) @@ -140,6 +141,12 @@ def _default_context(request): ctx['event'] = request.event ctx['languages'] = [get_language_info(code) for code in request.event.settings.locales] + ctx['cookie_providers'] = get_cookie_providers(request.event, request) + if get_or_create_cart_id(request, create=False): + c = cart_session(request) + if "widget_data" in c and c["widget_data"].get("consent"): + ctx['cookie_consent_from_widget'] = c["widget_data"].get("consent").split(",") + if request.resolver_match: ctx['cart_namespace'] = request.resolver_match.kwargs.get('cart_namespace', '') elif hasattr(request, 'organizer'): diff --git a/src/pretix/presale/cookies.py b/src/pretix/presale/cookies.py new file mode 100644 index 000000000..afea6d5de --- /dev/null +++ b/src/pretix/presale/cookies.py @@ -0,0 +1,64 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. +from enum import Enum +from typing import List + +from pretix.presale.signals import register_cookie_providers + + +class UsageClass(Enum): + FUNCTIONAL = 1 + ANALYTICS = 2 + MARKETING = 3 + SOCIAL = 4 + + +class CookieProvider: + def __init__(self, identifier: str, usage_classes: List[UsageClass], provider_name: str, privacy_url: str = None, **kwargs): + self.identifier = identifier + self.usage_classes = usage_classes + self.provider_name = provider_name + self.privacy_url = privacy_url + + +def get_cookie_providers(event, request): + c = [ + ] + for receiver, response in register_cookie_providers.send(event, request=request): + if isinstance(response, list): + c += response + else: + c.append(response) + c.sort(key=lambda k: str(k.provider_name)) + return c diff --git a/src/pretix/presale/signals.py b/src/pretix/presale/signals.py index 373e13049..49e7acced 100644 --- a/src/pretix/presale/signals.py +++ b/src/pretix/presale/signals.py @@ -401,3 +401,13 @@ This signal is sent out when the description of an item or variation is rendered additional text to the description. You are passed the ``item`` and ``variation`` and expected to return HTML. """ + +register_cookie_providers = EventPluginSignal() +""" +Arguments: ``request`` + +This signal is sent out to get all cookie providers that could set a cookie on this page, regardless of +consent state. Receivers should return a list of ``pretix.presale.cookies.CookieProvider`` objects. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" diff --git a/src/pretix/presale/templates/pretixpresale/event/base.html b/src/pretix/presale/templates/pretixpresale/event/base.html index 64825986e..3065dd5cb 100644 --- a/src/pretix/presale/templates/pretixpresale/event/base.html +++ b/src/pretix/presale/templates/pretixpresale/event/base.html @@ -169,6 +169,12 @@ {% if request.event.settings.contact_mail %}
  • {% trans "Contact event organizer" %}
  • {% endif %} + {% if request.event.settings.privacy_url %} +
  • {% trans "Privacy policy" %}
  • + {% endif %} + {% if request.event.settings.cookie_consent and cookie_providers %} +
  • + {% endif %} {% if request.event.settings.imprint_url %}
  • {% trans "Imprint" %}
  • {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/fragment_js.html b/src/pretix/presale/templates/pretixpresale/fragment_js.html index 9098c3d0a..80af10578 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_js.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_js.html @@ -14,6 +14,7 @@ + diff --git a/src/pretix/presale/templates/pretixpresale/fragment_modals.html b/src/pretix/presale/templates/pretixpresale/fragment_modals.html index 294c05688..4343a93b8 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_modals.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_modals.html @@ -1,4 +1,7 @@ {% load i18n %} +{% load rich_text %} +{% load safelink %} +{% load escapejson %}
    +{% if request.organizer and request.organizer.settings.cookie_consent %} + + {% if cookie_consent_from_widget %} + {{ cookie_consent_from_widget|json_script:"cookie-consent-from-widget" }} + {% endif %} + {% if cookie_providers %} + + {% endif %} +{% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/base.html b/src/pretix/presale/templates/pretixpresale/organizers/base.html index 905ff154e..f319223cf 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/base.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/base.html @@ -87,6 +87,12 @@ {% if not request.event and request.organizer.settings.contact_mail %}
  • {% trans "Contact event organizer" %}
  • {% endif %} + {% if not request.event and request.organizer.settings.privacy_url %} +
  • {% trans "Privacy policy" %}
  • + {% endif %} + {% if not request.event and request.organizer.settings.cookie_consent and cookie_providers %} +
  • + {% endif %} {% if not request.event and request.organizer.settings.imprint_url %}
  • {% trans "Imprint" %}
  • {% endif %} diff --git a/src/pretix/static/pretixpresale/js/ui/cookieconsent.js b/src/pretix/static/pretixpresale/js/ui/cookieconsent.js new file mode 100644 index 000000000..0ff66588e --- /dev/null +++ b/src/pretix/static/pretixpresale/js/ui/cookieconsent.js @@ -0,0 +1,86 @@ +/*global $ */ + +$(function () { + window.pretix = window.pretix || {}; + + var storage_key = $("#cookie-consent-storage-key").text(); + function update_consent(consent) { + if (storage_key) window.localStorage[storage_key] = JSON.stringify(consent); + window.pretix.cookie_consent = consent; + + // Event() is not supported by IE11, see ployfill here: + // https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#polyfill + var e = document.createEvent('CustomEvent'); + e.initCustomEvent('pretix:cookie-consent:change', true, true, consent); + document.dispatchEvent(e) + } + + if (!storage_key) { + update_consent(null); + return; + } + + var storage_val = window.localStorage[storage_key]; + var show_dialog = !storage_val; + var consent_checkboxes = $("#cookie-consent-details input[type=checkbox][name]"); + var consent_modal = $("#cookie-consent-modal"); + if (storage_val) { + storage_val = JSON.parse(storage_val); + consent_checkboxes.each(function () { + if (typeof storage_val[this.name] === "undefined") { + // A new cookie type has been added that we haven't asked for yet + show_dialog = true; + } else if (storage_val[this.name]) { + this.checked = true; + } + }) + } else { + storage_val = {} + var consented = $("#cookie-consent-from-widget").text(); + if (consented) { + consented = JSON.parse(consented); + consent_checkboxes.each(function () { + this.checked = storage_val[this.name] = consented.indexOf(this.name) > -1; + }) + show_dialog = false + } + } + update_consent(storage_val); + + function _set_button_text () { + var btn = $("#cookie-consent-button-no"); + btn.text( + consent_checkboxes.filter(":checked").length ? + btn.attr("data-detail-text") : + btn.attr("data-summary-text") + ); + } + + if (consent_checkboxes.filter(":checked").length) { + $("#cookie-consent-details").prop("open", true).find("> *:not(summary)").show(); + } + + _set_button_text(); + if (show_dialog) { + // We use .css() instead of .show() because of some weird issue that only occurs in Firefox + // and only within the widget. + consent_modal.css("display", "block"); + } + + $("#cookie-consent-button-yes, #cookie-consent-button-no").on("click", function () { + consent_modal.hide(); + var consent = {}; + var consent_all = this.id == "cookie-consent-button-yes"; + consent_checkboxes.each(function () { + consent[this.name] = this.checked = consent_all || this.checked; + }); + if (consent_all) _set_button_text(); + update_consent(consent); + }); + consent_checkboxes.on("change", _set_button_text); + $("#cookie-consent-reopen").on("click", function (e) { + consent_modal.show() + e.preventDefault() + return true + }) +}); diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index c46978e21..e8cf10f9f 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -218,6 +218,16 @@ $(function () { $(this).append(content); }); + $("[data-click-to-load]").on("click", function(e) { + var target = document.getElementById(this.getAttribute("data-click-to-load")); + target.src = this.href; + target.focus(); + e.preventDefault(); + }); + $(".overlay-remove").on("click", function() { + $(this).closest(".contains-overlay").find(".overlay").fadeOut(); + }); + $("#voucher-box").hide(); $("#voucher-toggle").show(); $("#voucher-toggle a").click(function () { diff --git a/src/pretix/static/pretixpresale/scss/_forms.scss b/src/pretix/static/pretixpresale/scss/_forms.scss index 123360976..63134ed3e 100644 --- a/src/pretix/static/pretixpresale/scss/_forms.scss +++ b/src/pretix/static/pretixpresale/scss/_forms.scss @@ -132,6 +132,15 @@ a.btn, button.btn { } } +details { + summary .chevron::before { + content: $fa-var-caret-right; + } + &[open] .chevron::before { + content: $fa-var-caret-down; + } +} + @media(max-width: $screen-xs-max) { .nameparts-form-group { diff --git a/src/pretix/static/pretixpresale/scss/main.scss b/src/pretix/static/pretixpresale/scss/main.scss index 976a889e3..16e15e990 100644 --- a/src/pretix/static/pretixpresale/scss/main.scss +++ b/src/pretix/static/pretixpresale/scss/main.scss @@ -75,6 +75,13 @@ footer nav li:not(:first-child):before { width: 1.5em; text-align: center; } +footer nav .btn-link { + display: inline; + padding: 0; + margin: 0; + font-size: 11px; + vertical-align: baseline; +} .js-only { display: none; @@ -122,6 +129,29 @@ a:hover .panel-primary > .panel-heading { } } +.contains-overlay { + position: relative; +} +.overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: $gray-lightest; +} +.overlay-centered { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.overlay-centered .overlay-content { + max-width: 35em; + margin-left: auto; + margin-right: auto; +} + body.loading .container { -webkit-filter: blur(2px); -moz-filter: blur(2px); @@ -136,7 +166,7 @@ body.loading .container { font-size: 120px; color: $brand-primary; } -#loadingmodal, #ajaxerr { +#loadingmodal, #ajaxerr, #cookie-consent-modal { position: fixed; top: 0; left: 0; @@ -156,9 +186,10 @@ body.loading .container { .modal-card { margin: 50px auto 0; - top: 50px; width: 90%; max-width: 600px; + max-height: calc(100vh - 100px); + overflow-y: auto; background: white; border-radius: $border-radius-large; box-shadow: 0 7px 14px 0 rgba(78, 50, 92, 0.1),0 3px 6px 0 rgba(0,0,0,.07); @@ -178,10 +209,31 @@ body.loading .container { } } } + + &#cookie-consent-modal { + background: rgba(255, 255, 255, .5); + opacity: 1; + visibility: visible; + display: none; + .modal-card-content { + margin-left: 0; + } + details { + & > summary { + list-style: inherit; + } + & > summary::-webkit-details-marker { + display: inherit; + } + margin-bottom: 10px; + } + } } @media (max-width: 700px) { - #loadingmodal, #ajaxerr { + #loadingmodal, #ajaxerr, #cookie-consent-modal { .modal-card { + margin: 25px auto 0; + max-height: calc(100vh - 50px - 20px); .modal-card-icon { float: none; width: 100%;