diff --git a/website_appointment_booking/README.rst b/website_appointment_booking/README.rst new file mode 100644 index 00000000..453e6cba --- /dev/null +++ b/website_appointment_booking/README.rst @@ -0,0 +1,148 @@ +=========================== +Website Appointment Booking +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a6976acc808171b36c3dec72efe7ca27cb200a425579c9a390c2e78bb5898810 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcalendar-lightgray.png?logo=github + :target: https://github.com/OCA/calendar/tree/18.0/website_appointment_booking + :alt: OCA/calendar +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/calendar-18-0/calendar-18-0-website_appointment_booking + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/calendar&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds public appointment booking pages to the website, +powered by the OCA ``resource_booking`` module. + +Booking types can be published on the website with a customizable URL +slug. Visitors can browse available time slots on a monthly calendar and +book an appointment without logging in. + +Key features: + +- Public booking page at ``/book/`` for each published booking + type +- Monthly calendar showing available time slots based on resource + availability +- Server-rendered slot data -- no extra AJAX calls needed +- Automatic partner creation or reuse based on visitor email +- Calendar invitation sent to both parties upon confirmation +- Race condition handling when two visitors try to book the same slot + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +This module requires the ``resource_booking`` and ``website`` modules to +be installed. The ``resource_booking`` module is available from the OCA +Calendar repository. + +Configuration +============= + +Before publishing a booking type, ensure you have configured the +``resource_booking`` module: + +1. Create at least one **Resource** and **Resource Calendar** under + **Resource Bookings > Configuration**. +2. Create one or more **Resource Combinations** under **Resource + Bookings > Combinations**, linking resources to calendars. +3. Create a **Booking Type** under **Resource Bookings > Types** and + assign the combinations to it. + +To publish a booking type on the website: + +4. Open the booking type you want to publish. +5. In the **Website** section, check **Published on Website**. +6. Optionally customize the **Website Slug** (auto-generated from the + name). +7. Optionally add a **Website Description** that will appear on the + booking page. +8. The booking page is now accessible at ``/book/``. + +Usage +===== + +Once a booking type is published: + +1. Share the URL ``/book/`` with your clients or embed it on your + website. +2. Visitors see a monthly calendar with available days highlighted. +3. Clicking a day reveals the available time slots for that day. +4. Clicking a time slot shows a simple form asking for name and email. +5. Upon confirmation, a ``resource.booking`` record is created and + confirmed automatically, and calendar invitations are sent to both + parties. +6. If a slot is no longer available (race condition), the visitor is + redirected back to the calendar with an informative error message. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Ledo Enterprises LLC + +Contributors +------------ + +- `Ledo Enterprises LLC `__: + + - Don Kendall + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-dnplkndll| image:: https://github.com/dnplkndll.png?size=40px + :target: https://github.com/dnplkndll + :alt: dnplkndll + +Current `maintainer `__: + +|maintainer-dnplkndll| + +This module is part of the `OCA/calendar `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_appointment_booking/__init__.py b/website_appointment_booking/__init__.py new file mode 100644 index 00000000..0c932600 --- /dev/null +++ b/website_appointment_booking/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Ledo Enterprises LLC - Don Kendall +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models diff --git a/website_appointment_booking/__manifest__.py b/website_appointment_booking/__manifest__.py new file mode 100644 index 00000000..afacab4c --- /dev/null +++ b/website_appointment_booking/__manifest__.py @@ -0,0 +1,33 @@ +# Copyright 2025 Ledo Enterprises LLC - Don Kendall +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Website Appointment Booking", + "summary": "Public appointment booking pages for resource booking types", + "version": "18.0.1.0.0", + "development_status": "Beta", + "category": "Appointments", + "website": "https://github.com/OCA/calendar", + "author": "Ledo Enterprises LLC, Odoo Community Association (OCA)", + "maintainers": ["dnplkndll"], + "license": "AGPL-3", + "installable": True, + "depends": [ + "resource_booking", + "website", + "crm", + "mail", + ], + "data": [ + "security/ir.model.access.csv", + "data/mail_templates.xml", + "templates/booking.xml", + "views/resource_booking_type_views.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_appointment_booking/static/src/scss/booking.scss", + "website_appointment_booking/static/src/js/booking_page.esm.js", + ], + }, +} diff --git a/website_appointment_booking/controllers/__init__.py b/website_appointment_booking/controllers/__init__.py new file mode 100644 index 00000000..1a4b4448 --- /dev/null +++ b/website_appointment_booking/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Ledo Enterprises LLC - Don Kendall +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import main diff --git a/website_appointment_booking/controllers/main.py b/website_appointment_booking/controllers/main.py new file mode 100644 index 00000000..49f39365 --- /dev/null +++ b/website_appointment_booking/controllers/main.py @@ -0,0 +1,440 @@ +# Copyright 2025 Ledo Enterprises LLC - Don Kendall +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +import urllib.request +from datetime import datetime, timedelta, timezone + +import pytz +from dateutil.parser import isoparse +from dateutil.relativedelta import relativedelta +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.exceptions import ValidationError +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class WebsiteAppointmentBooking(http.Controller): + def _get_booking_type(self, slug): + """Look up a published booking type by its slug.""" + return ( + request.env["resource.booking.type"] + .sudo() + .search( + [("website_slug", "=", slug), ("website_published", "=", True)], + limit=1, + ) + ) + + @staticmethod + def _validate_tz(tz): + """Return ``tz`` if pytz can resolve it (incl. aliases), else None. + + ``pytz.all_timezones_set`` only contains the canonical names — + modern browsers can emit deprecated-but-valid aliases like + ``Asia/Calcutta``, ``America/Buenos_Aires``, ``Etc/GMT+5`` which + membership tests reject. ``pytz.timezone()`` accepts them all, + so use it as the validity probe. + """ + if not tz or not isinstance(tz, str): + return None + try: + pytz.timezone(tz) + except pytz.UnknownTimeZoneError: + return None + return tz + + def _create_phantom_booking(self, booking_type, tz=None): + """Create an in-memory booking for slot computation. + + Uses ``new()`` to avoid writing to the database. The phantom booking + is configured with auto-assignment so that ``_get_available_slots`` + considers all resource combinations. ``tz`` controls the bucketing + timezone for the calendar grid; if absent it falls back to the + booking type's resource calendar timezone. + """ + Booking = request.env["resource.booking"].sudo() + if tz is None: + tz = booking_type.resource_calendar_id.tz or "UTC" + return Booking.with_context(tz=tz).new( + { + "type_id": booking_type.id, + "duration": booking_type.duration, + "combination_auto_assign": True, + } + ) + + @staticmethod + def _has_slots_in_visitor_window( + slots, visitor_tz, hours_start=9, hours_end=17, days=7 + ): + """Are any of the padded slots in the visitor's local working hours? + + Walks the slot list (which is already tz-aware, in + ``visitor_tz`` once the phantom booking is built with + ``with_context(tz=visitor_tz)``), and counts entries that fall + within ``hours_start``–``hours_end`` (visitor-local hours) + across the next ``days`` days from now. + + Returns True as soon as one such slot is found; the loop bails + early to keep this O(slots-until-first-match), not O(slots). + """ + try: + tz = pytz.timezone(visitor_tz) + except pytz.UnknownTimeZoneError: + return True # be conservative — never block when validation regresses + now = pytz.UTC.localize(datetime.utcnow()).astimezone(tz) + cutoff = now + timedelta(days=days) + for day_slots in slots.values(): + for slot_dt in day_slots: + local = slot_dt.astimezone(tz) + if now <= local < cutoff and hours_start <= local.hour < hours_end: + return True + return False + + def _serialize_slots(self, slots, time_format, effective_tz=None): + """Serialize slot data to a JSON-safe list of dicts. + + Each dict contains ``date``, ``time`` (display string) and ``iso`` + (full ISO 8601 value used for form submission). + + ``resource_booking._get_available_slots`` buckets by + ``test_start.date()`` using the booking's intrinsic tz (resource + calendar's). When the visitor selected a different tz via ``?tz=``, + we re-bucket in their tz here so the calendar grid reflects their + local dates. ``iso`` is normalized to UTC so it stays stable across + tz views — that lets clients compare slot identity regardless of + which view they're rendering. + """ + tz = pytz.timezone(effective_tz) if effective_tz else None + result = [] + for day, times in sorted(slots.items()): + for slot_dt in times: + if tz is not None and slot_dt.tzinfo is not None: + local = slot_dt.astimezone(tz) + result.append( + { + "date": local.date().isoformat(), + "time": local.strftime(time_format), + "iso": slot_dt.astimezone(pytz.UTC).isoformat(), + } + ) + else: + result.append( + { + "date": day.isoformat(), + "time": slot_dt.strftime(time_format), + "iso": slot_dt.isoformat(), + } + ) + return result + + @http.route( + [ + "/book/", + "/book///", + ], + auth="public", + type="http", + website=True, + sitemap=True, + ) + def booking_page(self, slug, year=None, month=None, error=None, **kwargs): + """Render the public booking page for a given booking type.""" + booking_type = self._get_booking_type(slug) + if not booking_type: + raise NotFound() + resource_tz = booking_type.resource_calendar_id.tz or "UTC" + # ``?tz=`` overrides the bucketing timezone for the calendar grid. + # Invalid values silently fall back to the resource tz. + effective_tz = self._validate_tz(kwargs.get("tz")) or resource_tz + phantom = self._create_phantom_booking(booking_type, tz=effective_tz) + calendar_ctx = phantom._get_calendar_context(year, month) + lang = calendar_ctx["res_lang"] + time_format = lang.time_format.replace(":%S", "") + + # Pad the slot fetch window by ±1 day so the JS client-side + # re-bucketer has neighbouring-day slots when the visitor's + # timezone shifts a slot across the month boundary. Without + # padding, a 23:30 ET slot on Mar 31 would bucket to Apr 1 in NZ + # but be missing if the visitor navigates to April. + start = calendar_ctx["start"] + booking_duration = timedelta(hours=booking_type.duration) + padded_start = start - timedelta(days=1) + padded_stop = ( + start + relativedelta(months=1) + booking_duration + timedelta(days=1) + ) + padded_slots = phantom._get_available_slots(padded_start, padded_stop) + slot_data = self._serialize_slots(padded_slots, time_format, effective_tz) + + # ``show_request_banner``: visitor is outside the resource tz AND + # has zero slots in their local working hours over the next week. + # Domestic visitors (same tz as resource) never see the banner. + has_window_slots = self._has_slots_in_visitor_window(padded_slots, effective_tz) + show_request_banner = effective_tz != resource_tz and not has_window_slots + + values = { + "booking_type": booking_type, + "slot_data": slot_data, + "slot_data_json": json.dumps(slot_data), + "error": error, + "resource_tz": resource_tz, + "effective_tz": effective_tz, + "show_request_banner": show_request_banner, + "request_success": kwargs.get("request_success") == "1", + } + values.update(calendar_ctx) + return request.render("website_appointment_booking.booking_page", values) + + @http.route( + "/book//confirm", + auth="public", + type="http", + website=True, + methods=["POST"], + csrf=True, + ) + def booking_confirm(self, slug, **kwargs): + """Process a booking confirmation. + + Expects POST parameters ``name``, ``email``, ``when`` (ISO 8601) and + an optional ``display_tz`` (IANA name) used to format the success + page in the visitor's timezone. Creates or finds the partner, + creates the booking, assigns the slot and confirms. + """ + booking_type = self._get_booking_type(slug) + if not booking_type: + raise NotFound() + name = (kwargs.get("name") or "").strip() + email = (kwargs.get("email") or "").strip() + when_str = kwargs.get("when", "") + if not name or not email or not when_str: + return request.redirect(f"/book/{slug}?error=Please+fill+in+all+fields.") + # Parse the submitted datetime + try: + when_tz_aware = isoparse(when_str) + except (ValueError, TypeError): + return request.redirect(f"/book/{slug}?error=Invalid+date+selected.") + # Convert to UTC-naive for Odoo storage. astimezone is exact — + # avoids the epoch float rounding of fromtimestamp(timestamp()). + when_naive = when_tz_aware.astimezone(timezone.utc).replace(tzinfo=None) + # Find or create partner + Partner = request.env["res.partner"].sudo() + partner = Partner.search([("email", "=ilike", email)], limit=1) + if not partner: + partner = Partner.create({"name": name, "email": email}) + elif not partner.name or partner.name == email: + partner.name = name + # Create and schedule the booking inside a savepoint so that + # a ValidationError (race condition: slot already taken) can be + # caught without poisoning the database cursor. + Booking = request.env["resource.booking"].sudo() + resource_tz = booking_type.resource_calendar_id.tz or "UTC" + try: + with request.env.cr.savepoint(): + booking = Booking.with_context( + tz=resource_tz, + using_portal=True, + mail_create_nosubscribe=True, + ).create( + { + "type_id": booking_type.id, + "partner_ids": [(4, partner.id)], + "combination_auto_assign": True, + } + ) + booking.start = when_naive + booking.action_confirm() + except ValidationError: + # Race condition: slot was taken between page load and submit. + # The month-nav path is anchored to resource-tz months. + resource_when = when_tz_aware.astimezone(pytz.timezone(resource_tz)) + month_str = f"{resource_when:%Y/%m}" + return request.redirect( + f"/book/{slug}/{month_str}" + "?error=That+slot+is+no+longer+available.+Please+choose+another." + ) + # Re-derive the success-page strings from the canonical UTC instant + # using the validated display tz. This blocks a malicious client + # from submitting e.g. ``when=…+09:00`` to make the success page + # show a misleading time. + display_tz = self._validate_tz(kwargs.get("display_tz")) or resource_tz + display_dt = when_naive.replace(tzinfo=timezone.utc).astimezone( + pytz.timezone(display_tz) + ) + request.session["last_booking"] = { + "name": booking_type.name, + "start": display_dt.strftime("%B %d, %Y"), + "time": display_dt.strftime("%H:%M"), + "tz": display_tz, + "duration": booking_type.duration, + "location": booking_type.location or "", + } + return request.redirect(f"/book/{slug}/success") + + @http.route( + "/book//success", + auth="public", + type="http", + website=True, + ) + def booking_success(self, slug, **kwargs): + """Thank-you page after a successful booking.""" + booking_type = self._get_booking_type(slug) + if not booking_type: + raise NotFound() + last_booking = request.session.pop("last_booking", {}) + values = { + "booking_type": booking_type, + "last_booking": last_booking, + } + return request.render("website_appointment_booking.booking_success", values) + + # --- Out-of-hours request flow ----------------------------------------- + + _REQUEST_TAG = "intl-after-hours-request" + + def _resolve_request_tag(self): + """Look up or create the CRM tag attached to every request lead.""" + Tag = request.env["crm.tag"].sudo() + tag = Tag.search([("name", "=", self._REQUEST_TAG)], limit=1) + if not tag: + tag = Tag.create({"name": self._REQUEST_TAG}) + return tag + + def _publish_ntfy(self, lead, booking_type, visitor_tz, preferred_window): + """Best-effort push to the ntfy topic stored in ir.config_parameter. + + Reads three params: ``ntfy.base_url`` (defaults to the self-hosted + instance), ``ntfy.topic`` (the bot's write-only topic), and + ``ntfy.token`` (the bearer token). Missing topic → silent no-op + (this matches the website tier's ntfy.ts behavior). Failures are + logged and swallowed: the user-visible flow must never block on + a notification side-channel. + """ + icp = request.env["ir.config_parameter"].sudo() + topic = icp.get_param("ntfy.topic") + if not topic: + return + base = icp.get_param("ntfy.base_url", "https://ntfy.hz.ledoweb.com") + token = icp.get_param("ntfy.token") + + # Strip CR/LF from any user-supplied text that flows into HTTP + # headers so we don't get header injection via the visitor name. + def _safe_header(value, max_len=200): + if not value: + return "" + cleaned = "".join( + c if c >= " " and c != "\r" and c != "\n" else " " for c in str(value) + ).strip() + return cleaned[:max_len] + + title = _safe_header( + f"🌏 Out-of-hours request: {lead.contact_name or lead.email_from}", 100 + ) + body = ( + f"{lead.contact_name or '(no name)'} <{lead.email_from}>\n" + f"For: {booking_type.name}\n" + f"Visitor TZ: {visitor_tz}\n" + f"Preferred window: {preferred_window or '(none)'}" + ) + click = f"{request.httprequest.host_url.rstrip('/')}/odoo/crm/{lead.id}" + + url = f"{base.rstrip('/')}/{topic}" + headers = { + "Title": title, + "Tags": "earth_asia,inbox_tray", + "Priority": "4", + "Click": _safe_header(click, 512), + } + if token: + headers["Authorization"] = f"Bearer {token}" + + try: + req = urllib.request.Request( + url, + data=body.encode("utf-8"), + headers=headers, + method="POST", + ) + urllib.request.urlopen(req, timeout=3) + except Exception: # pylint: disable=broad-except + # fire-and-forget; never break the request + _logger.warning("ntfy publish failed", exc_info=True) + + @http.route( + "/book//request", + auth="public", + type="http", + website=True, + methods=["POST"], + csrf=True, + ) + def booking_request(self, slug, **kwargs): + """Submit an out-of-hours booking request. + + Creates a tagged ``crm.lead`` (no booking — the operator opens a + slot manually + replies), fires a high-priority ntfy push, sends + a confirmation email to the visitor, and redirects back to the + booking page with a success banner. + """ + booking_type = self._get_booking_type(slug) + if not booking_type: + raise NotFound() + name = (kwargs.get("name") or "").strip() + email = (kwargs.get("email") or "").strip() + preferred = (kwargs.get("preferred_window") or "").strip() + note = (kwargs.get("note") or "").strip() + visitor_tz = self._validate_tz(kwargs.get("visitor_tz")) or "UTC" + + if not name or not email: + return request.redirect(f"/book/{slug}?error=Name+and+email+are+required.") + + Partner = request.env["res.partner"].sudo() + partner = Partner.search([("email", "=ilike", email)], limit=1) + if not partner: + partner = Partner.create({"name": name, "email": email}) + elif not partner.name or partner.name == email: + partner.name = name + + tag = self._resolve_request_tag() + description = ( + f"Visitor requested an out-of-hours slot.\n\n" + f"For: {booking_type.name}\n" + f"Visitor timezone: {visitor_tz}\n" + f"Preferred window: {preferred or '(none specified)'}\n\n" + f"Notes:\n{note or '(none)'}" + ) + Lead = request.env["crm.lead"].sudo() + lead = Lead.create( + { + "name": f"[out-of-hours] {booking_type.name} — {name}", + "contact_name": name, + "email_from": email, + "partner_id": partner.id, + "type": "lead", + "description": description, + "tag_ids": [(4, tag.id)], + } + ) + + # Best-effort: notify Don via ntfy, send the visitor a confirmation. + self._publish_ntfy(lead, booking_type, visitor_tz, preferred) + + try: + template = request.env.ref( + "website_appointment_booking.mail_template_booking_request_confirmation" + ).sudo() + template.send_mail(lead.id, force_send=False) + except (ValueError, Exception): # pylint: disable=broad-except + _logger.warning( + "booking-request confirmation email failed for lead %s", + lead.id, + exc_info=True, + ) + + return request.redirect(f"/book/{slug}?request_success=1") diff --git a/website_appointment_booking/data/mail_templates.xml b/website_appointment_booking/data/mail_templates.xml new file mode 100644 index 00000000..02ff430c --- /dev/null +++ b/website_appointment_booking/data/mail_templates.xml @@ -0,0 +1,35 @@ + + + + + + Out-of-Hours Booking Request — Visitor Confirmation + + We received your booking request + {{ (object.user_id.email_formatted or user.email_formatted) }} + {{ object.email_from }} + +
+

