mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Allow to save invoice addresses and attendee profiles to customer account (#2084)
Co-authored-by: Raphael Michel <michel@rami.io> Co-authored-by: Richard Schreiber <wiffbi@gmail.com> Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
@@ -275,6 +275,7 @@ $(function () {
|
||||
attendee_address_fields.change(function () {
|
||||
copy_to_first_ticket = false;
|
||||
});
|
||||
questions_init_profiles($("body"));
|
||||
|
||||
// Subevent choice
|
||||
if ($(".subevent-toggle").length) {
|
||||
@@ -407,10 +408,15 @@ $(function () {
|
||||
if (counter > curCounter) {
|
||||
return; // Lost race
|
||||
}
|
||||
var selected_value = dependent.prop("data-selected-value");
|
||||
dependent.find("option").filter(function (t) {return !!$(this).attr("value")}).remove();
|
||||
if (data.data.length > 0) {
|
||||
$.each(data.data, function (k, s) {
|
||||
dependent.append($("<option>").attr("value", s.code).text(s.name));
|
||||
var o = $("<option>").attr("value", s.code).text(s.name);
|
||||
if (s.code == selected_value || (selected_value && selected_value.indexOf && selected_value.indexOf(s.code) > -1)) {
|
||||
o.prop("selected", true);
|
||||
}
|
||||
dependent.append(o);
|
||||
});
|
||||
dependent.closest(".form-group").show();
|
||||
dependent.prop('required', dependency.prop("required"));
|
||||
|
||||
@@ -113,3 +113,373 @@ function questions_init_photos(el) {
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function questions_init_profiles(el) {
|
||||
/*
|
||||
Auto-fill answers with profiles and addresses from customer account.
|
||||
|
||||
There are two types of profiles:
|
||||
1. profiles for answers and
|
||||
2. profiles for invoice addesses
|
||||
|
||||
Both are handled the same way.
|
||||
|
||||
Each form section/fieldset has its own auto-fill and save to profile
|
||||
inputs. Each fieldset can define its own profiles by providing the
|
||||
HTML-attribute data-profiles-id, which defaults to "profiles_json".
|
||||
Currently only the invoice address fieldset uses this to load a
|
||||
different set of profiles.
|
||||
|
||||
For each section each profile’s answers are matched to inputs inside
|
||||
this section. Only matching ones are shown for auto-fill. If multiple
|
||||
profiles only match the same inputs with the same values (e.g. name)
|
||||
then only the first one is shown as showing multiple profile with the
|
||||
same values is not helpful.
|
||||
|
||||
Feature-Idea:
|
||||
– in the original profile description, strikethrough which answer
|
||||
will be overwritten, followed by the new answer
|
||||
– add new answers with a + in front
|
||||
– change <select> to a list of radio-buttons for multiline-display
|
||||
of profiles?
|
||||
*/
|
||||
var profilesById = {};
|
||||
function getProfilesById(id) {
|
||||
if (!(id in profilesById)) {
|
||||
var element = document.getElementById(id);
|
||||
profilesById[id] = (!element || !element.textContent) ? [] : JSON.parse(element.textContent);
|
||||
}
|
||||
return profilesById[id];
|
||||
}
|
||||
|
||||
function matchProfilesToInputs(profiles, scope) {
|
||||
var filtered = [];
|
||||
var data;
|
||||
var matched_field;
|
||||
var addSpecialKey;
|
||||
// special fields are used for substition with human readable or pre-formatted values
|
||||
var addSpecialFieldMap = {
|
||||
"country": "_country_for_address",
|
||||
"state": "_state_for_address",
|
||||
"name_parts_0": "_name",
|
||||
"attendee_name_parts_0": "_attendee_name",
|
||||
}
|
||||
for (var p of profiles) {
|
||||
data = {};
|
||||
for (var key of Object.keys(p)) {
|
||||
if (key.startsWith("_")) {
|
||||
continue;
|
||||
}
|
||||
matched_field = getMatchingInput(key, p[key], scope);
|
||||
if (matched_field) {
|
||||
// TODO: only add if no other answer matches same fields?
|
||||
data[key] = {
|
||||
"value": (typeof p[key] == "string") ? p[key] : p[key]["value"],
|
||||
"field": matched_field
|
||||
};
|
||||
if (p[key]["label"]) data[key]["label"] = p[key]["label"];
|
||||
if (p[key]["type"]) data[key]["type"] = p[key]["type"];
|
||||
if (addSpecialKey = addSpecialFieldMap[key]) {
|
||||
data[addSpecialKey] = p[addSpecialKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length) filtered.push(data);
|
||||
};
|
||||
return filtered;
|
||||
}
|
||||
// For auto-fill with few inputs it could happen that multiple profiles
|
||||
// only match with the same fields that have the same values. It makes
|
||||
// no sense to show multiple profiles if all fill the same value(s).
|
||||
// Therefore filter profiles to unique ones.
|
||||
function uniqueProfiles(profiles) {
|
||||
var uniques = [];
|
||||
var matchIndex;
|
||||
for (var p of profiles) {
|
||||
matchIndex = uniques.findIndex(function(element, index, array) {
|
||||
return _profilesAreEqual(element, p);
|
||||
});
|
||||
if (matchIndex === -1) uniques.push(p);
|
||||
}
|
||||
return uniques;
|
||||
}
|
||||
function _profilesAreEqual(a, b) {
|
||||
var keysA = Object.keys(a);
|
||||
var keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
keysA.sort();
|
||||
keysB.sort();
|
||||
if (!keysA.every((val, index) => val === keysB[index])) return false;
|
||||
if (!keysA.every((key, index) => a[key].value === b[key].value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function _getInputForLabel(label) {
|
||||
if (!label) return null;
|
||||
var input;
|
||||
if (label.getAttribute("for")) {
|
||||
input = document.getElementById(label.getAttribute("for"));
|
||||
if (input) return input;
|
||||
}
|
||||
// for grouped inputs like phone number the "label" is more a fieldset/legend
|
||||
return label.closest(".form-group").querySelectorAll("select, input, textarea");
|
||||
}
|
||||
function getMatchingInput(key, answer, scope) {
|
||||
var $label;
|
||||
// _0 and _1 are e.g. for phone-fields. name-fields have their parts/keys already split
|
||||
var $fields = $('[name$="' + key + '"], [name$="' + key + '_0"], [name$="' + key + '_1"]', scope);
|
||||
if ($fields.length) return $fields;
|
||||
|
||||
if (answer.identifier) {
|
||||
$label = $('[data-identifier="' + answer.identifier + '"]', scope);
|
||||
var input = _getInputForLabel($label.get(0));
|
||||
if (input) return $(input);
|
||||
}
|
||||
for (var label of scope.getElementsByTagName("label")) {
|
||||
if (label.textContent === answer.label) {
|
||||
var input = _getInputForLabel(label);
|
||||
if (input) return $(input);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatAnswerHumanReadable(answer) {
|
||||
if (typeof answer == "string") return answer;
|
||||
if (typeof answer == "number") return answer.toString();
|
||||
if (!answer && answer !== false) return "";
|
||||
var value = answer.value;
|
||||
if ("type" in answer) {
|
||||
if (answer.type === "TEL") {
|
||||
// TODO: format phone number with locale or use pre-formatted like with names?
|
||||
return value;
|
||||
}
|
||||
if (answer.type === "W") {
|
||||
return moment(value).format(document.body.getAttribute("data-datetimeformat"));
|
||||
}
|
||||
if (answer.type === "D") {
|
||||
return moment(value).format(document.body.getAttribute("data-dateformat"));
|
||||
}
|
||||
if (answer.type === "H") {
|
||||
var format = document.body.getAttribute("data-timeformat");
|
||||
return moment(value, "HH:mm:ss").format(format);
|
||||
}
|
||||
if (answer.type === "B") {
|
||||
return value ? gettext("Yes") : gettext("No");
|
||||
}
|
||||
}
|
||||
if (typeof value == "string") return value;
|
||||
if (!value) return "";
|
||||
return Object.values(value).join(", ");
|
||||
}
|
||||
|
||||
// TODO: add as few info as possible to make a distinction between available profiles?
|
||||
function labelForProfile(p, profiles, scope = null) {
|
||||
var parts = describeProfile(p);
|
||||
var label = parts.join(", ");
|
||||
if (label.length > 74) {
|
||||
var len = label.lastIndexOf(' ', 74);
|
||||
label = label.substr(0, Math.max(len, 48)) + " …";
|
||||
}
|
||||
return label;
|
||||
}
|
||||
function getAnswer(a) {
|
||||
if (typeof a == "string") return a;
|
||||
return a && "value" in a ? a["value"] : "";
|
||||
}
|
||||
function describeProfile(p) {
|
||||
if (!p) return [];
|
||||
var lines = [
|
||||
getAnswer(p["company"]),
|
||||
p["_name"],
|
||||
[p["_attendee_name"], getAnswer(p["attendee_email"])].filter(v => v).join(", "),
|
||||
[
|
||||
getAnswer(p["street"]),
|
||||
[getAnswer(p["zipcode"]), getAnswer(p["city"]), p["_state_for_address"]].filter(v => v).join(" "),
|
||||
p["_country_for_address"]
|
||||
].filter(v => v).join(", ")
|
||||
];
|
||||
lines = lines.filter(line => line && line.trim());
|
||||
|
||||
var answer;
|
||||
var label;
|
||||
for (var key of Object.keys(p)) {
|
||||
if (!key.startsWith("question_")) continue;
|
||||
answer = p[key];
|
||||
label = answer["label"] || "";
|
||||
lines.push(label + ("!?.:".split("").indexOf(label.slice(-1)) > -1 ? " " : ": ") + formatAnswerHumanReadable(answer))
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
function escapeHTML(t) {
|
||||
return $("<div>").text(t).get(0).innerHTML;
|
||||
}
|
||||
function describeProfileHTML(p) {
|
||||
return describeProfile(p).map(escapeHTML).join("<br>");
|
||||
}
|
||||
|
||||
|
||||
function _updateDescription(select, profile, $help) {
|
||||
// show additional description if different from option-text
|
||||
var label = select.options[select.selectedIndex].textContent;
|
||||
var lines = describeProfile(profile).map(escapeHTML);
|
||||
if (!lines.length || label === lines.join(", ")) {
|
||||
$help.slideUp(function() {
|
||||
$help.html("");
|
||||
});
|
||||
}
|
||||
else {
|
||||
$help.html(lines.join("<br>")).slideDown();
|
||||
}
|
||||
}
|
||||
|
||||
function setupSaveToProfile(scope, profiles) {
|
||||
var $select = $('[name$="saved_id"]', scope);
|
||||
var $selectContainer = $select.closest(".form-group").addClass("profile-save-id");
|
||||
var $checkbox = $('[name$="save"]', scope);
|
||||
var $checkboxContainer = $checkbox.closest(".form-group").addClass("profile-save");
|
||||
var $help = $selectContainer.find(".help-block");
|
||||
|
||||
var $container = $("<div class='profile-save-container'></div>");
|
||||
$selectContainer.after($container);
|
||||
$container.append($checkboxContainer);
|
||||
$container.append($selectContainer);
|
||||
|
||||
if (!profiles || !profiles.length) {
|
||||
$selectContainer.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$checkbox.change(function() {
|
||||
if (this.checked) $selectContainer.slideDown();
|
||||
else $selectContainer.slideUp();
|
||||
});
|
||||
|
||||
for (var p of profiles) {
|
||||
$select.append($('<option>').attr('value', p._pk).text(labelForProfile(p, profiles)));
|
||||
}
|
||||
$select.append('<option value="" disabled>–</option>');
|
||||
$select.append($select.find("option").first());
|
||||
$select.get(0).selectedIndex = 0;
|
||||
$select.change(function() {
|
||||
_updateDescription(this, profiles[this.selectedIndex], $help);
|
||||
}).trigger("change");
|
||||
$checkbox.trigger("change");
|
||||
}
|
||||
|
||||
// setup auto-fill for each scope/fieldset
|
||||
// match profile’s answers to inputs in scope
|
||||
// if none match, do not show auto-fill
|
||||
// if one matches, only show button to auto-fill
|
||||
// else show select with profiles and button to auto-fill
|
||||
function setupAutoFill(scope, profiles) {
|
||||
var matchedProfiles = uniqueProfiles(matchProfilesToInputs(profiles, scope));
|
||||
if (!matchedProfiles.length) {
|
||||
$(scope).addClass("profile-none-matched");
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedProfile = matchedProfiles[0];
|
||||
var $select = $(".profile-select", scope);
|
||||
var $button = $(".profile-apply", scope);
|
||||
var $help = $(".profile-desc", scope);
|
||||
|
||||
if (matchedProfiles.length === 1) {
|
||||
$(".profile-select-control", scope).hide().parent().addClass("form-control-text");
|
||||
$help.html(describeProfileHTML(selectedProfile)).addClass("single-profile-desc").after($button);
|
||||
}
|
||||
else {
|
||||
var i = 0;
|
||||
for (p of matchedProfiles) {
|
||||
$select.append($("<option>").text(labelForProfile(p, matchedProfiles, scope)).attr("value", i));
|
||||
i++;
|
||||
}
|
||||
$select.change(function() {
|
||||
selectedProfile = matchedProfiles[this.value];
|
||||
_updateDescription(this, selectedProfile, $help);
|
||||
}).trigger("change");
|
||||
}
|
||||
// Add-Ons sit on same level as their parent product scope
|
||||
// Therefore use .prevUntil("legend") as an Add-On is
|
||||
// offset by a <legend>
|
||||
$(scope).prevUntil("legend").addClass("profile-pre-select");
|
||||
|
||||
$button.click(function() {
|
||||
Object.keys(selectedProfile).forEach(function(key) {
|
||||
var answer = selectedProfile[key].value;
|
||||
var $field = selectedProfile[key].field;
|
||||
if (!$field || !$field.length) return;
|
||||
|
||||
if ($field.attr("type") === "checkbox") {
|
||||
if (answer === true || answer === false) {
|
||||
// boolean
|
||||
$field.prop("checked", answer).trigger("change");
|
||||
}
|
||||
else if (typeof answer !== 'string') {
|
||||
answer = Object.keys(answer);
|
||||
$field.each(function() {
|
||||
var checked = answer.indexOf(this.value) > -1;
|
||||
if (checked !== this.checked) {
|
||||
this.checked = checked;
|
||||
$(this).trigger("change");
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if ($field.attr("type") === "radio") {
|
||||
$field.filter('[value="' + answer + '"]').prop("checked", true).trigger("change");
|
||||
} else if ($field.length > 1) {
|
||||
// multiple matching fields, could be phone number or datetime
|
||||
var $field_0 = $field.filter('[name$="_0"]');
|
||||
var $field_1 = $field.filter('[name$="_1"]');
|
||||
if (answer.substr(0, 1) === "+") {
|
||||
var prefix = "";
|
||||
var options = $field_0.get(0).options;
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
var v = options[i].value;
|
||||
if (v && answer.substr(0, v.length) === v) {
|
||||
prefix = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
var number = answer.substr(prefix.length);
|
||||
$field_0.val(prefix).trigger("change");
|
||||
$field_1.val(number).trigger("change");
|
||||
}
|
||||
else if ($field_0.hasClass("datepickerfield")) {
|
||||
$field_0.data('DateTimePicker').date(moment(answer));
|
||||
$field_1.data('DateTimePicker').date(moment(answer));
|
||||
}
|
||||
} else if ($field.is("select")) {
|
||||
if (answer && typeof answer !== 'string') {
|
||||
answer = Object.keys(answer);
|
||||
}
|
||||
// save answer as data-attribute so if external event changes select-element/options it can select correct entries
|
||||
// currently used when country => state changes
|
||||
$field.prop("data-selected-value", answer);
|
||||
$field.find("option").each(function() {
|
||||
this.selected = this.value === answer || (answer && answer.indexOf && answer.indexOf(this.value) > -1);
|
||||
});
|
||||
$field.trigger("change");
|
||||
} else {
|
||||
if ($field.hasClass("datepickerfield")) {
|
||||
$field.data('DateTimePicker').date(moment(answer));
|
||||
}
|
||||
else {
|
||||
$field.val(answer).trigger("change");
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// each fieldset is its own scope for auto-fill and save
|
||||
el.find(".profile-scope").each(function () {
|
||||
var profiles = getProfilesById(this.getAttribute("data-profiles-id") || "profiles_json");
|
||||
|
||||
setupSaveToProfile(this, profiles);
|
||||
setupAutoFill(this, profiles);
|
||||
|
||||
this.classList.add("profile-select-initialized");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,3 +103,56 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.profile-pre-select {
|
||||
background: $panel-footer-bg;
|
||||
margin-top: -15px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
.profile-pre-select .addon-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.profile-select-container {
|
||||
display: none;
|
||||
border-bottom: 1px solid $hr-border;
|
||||
background: $panel-footer-bg;
|
||||
padding-bottom: 6px;
|
||||
|
||||
margin-top: -15px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
.profile-save-container {
|
||||
border-top: 1px solid $hr-border;
|
||||
padding: 15px;
|
||||
margin: 15px -15px;
|
||||
background: $panel-footer-bg;
|
||||
}
|
||||
.profile-scope:last-child .profile-save-container {
|
||||
margin-bottom: -15px;
|
||||
}
|
||||
.profile-save-container .help-block {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.single-profile-desc {
|
||||
margin-top: 0;
|
||||
}
|
||||
.profile-save, .profile-save-id {
|
||||
display: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: .5em;
|
||||
}
|
||||
.profile-select-initialized .profile-select-container,
|
||||
.profile-select-initialized .profile-save {
|
||||
display: block;
|
||||
}
|
||||
.profile-none-matched .profile-select-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-add-on-legend {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
.profile-add-on {
|
||||
padding: 15px;
|
||||
border: 1px solid $hr-border;
|
||||
}
|
||||
|
||||
@@ -322,6 +322,17 @@ h2 .label {
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
border-bottom: 0px solid #ddd;
|
||||
}
|
||||
.tab-content {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0px 5px 5px 5px;
|
||||
& .tab-pane > table {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 0 through 100 {
|
||||
.progress-bar-#{$i} { width: 1% * $i; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user