586 lines
34 KiB
XML
586 lines
34 KiB
XML
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
|
|
<!-- ==================== PUBLIC BOOKING PAGE ==================== -->
|
|
|
|
<template id="public_booking_page" name="Public Booking Page">
|
|
<t t-call="website.layout">
|
|
<div class="container py-5" style="max-width: 700px;">
|
|
|
|
<!-- Header -->
|
|
<div class="text-center mb-4">
|
|
<div class="d-inline-flex align-items-center justify-content-center rounded-circle mb-3"
|
|
style="width: 64px; height: 64px; background: linear-gradient(135deg, #5ba848, #3a8fb7);">
|
|
<i class="fa fa-calendar-check-o text-white" style="font-size: 28px;"/>
|
|
</div>
|
|
<h2 class="mb-1">Book a Time with <t t-out="staff_user.name"/></h2>
|
|
<p class="text-muted">Select a date and time that works for you</p>
|
|
</div>
|
|
|
|
<!-- Success Message -->
|
|
<t t-if="success">
|
|
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
|
<div class="mb-3">
|
|
<i class="fa fa-check-circle text-success" style="font-size: 48px;"/>
|
|
</div>
|
|
<h4 class="mb-2">Appointment Booked!</h4>
|
|
<p class="text-muted mb-0"><t t-out="success"/></p>
|
|
</div>
|
|
</t>
|
|
|
|
<!-- Error Message -->
|
|
<t t-if="error">
|
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
|
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
|
</div>
|
|
</t>
|
|
|
|
<t t-if="not success">
|
|
<form t-att-action="'/schedule/%s/book' % booking_slug" method="post" id="publicBookingForm">
|
|
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
|
|
|
<!-- Step 1: Date & Time -->
|
|
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
|
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
|
|
style="border-radius: 12px 12px 0 0;">
|
|
<h5 class="mb-0">
|
|
<span class="badge rounded-pill me-2"
|
|
style="background: linear-gradient(135deg, #5ba848, #3a8fb7);">1</span>
|
|
Select Date & Time
|
|
</h5>
|
|
</div>
|
|
<div class="card-body px-4 pb-4">
|
|
<!-- Appointment Type -->
|
|
<t t-if="appointment_types and len(appointment_types) > 1">
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Appointment Type</label>
|
|
<select name="appointment_type_id" class="form-select"
|
|
id="publicAppointmentType">
|
|
<t t-foreach="appointment_types" t-as="atype">
|
|
<option t-att-value="atype.id">
|
|
<t t-out="atype.name"/>
|
|
(<t t-out="'%.0f' % (atype.appointment_duration * 60)"/> min)
|
|
</option>
|
|
</t>
|
|
</select>
|
|
</div>
|
|
</t>
|
|
<t t-elif="appointment_types">
|
|
<input type="hidden" name="appointment_type_id"
|
|
t-att-value="appointment_types[0].id"/>
|
|
</t>
|
|
|
|
<!-- Date -->
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Select Date</label>
|
|
<input type="date" class="form-control" id="publicBookingDate"
|
|
name="date" required="required"
|
|
t-att-min="today"/>
|
|
</div>
|
|
|
|
<!-- Available Slots -->
|
|
<div id="publicSlotsContainer" style="display: none;">
|
|
<label class="form-label fw-semibold">Available Time Slots</label>
|
|
<div id="publicSlotsLoading" class="text-center py-3" style="display: none;">
|
|
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
|
|
Loading available slots...
|
|
</div>
|
|
<div id="publicSlotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
|
|
<div id="publicNoSlots" class="text-muted py-2" style="display: none;">
|
|
<i class="fa fa-info-circle me-1"/> No available slots for this date.
|
|
</div>
|
|
<input type="hidden" name="slot_datetime" id="publicSlotDatetime"/>
|
|
<input type="hidden" name="slot_duration" id="publicSlotDuration"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Your Details -->
|
|
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
|
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
|
|
style="border-radius: 12px 12px 0 0;">
|
|
<h5 class="mb-0">
|
|
<span class="badge rounded-pill me-2"
|
|
style="background: linear-gradient(135deg, #5ba848, #3a8fb7);">2</span>
|
|
Your Details
|
|
</h5>
|
|
</div>
|
|
<div class="card-body px-4 pb-4">
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Your Name <span class="text-danger">*</span></label>
|
|
<input type="text" name="visitor_name" class="form-control"
|
|
placeholder="Full name" required="required"/>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Email <span class="text-danger">*</span></label>
|
|
<input type="email" name="visitor_email" class="form-control"
|
|
placeholder="your@email.com" required="required"/>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Phone</label>
|
|
<input type="tel" name="visitor_phone" class="form-control"
|
|
placeholder="(optional)"/>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Address</label>
|
|
<input type="text" name="client_street" class="form-control mb-2"
|
|
id="publicClientStreet"
|
|
placeholder="Start typing an address..."/>
|
|
<div class="row g-2">
|
|
<div class="col-md-4">
|
|
<input type="text" name="client_city" class="form-control"
|
|
id="publicClientCity" placeholder="City"/>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<input type="text" name="client_province" class="form-control"
|
|
id="publicClientProvince" placeholder="Province"/>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<input type="text" name="client_postal" class="form-control"
|
|
id="publicClientPostal" placeholder="Postal Code"/>
|
|
</div>
|
|
</div>
|
|
<input type="hidden" name="client_lat" id="publicClientLat" value="0"/>
|
|
<input type="hidden" name="client_lng" id="publicClientLng" value="0"/>
|
|
</div>
|
|
<div class="mb-0">
|
|
<label class="form-label fw-semibold">Notes</label>
|
|
<textarea name="visitor_notes" class="form-control" rows="3"
|
|
placeholder="Anything you'd like us to know..."></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit -->
|
|
<div class="text-end">
|
|
<button type="submit" class="btn btn-primary btn-lg px-4" id="publicBtnSubmit"
|
|
disabled="disabled">
|
|
<i class="fa fa-calendar-check-o me-1"/> Confirm Booking
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</t>
|
|
|
|
<!-- Footer -->
|
|
<div class="text-center mt-4">
|
|
<small class="text-muted">Powered by Fusion Schedule</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Public Booking JS -->
|
|
<script type="text/javascript">
|
|
(function () {
|
|
'use strict';
|
|
|
|
var dateInput = document.getElementById('publicBookingDate');
|
|
var slotsContainer = document.getElementById('publicSlotsContainer');
|
|
var slotsGrid = document.getElementById('publicSlotsGrid');
|
|
var slotsLoading = document.getElementById('publicSlotsLoading');
|
|
var noSlots = document.getElementById('publicNoSlots');
|
|
var slotDatetime = document.getElementById('publicSlotDatetime');
|
|
var slotDuration = document.getElementById('publicSlotDuration');
|
|
var submitBtn = document.getElementById('publicBtnSubmit');
|
|
var typeSelect = document.getElementById('publicAppointmentType');
|
|
var selectedSlotBtn = null;
|
|
|
|
var slug = '<t t-out="booking_slug"/>';
|
|
|
|
function getTypeId() {
|
|
if (typeSelect) return typeSelect.value;
|
|
var hidden = document.querySelector('input[name="appointment_type_id"]');
|
|
return hidden ? hidden.value : null;
|
|
}
|
|
|
|
function fetchSlots(date) {
|
|
var typeId = getTypeId();
|
|
if (!typeId || !date) return;
|
|
|
|
slotsContainer.style.display = 'block';
|
|
slotsLoading.style.display = 'block';
|
|
slotsGrid.innerHTML = '';
|
|
noSlots.style.display = 'none';
|
|
slotDatetime.value = '';
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
selectedSlotBtn = null;
|
|
|
|
fetch('/schedule/' + slug + '/available-slots', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
method: 'call',
|
|
params: {
|
|
selected_date: date,
|
|
appointment_type_id: parseInt(typeId),
|
|
},
|
|
}),
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
slotsLoading.style.display = 'none';
|
|
var result = data.result || {};
|
|
var slots = result.slots || [];
|
|
|
|
if (!slots.length) {
|
|
noSlots.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
var morningSlots = [];
|
|
var afternoonSlots = [];
|
|
slots.forEach(function (s) {
|
|
var hour = parseInt(s.start_hour);
|
|
if (isNaN(hour)) {
|
|
var match = s.start_hour.match(/(\d+)/);
|
|
hour = match ? parseInt(match[1]) : 0;
|
|
if (s.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12;
|
|
if (s.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0;
|
|
}
|
|
(hour < 12 ? morningSlots : afternoonSlots).push(s);
|
|
});
|
|
|
|
function renderGroup(label, icon, group) {
|
|
if (!group.length) return;
|
|
var h = document.createElement('div');
|
|
h.className = 'w-100 mt-2 mb-1';
|
|
h.innerHTML = '<small class="text-muted fw-semibold"><i class="fa ' + icon + ' me-1"></i>' + label + '</small>';
|
|
slotsGrid.appendChild(h);
|
|
group.forEach(function (s) {
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'btn btn-outline-primary btn-sm';
|
|
btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;';
|
|
btn.textContent = s.start_hour;
|
|
btn.addEventListener('click', function () {
|
|
if (selectedSlotBtn) {
|
|
selectedSlotBtn.classList.remove('btn-primary');
|
|
selectedSlotBtn.classList.add('btn-outline-primary');
|
|
}
|
|
btn.classList.remove('btn-outline-primary');
|
|
btn.classList.add('btn-primary');
|
|
selectedSlotBtn = btn;
|
|
slotDatetime.value = s.datetime;
|
|
slotDuration.value = s.duration;
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
});
|
|
slotsGrid.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
renderGroup('Morning', 'fa-sun-o', morningSlots);
|
|
renderGroup('Afternoon', 'fa-cloud', afternoonSlots);
|
|
})
|
|
.catch(function () {
|
|
slotsLoading.style.display = 'none';
|
|
noSlots.textContent = 'Failed to load slots. Please try again.';
|
|
noSlots.style.display = 'block';
|
|
});
|
|
}
|
|
|
|
if (dateInput) {
|
|
dateInput.addEventListener('change', function () { fetchSlots(this.value); });
|
|
}
|
|
if (typeSelect) {
|
|
typeSelect.addEventListener('change', function () {
|
|
if (dateInput && dateInput.value) fetchSlots(dateInput.value);
|
|
});
|
|
}
|
|
|
|
var form = document.getElementById('publicBookingForm');
|
|
if (form) {
|
|
form.addEventListener('submit', function (e) {
|
|
if (!slotDatetime.value) { e.preventDefault(); alert('Please select a time slot.'); return; }
|
|
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Booking...'; }
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
<t t-if="google_maps_api_key">
|
|
<script>
|
|
function initPublicAddressAutocomplete() {
|
|
var streetInput = document.getElementById('publicClientStreet');
|
|
if (!streetInput || typeof google === 'undefined') return;
|
|
var autocomplete = new google.maps.places.Autocomplete(streetInput, {
|
|
types: ['address'],
|
|
componentRestrictions: { country: 'ca' },
|
|
fields: ['address_components', 'geometry'],
|
|
});
|
|
autocomplete.addListener('place_changed', function () {
|
|
var place = autocomplete.getPlace();
|
|
if (!place.address_components) return;
|
|
var streetNumber = '', streetName = '', city = '', province = '', postalCode = '';
|
|
place.address_components.forEach(function (c) {
|
|
var t = c.types;
|
|
if (t.indexOf('street_number') > -1) streetNumber = c.long_name;
|
|
if (t.indexOf('route') > -1) streetName = c.long_name;
|
|
if (t.indexOf('locality') > -1) city = c.long_name;
|
|
if (t.indexOf('administrative_area_level_1') > -1) province = c.short_name;
|
|
if (t.indexOf('postal_code') > -1) postalCode = c.long_name;
|
|
});
|
|
streetInput.value = (streetNumber + ' ' + streetName).trim();
|
|
var ci = document.getElementById('publicClientCity');
|
|
if (ci) ci.value = city;
|
|
var pr = document.getElementById('publicClientProvince');
|
|
if (pr) pr.value = province;
|
|
var po = document.getElementById('publicClientPostal');
|
|
if (po) po.value = postalCode;
|
|
if (place.geometry && place.geometry.location) {
|
|
var la = document.getElementById('publicClientLat');
|
|
var ln = document.getElementById('publicClientLng');
|
|
if (la) la.value = place.geometry.location.lat();
|
|
if (ln) ln.value = place.geometry.location.lng();
|
|
}
|
|
});
|
|
}
|
|
</script>
|
|
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&libraries=places&callback=initPublicAddressAutocomplete"
|
|
async="async" defer="defer"></script>
|
|
</t>
|
|
</t>
|
|
</template>
|
|
|
|
<!-- ==================== PUBLIC MANAGE PAGE ==================== -->
|
|
|
|
<template id="public_manage_page" name="Manage Your Appointment">
|
|
<t t-call="website.layout">
|
|
<div class="container py-5" style="max-width: 600px;">
|
|
|
|
<!-- Header -->
|
|
<div class="text-center mb-4">
|
|
<div class="d-inline-flex align-items-center justify-content-center rounded-circle mb-3"
|
|
style="width: 64px; height: 64px; background: linear-gradient(135deg, #5ba848, #3a8fb7);">
|
|
<i class="fa fa-calendar-check-o text-white" style="font-size: 28px;"/>
|
|
</div>
|
|
<h2 class="mb-1">Your Appointment</h2>
|
|
<p class="text-muted">Manage your booking below</p>
|
|
</div>
|
|
|
|
<!-- Cancelled state -->
|
|
<t t-if="cancelled">
|
|
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
|
<div class="mb-3">
|
|
<i class="fa fa-times-circle text-danger" style="font-size: 48px;"/>
|
|
</div>
|
|
<h4 class="mb-2">Appointment Cancelled</h4>
|
|
<p class="text-muted mb-0">Your appointment has been cancelled.</p>
|
|
<t t-if="booking_slug">
|
|
<a t-attf-href="/schedule/#{booking_slug}" class="btn btn-primary mt-3">Book a New Appointment</a>
|
|
</t>
|
|
</div>
|
|
</t>
|
|
|
|
<!-- Rescheduled state -->
|
|
<t t-elif="rescheduled">
|
|
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
|
<div class="mb-3">
|
|
<i class="fa fa-check-circle text-success" style="font-size: 48px;"/>
|
|
</div>
|
|
<h4 class="mb-2">Appointment Rescheduled</h4>
|
|
<p class="text-muted mb-3">Your appointment has been moved to the new time.</p>
|
|
<t t-if="event">
|
|
<div class="bg-light rounded-3 p-3 d-inline-block mx-auto">
|
|
<strong><t t-out="event.start.astimezone(user_tz).strftime('%A, %B %d, %Y')"/></strong>
|
|
<br/>
|
|
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
|
|
</div>
|
|
</t>
|
|
</div>
|
|
</t>
|
|
|
|
<!-- Active appointment -->
|
|
<t t-elif="event">
|
|
<t t-if="error">
|
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
|
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
|
|
</div>
|
|
</t>
|
|
|
|
<!-- Appointment details card -->
|
|
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
|
<div class="card-body p-4">
|
|
<h5 class="mb-3"><t t-out="event.name"/></h5>
|
|
<div class="row g-3">
|
|
<div class="col-6">
|
|
<small class="text-muted d-block">Date</small>
|
|
<strong><t t-out="event.start.astimezone(user_tz).strftime('%A, %b %d, %Y')"/></strong>
|
|
</div>
|
|
<div class="col-6">
|
|
<small class="text-muted d-block">Time</small>
|
|
<strong><t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/></strong>
|
|
</div>
|
|
<t t-if="event.location">
|
|
<div class="col-12">
|
|
<small class="text-muted d-block">Location</small>
|
|
<span><t t-out="event.location"/></span>
|
|
</div>
|
|
</t>
|
|
<div class="col-6">
|
|
<small class="text-muted d-block">Duration</small>
|
|
<span><t t-out="'%.0f' % (event.duration * 60)"/> minutes</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reschedule section -->
|
|
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
|
|
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
|
|
style="border-radius: 12px 12px 0 0; cursor: pointer;"
|
|
data-bs-toggle="collapse" data-bs-target="#rescheduleSection"
|
|
aria-expanded="false">
|
|
<h6 class="mb-0">
|
|
<i class="fa fa-clock-o me-2 text-primary"/>Reschedule
|
|
<i class="fa fa-chevron-down float-end text-muted" style="font-size: 12px;"/>
|
|
</h6>
|
|
</div>
|
|
<div class="collapse" id="rescheduleSection">
|
|
<div class="card-body px-4 pb-4">
|
|
<form t-attf-action="/schedule/manage/#{token}/reschedule" method="post"
|
|
id="publicRescheduleForm">
|
|
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">New Date</label>
|
|
<input type="date" class="form-control" id="publicRescheduleDate"
|
|
required="required"/>
|
|
</div>
|
|
<div id="publicRescheduleSlotsContainer" style="display: none;">
|
|
<label class="form-label fw-semibold">Available Slots</label>
|
|
<div id="publicRescheduleSlotsLoading" class="text-center py-2"
|
|
style="display: none;">
|
|
<div class="spinner-border spinner-border-sm text-primary me-2"
|
|
role="status"/>
|
|
Loading...
|
|
</div>
|
|
<div id="publicRescheduleSlotsGrid"
|
|
class="d-flex flex-wrap gap-2 mb-2"></div>
|
|
<div id="publicRescheduleNoSlots" class="text-muted py-2"
|
|
style="display: none;">
|
|
No slots available for this date.
|
|
</div>
|
|
</div>
|
|
<input type="hidden" name="slot_datetime"
|
|
id="publicRescheduleSlotDatetime"/>
|
|
<button type="submit" class="btn btn-primary mt-2"
|
|
id="publicRescheduleSubmit" disabled="disabled">
|
|
<i class="fa fa-check me-1"/> Confirm New Time
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cancel section -->
|
|
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
|
|
<div class="card-body p-4">
|
|
<form t-attf-action="/schedule/manage/#{token}/cancel" method="post"
|
|
id="publicCancelForm"
|
|
onsubmit="return confirm('Are you sure you want to cancel this appointment?');">
|
|
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
|
<button type="submit" class="btn btn-outline-danger w-100">
|
|
<i class="fa fa-times me-1"/> Cancel Appointment
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</t>
|
|
|
|
<!-- Event not found (already cancelled) -->
|
|
<t t-else="">
|
|
<div class="card border-0 shadow-sm text-center p-5" style="border-radius: 12px;">
|
|
<div class="mb-3">
|
|
<i class="fa fa-calendar-times-o text-muted" style="font-size: 48px;"/>
|
|
</div>
|
|
<h4 class="mb-2">Appointment Not Found</h4>
|
|
<p class="text-muted mb-0">This appointment may have been cancelled or the link is invalid.</p>
|
|
</div>
|
|
</t>
|
|
|
|
<div class="text-center mt-4">
|
|
<small class="text-muted">Powered by Fusion Schedule</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Public Reschedule JS -->
|
|
<t t-if="event and not cancelled and not rescheduled">
|
|
<script type="text/javascript">
|
|
(function () {
|
|
'use strict';
|
|
var token = '<t t-out="token"/>';
|
|
var dateInput = document.getElementById('publicRescheduleDate');
|
|
var container = document.getElementById('publicRescheduleSlotsContainer');
|
|
var grid = document.getElementById('publicRescheduleSlotsGrid');
|
|
var loading = document.getElementById('publicRescheduleSlotsLoading');
|
|
var noSlots = document.getElementById('publicRescheduleNoSlots');
|
|
var slotInput = document.getElementById('publicRescheduleSlotDatetime');
|
|
var submitBtn = document.getElementById('publicRescheduleSubmit');
|
|
var selectedBtn = null;
|
|
|
|
if (!dateInput) return;
|
|
|
|
dateInput.addEventListener('change', function () {
|
|
var date = this.value;
|
|
if (!date) return;
|
|
container.style.display = 'block';
|
|
loading.style.display = 'block';
|
|
grid.innerHTML = '';
|
|
noSlots.style.display = 'none';
|
|
slotInput.value = '';
|
|
submitBtn.disabled = true;
|
|
selectedBtn = null;
|
|
|
|
fetch('/schedule/manage/' + token + '/available-slots', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0', method: 'call',
|
|
params: { selected_date: date },
|
|
}),
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
loading.style.display = 'none';
|
|
var slots = (data.result || {}).slots || [];
|
|
if (!slots.length) { noSlots.style.display = 'block'; return; }
|
|
slots.forEach(function (s) {
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'btn btn-outline-primary btn-sm';
|
|
btn.style.cssText = 'min-width: 90px; border-radius: 8px; padding: 8px 12px;';
|
|
btn.textContent = s.start_hour;
|
|
btn.addEventListener('click', function () {
|
|
if (selectedBtn) {
|
|
selectedBtn.classList.remove('btn-primary');
|
|
selectedBtn.classList.add('btn-outline-primary');
|
|
}
|
|
btn.classList.remove('btn-outline-primary');
|
|
btn.classList.add('btn-primary');
|
|
selectedBtn = btn;
|
|
slotInput.value = s.datetime;
|
|
submitBtn.disabled = false;
|
|
});
|
|
grid.appendChild(btn);
|
|
});
|
|
})
|
|
.catch(function () {
|
|
loading.style.display = 'none';
|
|
noSlots.textContent = 'Failed to load slots.';
|
|
noSlots.style.display = 'block';
|
|
});
|
|
});
|
|
|
|
var form = document.getElementById('publicRescheduleForm');
|
|
if (form) {
|
|
form.addEventListener('submit', function (e) {
|
|
if (!slotInput.value) { e.preventDefault(); alert('Please select a time slot.'); }
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
</t>
|
|
</t>
|
|
</template>
|
|
|
|
</odoo>
|