Files
Odoo-Modules/fusion_schedule/views/public_booking.xml
gsinghpal e56974d46f update
2026-03-16 08:14:56 -04:00

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 &amp; 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 &amp;&amp; hour !== 12) hour += 12;
if (s.start_hour.toLowerCase().indexOf('am') > -1 &amp;&amp; hour === 12) hour = 0;
}
(hour &lt; 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 = '&lt;small class="text-muted fw-semibold">&lt;i class="fa ' + icon + ' me-1">&lt;/i>' + label + '&lt;/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 &amp;&amp; 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 = '&lt;i class="fa fa-spinner fa-spin me-1">&lt;/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 &amp;&amp; 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}&amp;libraries=places&amp;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>