Hi,

+

Thanks for submitting your booking request. None of our published times fit your timezone, so we're going to open a custom slot for you.

+

You'll hear from us within one business day with a couple of options.

+

If you need to reach out sooner, just reply to this email.

+

— The team at Ledoweb

+

+ This is an automated confirmation. Your request has been logged and assigned to our team. +

+
+
+ +
+
diff --git a/website_appointment_booking/models/__init__.py b/website_appointment_booking/models/__init__.py new file mode 100644 index 00000000..2115660d --- /dev/null +++ b/website_appointment_booking/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Ledo Enterprises LLC - Don Kendall +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import resource_booking_type diff --git a/website_appointment_booking/models/resource_booking_type.py b/website_appointment_booking/models/resource_booking_type.py new file mode 100644 index 00000000..f563153b --- /dev/null +++ b/website_appointment_booking/models/resource_booking_type.py @@ -0,0 +1,51 @@ +# Copyright 2025 Ledo Enterprises LLC - Don Kendall +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import re + +from odoo import api, fields, models + + +def _slugify(value): + """Convert a string to a URL-friendly slug.""" + value = (value or "").lower().strip() + value = re.sub(r"[^\w\s-]", "", value) + value = re.sub(r"[-\s]+", "-", value) + return value.strip("-") + + +class ResourceBookingType(models.Model): + _inherit = "resource.booking.type" + + website_published = fields.Boolean( + copy=False, + help="When checked, this booking type will be available on a public " + "booking page accessible without login.", + ) + website_slug = fields.Char( + compute="_compute_website_slug", + store=True, + readonly=False, + copy=False, + help="URL-friendly identifier used in the public booking page URL. " + "Auto-generated from the name, but can be customized.", + ) + website_description = fields.Html( + translate=True, + sanitize_attributes=False, + help="Introductory text displayed on the public booking page.", + ) + + _sql_constraints = [ + ( + "website_slug_unique", + "UNIQUE(website_slug)", + "The website slug must be unique.", + ), + ] + + @api.depends("name") + def _compute_website_slug(self): + for record in self: + if not record.website_slug and record.name: + record.website_slug = _slugify(record.name) diff --git a/website_appointment_booking/pyproject.toml b/website_appointment_booking/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/website_appointment_booking/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_appointment_booking/readme/CONFIGURE.md b/website_appointment_booking/readme/CONFIGURE.md new file mode 100644 index 00000000..476a4c86 --- /dev/null +++ b/website_appointment_booking/readme/CONFIGURE.md @@ -0,0 +1,18 @@ +Before publishing a booking type, ensure you have configured the +``resource_booking`` module: + +1. Create at least one **Resource** and **Resource Calendar** under + **Resource Bookings > Configuration**. +2. Create one or more **Resource Combinations** under + **Resource Bookings > Combinations**, linking resources to calendars. +3. Create a **Booking Type** under **Resource Bookings > Types** and + assign the combinations to it. + +To publish a booking type on the website: + +4. Open the booking type you want to publish. +5. In the **Website** section, check **Published on Website**. +6. Optionally customize the **Website Slug** (auto-generated from the name). +7. Optionally add a **Website Description** that will appear on the booking + page. +8. The booking page is now accessible at ``/book/``. diff --git a/website_appointment_booking/readme/CONTRIBUTORS.md b/website_appointment_booking/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..429e51ca --- /dev/null +++ b/website_appointment_booking/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Ledo Enterprises LLC](https://ledoweb.com): + + - Don Kendall diff --git a/website_appointment_booking/readme/DESCRIPTION.md b/website_appointment_booking/readme/DESCRIPTION.md new file mode 100644 index 00000000..8916d982 --- /dev/null +++ b/website_appointment_booking/readme/DESCRIPTION.md @@ -0,0 +1,15 @@ +This module adds public appointment booking pages to the website, powered by +the OCA `resource_booking` module. + +Booking types can be published on the website with a customizable URL slug. +Visitors can browse available time slots on a monthly calendar and book an +appointment without logging in. + +Key features: + +- Public booking page at `/book/` for each published booking type +- Monthly calendar showing available time slots based on resource availability +- Server-rendered slot data -- no extra AJAX calls needed +- Automatic partner creation or reuse based on visitor email +- Calendar invitation sent to both parties upon confirmation +- Race condition handling when two visitors try to book the same slot diff --git a/website_appointment_booking/readme/INSTALL.md b/website_appointment_booking/readme/INSTALL.md new file mode 100644 index 00000000..8dd8f4bc --- /dev/null +++ b/website_appointment_booking/readme/INSTALL.md @@ -0,0 +1,3 @@ +This module requires the `resource_booking` and `website` modules to be +installed. The `resource_booking` module is available from the OCA Calendar +repository. diff --git a/website_appointment_booking/readme/USAGE.md b/website_appointment_booking/readme/USAGE.md new file mode 100644 index 00000000..ecd76a7b --- /dev/null +++ b/website_appointment_booking/readme/USAGE.md @@ -0,0 +1,10 @@ +Once a booking type is published: + +1. Share the URL `/book/` with your clients or embed it on your website. +2. Visitors see a monthly calendar with available days highlighted. +3. Clicking a day reveals the available time slots for that day. +4. Clicking a time slot shows a simple form asking for name and email. +5. Upon confirmation, a `resource.booking` record is created and confirmed + automatically, and calendar invitations are sent to both parties. +6. If a slot is no longer available (race condition), the visitor is redirected + back to the calendar with an informative error message. diff --git a/website_appointment_booking/security/ir.model.access.csv b/website_appointment_booking/security/ir.model.access.csv new file mode 100644 index 00000000..6821edbe --- /dev/null +++ b/website_appointment_booking/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +website_appointment_booking_type_public,Public read on resource booking types,resource_booking.model_resource_booking_type,base.group_public,1,0,0,0 +website_appointment_booking_combination_public,Public read on resource booking combinations,resource_booking.model_resource_booking_combination,base.group_public,1,0,0,0 +website_appointment_booking_combination_rel_public,Public read on resource booking type combination relations,resource_booking.model_resource_booking_type_combination_rel,base.group_public,1,0,0,0 diff --git a/website_appointment_booking/static/description/icon.png b/website_appointment_booking/static/description/icon.png new file mode 100644 index 00000000..7d9a746d Binary files /dev/null and b/website_appointment_booking/static/description/icon.png differ diff --git a/website_appointment_booking/static/description/icon.svg b/website_appointment_booking/static/description/icon.svg new file mode 100644 index 00000000..1384bd44 --- /dev/null +++ b/website_appointment_booking/static/description/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/website_appointment_booking/static/description/index.html b/website_appointment_booking/static/description/index.html new file mode 100644 index 00000000..a77e3426 --- /dev/null +++ b/website_appointment_booking/static/description/index.html @@ -0,0 +1,491 @@ + + + + + +Website Appointment Booking + + + +
+

