event_route = frappe.form_dict.route
event_doc = frappe.get_cached_doc("FE Event", {"route": event_route})
data.event_doc = event_doc
# Ticket Types
available_ticket_types = []
published_ticket_types = frappe.db.get_all(
"Event Ticket Type",
filters={"is_published": True, "event": event_doc.name},
pluck="name"
)
for ticket_type in published_ticket_types:
tt = frappe.get_cached_doc("Event Ticket Type", ticket_type)
print(tt.remaining_tickets)
if tt.are_tickets_available(1):
available_ticket_types.append(tt)
data.available_ticket_types = available_ticket_types
# Ticket Add-ons
add_ons = frappe.db.get_all(
"Ticket Add-on",
filters={"event": event_doc.name},
fields=["name", "title", "price", "currency", "user_selects_option", "options"]
)
for add_on in add_ons:
if add_on.user_selects_option:
add_on.options = add_on.options.split("\n")
data.available_add_ons = add_ons
document.addEventListener("alpine:init", () => {
Alpine.store("booking_form", {
// ... (all other properties like num_attendees, attendees, etc. remain the same) ...
num_attendees: 1,
attendees: [],
available_add_ons: window.available_add_ons,
available_ticket_types: window.available_ticket_types,
add_ons_map: {},
ticket_types_map: {},
event_id: null,
submitting: false, // To disable the button during request
init() {
// Create maps for quick data retrieval
for (const add_on of this.available_add_ons) {
this.add_ons_map[add_on.name] = add_on;
}
for (const tt of this.available_ticket_types) {
this.ticket_types_map[tt.name] = tt;
}
if (this.available_ticket_types.length > 0) {
this.event_id = this.available_ticket_types[0].event;
}
// Initialize the first attendee
this.attendees.push(this.createNewAttendee());
},
createNewAttendee() {
const new_attendee = {
full_name: "",
email: "",
ticket_type: this.available_ticket_types[0]?.name || "",
add_ons: {},
};
for (const add_on of this.available_add_ons) {
new_attendee.add_ons[add_on.name] = {
selected: false,
option: add_on.options ? add_on.options[0] || null : null,
};
}
return new_attendee;
},
// ... (summary and total getters remain the same) ...
get summary() {
const summary = {
tickets: {},
add_ons: {},
};
for (const attendee of this.attendees) {
const ticket_type = attendee.ticket_type;
if (ticket_type && this.ticket_types_map[ticket_type]) {
if (!summary.tickets[ticket_type]) {
summary.tickets[ticket_type] = {
count: 0,
amount: 0,
price: this.ticket_types_map[ticket_type].price,
title: this.ticket_types_map[ticket_type].title,
};
}
summary.tickets[ticket_type].count++;
summary.tickets[ticket_type].amount +=
this.ticket_types_map[ticket_type].price;
}
for (const add_on_name in attendee.add_ons) {
if (attendee.add_ons[add_on_name].selected) {
if (!summary.add_ons[add_on_name]) {
summary.add_ons[add_on_name] = {
count: 0,
amount: 0,
price: this.add_ons_map[add_on_name].price,
title: this.add_ons_map[add_on_name].title,
};
}
summary.add_ons[add_on_name].count++;
summary.add_ons[add_on_name].amount +=
this.add_ons_map[add_on_name].price;
}
}
}
return summary;
},
get total() {
let total = 0;
const current_summary = this.summary;
for (const key in current_summary.tickets) {
total += current_summary.tickets[key].amount;
}
for (const key in current_summary.add_ons) {
total += current_summary.add_ons[key].amount;
}
return total;
},
// MODIFIED submit method
async submit() {
if (this.submitting) return; // Prevent double-clicks
this.submitting = true;
// 1. Create a deep copy of the attendees to avoid modifying the UI state.
const payload = JSON.parse(JSON.stringify(this.attendees));
// 2. Transform the add_ons for each attendee.
const attendees_payload = payload.map((attendee) => {
const selected_add_ons = [];
for (const add_on_name in attendee.add_ons) {
const add_on_state = attendee.add_ons[add_on_name];
if (add_on_state.selected) {
selected_add_ons.push({
add_on: add_on_name,
// Use the selected option, or true if no option is applicable
value: add_on_state.option || true,
});
}
}
// Replace the old add_ons object with the new, clean array
attendee.add_ons = selected_add_ons;
return attendee;
});
const final_payload = {
event: this.event_id,
attendees: attendees_payload,
// Define the success URL for Frappe to redirect to
redirect_to: "/booking-confirmation", // Or any other success page
};
try {
const response = await fetch(
"/api/v2/method/events.api.process_booking",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-Frappe-CSRF-Token": frappe.csrf_token,
},
body: JSON.stringify(final_payload),
},
);
if (!response.ok) {
// If the server returns an error (e.g., 4xx, 5xx)
const errorData = await response.json();
// Frappe often wraps errors in _server_messages
const errorMessage =
JSON.parse(errorData.errors || "[]")[0] ||
"An unknown error occurred.";
alert(errorMessage); // Show error to the user
} else {
const {data} = await response.json();
window.location.href = data;
}
} catch(error) {
console.error("Submission failed:", error);
alert("Could not connect to the server. Please try again later.");
} finally {
// Re-enable the button regardless of success or failure
this.submitting = false;
}
},
});
// ... (Alpine.effect remains the same) ...
Alpine.effect(() => {
const store = Alpine.store("booking_form");
const target_count = store.num_attendees;
const current_count = store.attendees.length;
if (target_count > current_count) {
for (let i = current_count; i < target_count; i++) {
store.attendees.push(store.createNewAttendee());
}
} else if (target_count < current_count) {
store.attendees.splice(target_count);
}
});
});
<script>
// These are passed from your backend (e.g., Jinja2)
window.available_add_ons = {{ available_add_ons|json }};
window.available_ticket_types = {{ available_ticket_types|json }};
</script>
<div>
<label>Num Attendees</label>
<input
min="1"
x-model.number="$store.booking_form.num_attendees"
type="number"
/>
</div>
<div>
<template x-for="(attendee, index) in $store.booking_form.attendees" :key="index">
<div class="attendee-card">
<h4>Attendee <span x-text="index + 1"></span></h4>
<input
x-model="attendee.full_name"
placeholder="Full Name"
required
type="text"
/>
<input
x-model="attendee.email"
placeholder="Email"
required
type="email"
/>
<div>
<label>Ticket Type</label>
<select x-model="attendee.ticket_type">
{% for tt in available_ticket_types %}
<option value="{{ tt.name }}">{{ tt.title }}</option>
{% endfor %}
</select>
</div>
<div>
<h5>Add-ons</h5>
{% for add_on in available_add_ons %}
<div>
<input
type="checkbox"
x-model="attendee.add_ons['{{add_on.name}}'].selected"
:id="`add_on_{{add_on.name}}_${index}`"
/>
<label :for="`add_on_{{add_on.name}}_${index}`"
>{{ add_on.title }}</label
>
{% if add_on.user_selects_option %}
<template x-if="attendee.add_ons['{{add_on.name}}'].selected">
<div>
<select x-model="attendee.add_ons['{{add_on.name}}'].option">
{% for option in add_on.options %}
<option value="{{ option }}">{{ option }}</option>
{% endfor %}
</select>
</div>
</template>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</template>
</div>