Website Appointment Booking

+ + +

Beta License: AGPL-3 OCA/calendar Translate me on Weblate Try me on Runboat

+

This module adds public appointment booking pages to the website, +powered by the OCA resource_booking module.

+

Booking types can be published on the website with a customizable URL +slug. Visitors can browse available time slots on a monthly calendar and +book an appointment without logging in.

+

Key features:

+
    +
  • Public booking page at /book/<slug> for each published booking +type
  • +
  • Monthly calendar showing available time slots based on resource +availability
  • +
  • Server-rendered slot data – no extra AJAX calls needed
  • +
  • Automatic partner creation or reuse based on visitor email
  • +
  • Calendar invitation sent to both parties upon confirmation
  • +
  • Race condition handling when two visitors try to book the same slot
  • +
+

Table of contents

+ +
+

Installation

+

This module requires the resource_booking and website modules to +be installed. The resource_booking module is available from the OCA +Calendar repository.

+
+
+

Configuration

+

Before publishing a booking type, ensure you have configured the +resource_booking module:

+
    +
  1. Create at least one Resource and Resource Calendar under +Resource Bookings > Configuration.
  2. +
  3. Create one or more Resource Combinations under Resource +Bookings > Combinations, linking resources to calendars.
  4. +
  5. Create a Booking Type under Resource Bookings > Types and +assign the combinations to it.
  6. +
+

To publish a booking type on the website:

+
    +
  1. Open the booking type you want to publish.
  2. +
  3. In the Website section, check Published on Website.
  4. +
  5. Optionally customize the Website Slug (auto-generated from the +name).
  6. +
  7. Optionally add a Website Description that will appear on the +booking page.
  8. +
  9. The booking page is now accessible at /book/<slug>.
  10. +
+
+
+

Usage

+

Once a booking type is published:

+
    +
  1. Share the URL /book/<slug> with your clients or embed it on your +website.
  2. +
  3. Visitors see a monthly calendar with available days highlighted.
  4. +
  5. Clicking a day reveals the available time slots for that day.
  6. +
  7. Clicking a time slot shows a simple form asking for name and email.
  8. +
  9. Upon confirmation, a resource.booking record is created and +confirmed automatically, and calendar invitations are sent to both +parties.
  10. +
  11. If a slot is no longer available (race condition), the visitor is +redirected back to the calendar with an informative error message.
  12. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ledo Enterprises LLC
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

dnplkndll

+

This module is part of the OCA/calendar project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/website_appointment_booking/static/src/js/booking_page.esm.js b/website_appointment_booking/static/src/js/booking_page.esm.js new file mode 100644 index 00000000..6cfcb80d --- /dev/null +++ b/website_appointment_booking/static/src/js/booking_page.esm.js @@ -0,0 +1,382 @@ +/* Copyright 2025 Ledo Enterprises LLC - Don Kendall + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +/** + * Public booking page interactivity. + * + * Uses Odoo's publicWidget system so that the widget initializes correctly + * with deferred/lazy asset loading in Odoo 18. Reads slot data from a + * hidden data attribute rendered server-side and handles day/slot + * selection without additional network requests. + * + * Timezone handling: slot ISO instants are absolute (carry an offset). The + * server renders day-buckets and time strings in the booking type's + * resource calendar timezone. If the visitor's browser timezone differs, + * this widget re-buckets the slots into visitor-local days and reformats + * the time strings — entirely client-side, no round-trip. An optional + * ``?tz=`` query param lets the server bucket in an explicit timezone + * (overrides browser detection); a dropdown lets the visitor pick. + */ + +/* eslint-env browser */ +import publicWidget from "@web/legacy/js/public/public_widget"; + +/** Build an ``Intl.DateTimeFormat`` keyed in the given timezone. */ +function _dateFormatterFor(tz) { + return new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} + +function _timeFormatterFor(tz) { + return new Intl.DateTimeFormat(undefined, { + timeZone: tz, + hour: "numeric", + minute: "2-digit", + }); +} + +/** Return the IANA timezone the browser thinks it's in. */ +function _detectBrowserTz() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; + } catch { + return ""; + } +} + +/** + * Out-of-hours request banner — the "Request a custom slot" CTA on the + * booking page when the visitor's timezone has no overlap with the + * published booking hours. Lives as a sibling of the main calendar + * widget so its toggle state is independent. + */ +publicWidget.registry.WebsiteAppointmentRequestBanner = publicWidget.Widget.extend({ + selector: ".o_wab_request_banner", + events: { + "click .o_wab_request_toggle": "_onToggle", + }, + + _onToggle(ev) { + const form = this.el.querySelector("#o_wab_request_form"); + const toggleBtn = ev.currentTarget; + if (!form) { + return; + } + const willShow = form.classList.contains("d-none"); + form.classList.toggle("d-none", !willShow); + // Disable the toggle button when the form is open so a second click + // doesn't collapse the form while the user is mid-fill. + toggleBtn.setAttribute("disabled", "disabled"); + toggleBtn.classList.add("d-none"); + // Move focus to the name field so keyboard users land in the form. + const nameInput = form.querySelector("#o_wab_request_name"); + if (nameInput) { + nameInput.focus(); + } + }, +}); + +publicWidget.registry.WebsiteAppointmentBooking = publicWidget.Widget.extend({ + selector: ".o_wab_calendar", + events: { + "click .o_wab_day_available": "_onDayClick", + "keydown .o_wab_day_available": "_onDayKeydown", + "click .o_wab_slot_btn": "_onSlotClick", + "change #o_wab_tz_select": "_onTzSelectChange", + "toggle .o_wab_description details": "_onDetailsToggle", + }, + + /** + * @override + */ + start() { + const dataEl = this.el.querySelector("#o_wab_slot_data"); + if (!dataEl) { + return this._super(...arguments); + } + this.allSlots = JSON.parse(dataEl.dataset.slots || "[]"); + this.panel = this.el.querySelector("#o_wab_slots_panel"); + this.slotsTitle = this.panel + ? this.panel.querySelector(".o_wab_slots_title") + : null; + this.slotsList = this.panel + ? this.panel.querySelector(".o_wab_slots_list") + : null; + this.form = this.el.querySelector("#o_wab_form"); + this.whenInput = this.el.querySelector("#o_wab_when"); + this.displayTzInput = this.el.querySelector("#o_wab_display_tz"); + this.selectedDisplay = this.el.querySelector("#o_wab_selected_display"); + this.slotsEmpty = this.el.querySelector("#o_wab_slots_empty"); + this.tzSelect = this.el.querySelector("#o_wab_tz_select"); + this.tzLabel = this.el.querySelector("#o_wab_tz_label"); + this.tzSecondary = this.el.querySelector("#o_wab_tz_secondary"); + + this.resourceTz = this.el.dataset.resourceTz || "UTC"; + this.effectiveTz = this.el.dataset.effectiveTz || this.resourceTz; + this.visitorTz = _detectBrowserTz(); + + // The tz the booking page actually displays. Either the server + // already bucketed in it (explicit ?tz=), or JS is about to + // re-bucket into the visitor's tz, or it's the resource tz fallback. + let displayTz = this.effectiveTz; + const hasExplicitTz = new URLSearchParams(window.location.search).has("tz"); + if (!hasExplicitTz && this.visitorTz && this.visitorTz !== this.effectiveTz) { + this._rebucketSlots(this.visitorTz); + this._updateTzLabel(this.visitorTz); + displayTz = this.visitorTz; + } + if (this.displayTzInput) { + this.displayTzInput.value = displayTz; + } + if (this.tzSelect) { + this._ensureOptionPresent(this.tzSelect, displayTz); + this.tzSelect.value = displayTz; + } + + // Build lookup: date string -> [{time, iso}] + this.slotsByDate = {}; + for (const slot of this.allSlots) { + (this.slotsByDate[slot.date] = this.slotsByDate[slot.date] || []).push( + slot + ); + } + return this._super(...arguments); + }, + + // ------------------------------------------------------------------------- + // Timezone re-bucketing + // ------------------------------------------------------------------------- + + /** + * Reassign each slot's ``date`` and ``time`` to the visitor's tz, then + * walk the calendar DOM to flip availability marks to match. + * + * Server padded the slot list by ±1 day so this re-bucket can pull in + * neighbouring-month edge slots without going off the rendered grid. + * + * @param {String} visitorTz IANA tz + */ + _rebucketSlots(visitorTz) { + const dfmt = _dateFormatterFor(visitorTz); + const tfmt = _timeFormatterFor(visitorTz); + for (const slot of this.allSlots) { + const instant = new Date(slot.iso); + slot.date = dfmt.format(instant); + slot.time = tfmt.format(instant); + } + // Rebuild date → slots map after the mutation + const byDate = {}; + for (const slot of this.allSlots) { + (byDate[slot.date] = byDate[slot.date] || []).push(slot); + } + // Walk every day cell; toggle availability + role/tabindex. + // + // We mark availability based on bucketed-date membership even on + // "out of month" cells (trailing days from prev/next month that + // render muted in the grid). Without this, padded slots that + // re-bucket onto an adjacent-month edge — e.g. a 23:30 ET Mar 31 + // slot landing on Apr 1 for an NZ visitor viewing the March page — + // get silently dropped because the original server template only + // set the availability class on in-month days. Also strip the + // ``text-muted`` styling when an adjacent cell gains availability + // so it doesn't look greyed-out to the visitor. + const dayCells = this.el.querySelectorAll(".o_wab_day[data-date]"); + for (const cell of dayCells) { + const hasSlots = Boolean(byDate[cell.dataset.date]); + cell.classList.toggle("o_wab_day_available", hasSlots); + const isInMonth = cell.dataset.inMonth === "1"; + if (hasSlots && !isInMonth) { + cell.classList.remove("text-muted"); + } else if (!hasSlots && !isInMonth) { + cell.classList.add("text-muted"); + } + if (hasSlots) { + cell.setAttribute("role", "button"); + cell.setAttribute("tabindex", "0"); + } else { + cell.removeAttribute("role"); + cell.removeAttribute("tabindex"); + } + } + }, + + _updateTzLabel(tz) { + if (this.tzLabel) { + this.tzLabel.textContent = tz; + } + if (this.tzSecondary && tz !== this.resourceTz) { + this.tzSecondary.classList.remove("d-none"); + } + }, + + _ensureOptionPresent(select, value) { + if (!value) { + return; + } + const exists = Array.from(select.options).some((o) => o.value === value); + if (!exists) { + const opt = select.ownerDocument.createElement("option"); + opt.value = value; + opt.textContent = value; + // Insert at the top so it's the first thing users see. + select.insertBefore(opt, select.firstChild); + } + }, + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + /** + * Handle click on an available calendar day. + * + * @param {Event} ev + */ + _onDayClick(ev) { + const td = ev.currentTarget; + const date = td.dataset.date; + if (!date) { + return; + } + // After re-bucket the lookup may be stale — recompute on demand + if (!this.slotsByDate[date]) { + const fresh = this.allSlots.filter((s) => s.date === date); + if (!fresh.length) { + return; + } + this.slotsByDate[date] = fresh; + } + + // Highlight selected day + this.el.querySelectorAll(".o_wab_day_selected").forEach((el) => { + el.classList.remove("o_wab_day_selected"); + }); + td.classList.add("o_wab_day_selected"); + + // Format the date for the slots panel header. Build the Date from + // an iso so the active tz applies; if no slots, fall back to the + // bare date string interpreted as local midnight. + const slotsForDay = this.slotsByDate[date]; + const activeTz = this.displayTzInput + ? this.displayTzInput.value + : this.effectiveTz; + let dateStr = ""; + if (slotsForDay && slotsForDay.length) { + dateStr = new Date(slotsForDay[0].iso).toLocaleDateString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + timeZone: activeTz || undefined, + }); + } else { + const dateObj = new Date(date + "T00:00:00"); + dateStr = dateObj.toLocaleDateString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + }); + } + + // Populate the slots panel + if (this.slotsTitle) { + this.slotsTitle.textContent = dateStr; + } + if (this.slotsList) { + this.slotsList.innerHTML = ""; + for (const slot of slotsForDay) { + const btn = this.el.ownerDocument.createElement("button"); + btn.type = "button"; + btn.className = "o_wab_slot_btn"; + btn.textContent = slot.time; + btn.dataset.iso = slot.iso; + btn.dataset.display = dateStr + " at " + slot.time; + this.slotsList.appendChild(btn); + } + } + if (this.panel) { + this.panel.classList.remove("d-none"); + } + // Hide the empty-state placeholder + if (this.slotsEmpty) { + this.slotsEmpty.classList.add("d-none"); + } + // Hide the booking form until a slot is picked + if (this.form) { + this.form.classList.add("d-none"); + } + // Scroll to the slots panel + if (this.panel) { + this.panel.scrollIntoView({behavior: "smooth", block: "nearest"}); + } + }, + + /** + * Keyboard activation on day cells — Enter or Space mirrors a click. + * + * @param {KeyboardEvent} ev + */ + _onDayKeydown(ev) { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + this._onDayClick(ev); + } + }, + + /** + * Handle click on a time slot button. + * + * @param {Event} ev + */ + _onSlotClick(ev) { + const btn = ev.currentTarget; + + // Highlight selected slot + if (this.panel) { + this.panel.querySelectorAll(".o_wab_slot_btn").forEach((el) => { + el.classList.remove("active"); + }); + } + btn.classList.add("active"); + + // Fill form hidden input + if (this.whenInput) { + this.whenInput.value = btn.dataset.iso; + } + if (this.selectedDisplay) { + this.selectedDisplay.textContent = btn.dataset.display; + } + if (this.form) { + this.form.classList.remove("d-none"); + this.form.scrollIntoView({behavior: "smooth", block: "nearest"}); + } + }, + + /** + * Tz dropdown change — reload with ``?tz=`` so the server + * buckets explicitly. ``URL`` preserves the current path (incl. the + * optional ``/year/month`` segments). + * + * @param {Event} ev + */ + _onDetailsToggle(ev) { + const opened = ev.currentTarget; + if (!opened.open) return; + this.el.querySelectorAll(".o_wab_description details").forEach((d) => { + if (d !== opened) d.removeAttribute("open"); + }); + }, + + _onTzSelectChange(ev) { + const tz = ev.currentTarget.value; + if (!tz) { + return; + } + const url = new URL(window.location.href); + url.searchParams.set("tz", tz); + window.location.assign(url.toString()); + }, +}); diff --git a/website_appointment_booking/static/src/scss/booking.scss b/website_appointment_booking/static/src/scss/booking.scss new file mode 100644 index 00000000..4e29ee29 --- /dev/null +++ b/website_appointment_booking/static/src/scss/booking.scss @@ -0,0 +1,479 @@ +/* Copyright 2025 Ledo Enterprises LLC - Don Kendall + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +// --------------------------------------------------------------------------- +// Variables — uses Bootstrap/Odoo theme tokens so site-wide branding applies +// --------------------------------------------------------------------------- +$wab-radius: 0.75rem; +$wab-radius-sm: 0.5rem; + +// --------------------------------------------------------------------------- +// Page wrapper +// --------------------------------------------------------------------------- +.o_wab_page { + min-height: 60vh; +} + +// --------------------------------------------------------------------------- +// Header card +// --------------------------------------------------------------------------- +.o_wab_header { + border: none; + border-radius: $wab-radius; + background: var(--o-gray-100, #f8f9fa); + border-left: 4px solid var(--o-brand-primary, #714b67); + + .o_wab_title { + font-weight: 700; + letter-spacing: -0.02em; + color: var(--o-body-color, #212529); + } + + .o_wab_description { + color: var(--o-gray-600, #6c757d); + line-height: 1.6; + } +} + +.o_wab_meta_pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.85rem; + border-radius: 2rem; + font-size: 0.82rem; + font-weight: 500; + background: var(--o-gray-200, #e9ecef); + color: var(--o-gray-700, #495057); + + i { + font-size: 0.78rem; + } +} + +// --------------------------------------------------------------------------- +// Out-of-hours request banner — shown when the visitor's tz has no slot +// overlap with the published booking calendar. +// --------------------------------------------------------------------------- +.o_wab_request_banner { + border-radius: $wab-radius; + background: linear-gradient( + 135deg, + rgba(var(--o-brand-primary-rgb, 113, 75, 103), 0.04), + rgba(var(--o-brand-primary-rgb, 113, 75, 103), 0.1) + ); + border: 1px solid rgba(var(--o-brand-primary-rgb, 113, 75, 103), 0.25); + + .o_wab_request_icon { + flex-shrink: 0; + width: 2rem; + height: 2rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(var(--o-brand-primary-rgb, 113, 75, 103), 0.15); + color: var(--o-brand-primary, #714b67); + font-size: 0.95rem; + } + + .o_wab_request_toggle { + border-radius: $wab-radius-sm; + font-weight: 500; + } + + .o_wab_request_form { + border-top: 1px solid rgba(0, 0, 0, 0.05); + padding-top: 1rem; + } +} + +.o_wab_success_banner { + border-radius: $wab-radius; + border-left: 4px solid var(--bs-success, #198754); + background: rgba(25, 135, 84, 0.06); +} + +// --------------------------------------------------------------------------- +// Calendar wrapper — two-column on desktop +// --------------------------------------------------------------------------- +.o_wab_calendar { + border-radius: $wab-radius; +} + +.o_wab_cal_grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + + @media (min-width: 768px) { + grid-template-columns: 1fr 280px; + } +} + +// --------------------------------------------------------------------------- +// Month navigation +// --------------------------------------------------------------------------- +.o_wab_month_nav { + user-select: none; + + .o_wab_month_label { + font-size: 1.1rem; + font-weight: 600; + letter-spacing: -0.01em; + } + + .o_wab_nav_btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.2rem; + height: 2.2rem; + border-radius: 50%; + border: 1px solid var(--o-gray-300, #dee2e6); + background: #fff; + color: var(--o-gray-600, #6c757d); + transition: all 0.15s ease; + text-decoration: none; + + &:hover { + border-color: var(--o-gray-500, #adb5bd); + color: var(--o-body-color, #212529); + background: var(--o-gray-100, #f8f9fa); + } + + i { + font-size: 0.75rem; + } + } +} + +// --------------------------------------------------------------------------- +// Calendar table +// --------------------------------------------------------------------------- +.o_wab_table { + border-collapse: separate; + border-spacing: 2px; + table-layout: fixed; + + th { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--o-gray-500, #adb5bd); + padding: 0.5rem 0.25rem; + border: none; + background: none; + } + + td { + border: none; + padding: 0; + vertical-align: middle; + text-align: center; + } + + // Day cell + .o_wab_day { + position: relative; + width: 2.5rem; + height: 2.5rem; + line-height: 2.5rem; + font-size: 0.88rem; + border-radius: 50%; + transition: all 0.15s ease; + + &.text-muted { + opacity: 0.3; + } + } + + // Available day + .o_wab_day_available { + cursor: pointer; + font-weight: 600; + color: var(--o-body-color, #212529); + + &::after { + content: ""; + position: absolute; + bottom: 3px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + background: var(--o-brand-primary, #714b67); + border-radius: 50%; + } + + &:hover { + background: var(--o-gray-200, #e9ecef); + } + + &:focus-visible { + outline: 2px solid var(--o-brand-primary, #714b67); + outline-offset: 2px; + background: var(--o-gray-200, #e9ecef); + } + } + + // Selected day + .o_wab_day_selected { + background: var(--o-brand-primary, #714b67) !important; + color: #fff !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + + &::after { + background: rgba(255, 255, 255, 0.8); + } + + &:hover { + color: #fff; + } + } +} + +// --------------------------------------------------------------------------- +// Slots panel (right column) +// --------------------------------------------------------------------------- +.o_wab_slots_panel { + border-radius: $wab-radius-sm; + background: var(--o-gray-100, #f8f9fa); + padding: 1.25rem; + + .o_wab_slots_title { + font-size: 0.9rem; + font-weight: 600; + color: var(--o-gray-700, #495057); + margin-bottom: 0.75rem; + padding-bottom: 0.6rem; + border-bottom: 1px solid var(--o-gray-200, #e9ecef); + } + + .o_wab_slots_list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } +} + +.o_wab_slot_btn { + min-width: 4.5rem; + padding: 0.4rem 0.75rem; + font-size: 0.82rem; + font-weight: 500; + border-radius: $wab-radius-sm; + border: 1px solid var(--o-gray-300, #dee2e6); + background: #fff; + color: var(--o-body-color, #212529); + transition: all 0.15s ease; + + &:hover { + border-color: var(--o-gray-500, #adb5bd); + background: var(--o-gray-100, #f8f9fa); + } + + &.active { + background: var(--o-brand-primary, #714b67); + border-color: var(--o-brand-primary, #714b67); + color: #fff; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + } +} + +// Placeholder when no day is selected +.o_wab_slots_empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 160px; + text-align: center; + color: var(--o-gray-400, #ced4da); + + i { + font-size: 1.8rem; + margin-bottom: 0.5rem; + } + + span { + font-size: 0.85rem; + } +} + +// --------------------------------------------------------------------------- +// Booking form +// --------------------------------------------------------------------------- +.o_wab_form { + border-radius: $wab-radius; + border: 1px solid var(--o-gray-200, #e9ecef); + background: #fff; + padding: 1.5rem; + + .o_wab_form_title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--o-gray-200, #e9ecef); + } + + .o_wab_selected_slot { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.85rem; + border-radius: $wab-radius-sm; + background: var(--o-gray-100, #f8f9fa); + color: var(--o-gray-700, #495057); + font-weight: 500; + font-size: 0.88rem; + + i { + font-size: 0.8rem; + color: var(--bs-success, #198754); + } + } + + .form-label { + font-size: 0.82rem; + font-weight: 500; + color: var(--o-gray-600, #6c757d); + } + + .form-control { + border-radius: $wab-radius-sm; + } + + .o_wab_submit_btn { + padding: 0.6rem 2rem; + border-radius: $wab-radius-sm; + font-weight: 600; + letter-spacing: 0.01em; + } +} + +// --------------------------------------------------------------------------- +// No-slots empty state +// --------------------------------------------------------------------------- +.o_wab_empty_month { + border-radius: $wab-radius; + background: var(--o-gray-100, #f8f9fa); + padding: 2.5rem 1.5rem; + text-align: center; + + i { + font-size: 2.5rem; + color: var(--o-gray-300, #dee2e6); + margin-bottom: 1rem; + } + + p { + color: var(--o-gray-600, #6c757d); + margin-bottom: 0.75rem; + } +} + +// --------------------------------------------------------------------------- +// Timezone selector + note +// --------------------------------------------------------------------------- +.o_wab_tz_control { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; +} + +.o_wab_tz_select { + max-width: 16rem; + font-size: 0.82rem; + border-radius: $wab-radius-sm; +} + +.o_wab_tz_note { + font-size: 0.78rem; + color: var(--o-gray-500, #adb5bd); + + #o_wab_tz_secondary { + font-size: 0.72rem; + color: var(--o-gray-400, #ced4da); + } +} + +// Tz badge on the success page next to the booking time +.o_wab_detail_tz { + display: inline-block; + margin-left: 0.4rem; + padding: 0.05rem 0.45rem; + border-radius: 0.6rem; + font-size: 0.7rem; + font-weight: 500; + color: var(--o-gray-600, #6c757d); + background: var(--o-gray-200, #e9ecef); + text-transform: none; + letter-spacing: 0; +} + +// --------------------------------------------------------------------------- +// Success page +// --------------------------------------------------------------------------- +.o_wab_success { + .o_wab_success_icon { + width: 5rem; + height: 5rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(25, 135, 84, 0.1); + color: var(--bs-success, #198754); + font-size: 2.2rem; + } + + .o_wab_detail_card { + border: none; + border-radius: $wab-radius; + background: var(--o-gray-100, #f8f9fa); + + .o_wab_detail_row { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.65rem 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--o-gray-200, #e9ecef); + } + + i { + width: 1.2rem; + text-align: center; + color: var(--o-gray-500, #adb5bd); + padding-top: 0.15rem; + } + + .o_wab_detail_label { + font-size: 0.78rem; + font-weight: 500; + color: var(--o-gray-500, #adb5bd); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .o_wab_detail_value { + font-weight: 500; + color: var(--o-body-color, #212529); + } + } + } +} + +// --------------------------------------------------------------------------- +// Error alert +// --------------------------------------------------------------------------- +.o_wab_error { + border-radius: $wab-radius-sm; + border-left: 4px solid var(--bs-danger, #dc3545); + background: rgba(220, 53, 69, 0.04); +} diff --git a/website_appointment_booking/templates/booking.xml b/website_appointment_booking/templates/booking.xml new file mode 100644 index 00000000..2cff0784 --- /dev/null +++ b/website_appointment_booking/templates/booking.xml @@ -0,0 +1,592 @@ + + + +