Changeset 3767446
- Timestamp:
- 10/26/2026 10:45:41 PM (2 days ago)
- Location:
- abientot
- Files:
-
- 34 added
- 30 edited
- 1 copied
-
tags/1.1.4 (copied) (copied from abientot/trunk )
-
tags/1.1.4/abientot.php (modified) (2 diffs)
-
tags/1.1.4/assets/admin/admin.css (modified) (2 diffs)
-
tags/1.1.4/assets/admin/admin.js (modified) (3 diffs)
-
tags/1.1.4/assets/admin/share-kit.js (added)
-
tags/1.1.4/assets/admin/vendor (added)
-
tags/1.1.4/assets/admin/vendor/qrcode-generator (added)
-
tags/1.1.4/assets/admin/vendor/qrcode-generator/LICENSE.txt (added)
-
tags/1.1.4/assets/admin/vendor/qrcode-generator/PROVENANCE.md (added)
-
tags/1.1.4/assets/admin/vendor/qrcode-generator/qrcode.js (added)
-
tags/1.1.4/assets/frontend/booking-form.css (modified) (8 diffs)
-
tags/1.1.4/assets/frontend/booking-form.js (modified) (12 diffs)
-
tags/1.1.4/includes/Admin/Admin.php (modified) (2 diffs)
-
tags/1.1.4/includes/Admin/BookingsManager.php (modified) (1 diff)
-
tags/1.1.4/includes/Admin/DashboardManager.php (modified) (1 diff)
-
tags/1.1.4/includes/Admin/ProBadge.php (modified) (4 diffs)
-
tags/1.1.4/includes/Admin/ReviewRequest.php (added)
-
tags/1.1.4/includes/Admin/ShareKit.php (added)
-
tags/1.1.4/includes/Admin/TodayManager.php (modified) (1 diff)
-
tags/1.1.4/includes/Database/Seeder.php (modified) (1 diff)
-
tags/1.1.4/includes/Frontend/BookingForm.php (modified) (9 diffs)
-
tags/1.1.4/includes/Plugin.php (modified) (2 diffs)
-
tags/1.1.4/includes/Reminders (added)
-
tags/1.1.4/includes/Reminders/Migrator.php (added)
-
tags/1.1.4/includes/Reminders/Module.php (added)
-
tags/1.1.4/includes/Reminders/Repository.php (added)
-
tags/1.1.4/includes/Reminders/Service.php (added)
-
tags/1.1.4/includes/Reminders/Settings.php (added)
-
tags/1.1.4/includes/Repositories/BookingPagesRepository.php (added)
-
tags/1.1.4/includes/Support/MultilingualText.php (added)
-
tags/1.1.4/includes/Support/WhatsApp.php (added)
-
tags/1.1.4/includes/Uninstaller.php (modified) (2 diffs)
-
tags/1.1.4/readme.txt (modified) (6 diffs)
-
trunk/abientot.php (modified) (2 diffs)
-
trunk/assets/admin/admin.css (modified) (2 diffs)
-
trunk/assets/admin/admin.js (modified) (3 diffs)
-
trunk/assets/admin/share-kit.js (added)
-
trunk/assets/admin/vendor (added)
-
trunk/assets/admin/vendor/qrcode-generator (added)
-
trunk/assets/admin/vendor/qrcode-generator/LICENSE.txt (added)
-
trunk/assets/admin/vendor/qrcode-generator/PROVENANCE.md (added)
-
trunk/assets/admin/vendor/qrcode-generator/qrcode.js (added)
-
trunk/assets/frontend/booking-form.css (modified) (8 diffs)
-
trunk/assets/frontend/booking-form.js (modified) (12 diffs)
-
trunk/includes/Admin/Admin.php (modified) (2 diffs)
-
trunk/includes/Admin/BookingsManager.php (modified) (1 diff)
-
trunk/includes/Admin/DashboardManager.php (modified) (1 diff)
-
trunk/includes/Admin/ProBadge.php (modified) (4 diffs)
-
trunk/includes/Admin/ReviewRequest.php (added)
-
trunk/includes/Admin/ShareKit.php (added)
-
trunk/includes/Admin/TodayManager.php (modified) (1 diff)
-
trunk/includes/Database/Seeder.php (modified) (1 diff)
-
trunk/includes/Frontend/BookingForm.php (modified) (9 diffs)
-
trunk/includes/Plugin.php (modified) (2 diffs)
-
trunk/includes/Reminders (added)
-
trunk/includes/Reminders/Migrator.php (added)
-
trunk/includes/Reminders/Module.php (added)
-
trunk/includes/Reminders/Repository.php (added)
-
trunk/includes/Reminders/Service.php (added)
-
trunk/includes/Reminders/Settings.php (added)
-
trunk/includes/Repositories/BookingPagesRepository.php (added)
-
trunk/includes/Support/MultilingualText.php (added)
-
trunk/includes/Support/WhatsApp.php (added)
-
trunk/includes/Uninstaller.php (modified) (2 diffs)
-
trunk/readme.txt (modified) (6 diffs)
Legend:
- Unmodified
- Added
- Removed
-
abientot/tags/1.1.4/abientot.php
r3764499 r3767446 2 2 3 3 /** 4 * Plugin Name: Abientot 4 * Plugin Name: Abientot – Appointment Booking 5 5 * Description: A local-first WordPress booking plugin for services, appointments, customers, calendars, and business scheduling. 6 * Version: 1.1. 36 * Version: 1.1.4 7 7 * Author: AbsoluDev 8 8 * Author URI: https://absolu.dev … … 77 77 define( 'ABIENTOT_EDITION', $abientot_edition ); 78 78 unset($abientot_edition); 79 define( 'ABIENTOT_VERSION', '1.1. 3' );79 define( 'ABIENTOT_VERSION', '1.1.4' ); 80 80 define( 'ABIENTOT_PLUGIN_FILE', __FILE__ ); 81 81 define( 'ABIENTOT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); -
abientot/tags/1.1.4/assets/admin/admin.css
r3764499 r3767446 103 103 } 104 104 105 .abientot-notification-stack .abientot-notification form p { 106 margin-top: 10px; 107 } 108 105 109 .abientot-notification-stack .abientot-notification.is-leaving { 106 110 opacity: 0; … … 6351 6355 font-weight: 650; 6352 6356 } 6357 6358 .abientot-whatsapp-link { 6359 margin-inline-start: 8px; 6360 } 6361 6362 .abientot-detail-list dd .abientot-whatsapp-link { 6363 margin-block-start: 4px; 6364 } 6365 6366 /* Share kit (dashboard card) */ 6367 .abientot-share-kit + .abientot-share-kit { border-top: 1px solid #dcdcde; margin-top: 12px; padding-top: 4px; } 6368 .abientot-share-kit a[data-abientot-share-url] { word-break: break-all; } 6369 .abientot-share-kit__actions .button { margin: 0 4px 4px 0; } 6370 .abientot-share-kit__qr { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; margin: 8px 0; } 6371 .abientot-share-kit__code { width: 180px; max-width: 100%; background: #fff; } 6372 .abientot-share-kit__code svg { display: block; width: 100%; height: auto; } 6373 .abientot-share-kit__tips ul { list-style: disc; margin-left: 1.5em; } -
abientot/tags/1.1.4/assets/admin/admin.js
r3764499 r3767446 250 250 251 251 function bindAdminNotifications() { 252 // Notices printed by admin_notices sit outside the app; those marked data-abientot-notice belong to it. 252 253 var notices = document.querySelectorAll( 253 '.abientot-admin .notice, .abientot-admin .updated, .abientot-admin .error '254 '.abientot-admin .notice, .abientot-admin .updated, .abientot-admin .error, .notice[data-abientot-notice]' 254 255 ); 255 256 var stack; … … 278 279 }; 279 280 280 notice.classList.add('abientot-notification', 'is-dismissible'); 281 // "inline" keeps WordPress (common.js) from moving the notice back under the page's 282 // screen-reader-only heading when it runs after this. 283 notice.classList.add('abientot-notification', 'is-dismissible', 'inline'); 281 284 if (!closeButton) { 282 285 closeButton = document.createElement('button'); … … 289 292 closeButton.addEventListener('click', removeNotice); 290 293 stack.appendChild(notice); 291 window.setTimeout(removeNotice, 5000); 294 // A notice that asks for a choice stays until it is answered or closed. 295 if (!notice.hasAttribute('data-abientot-persistent')) { 296 window.setTimeout(removeNotice, 5000); 297 } else { 298 notice.addEventListener('submit', function () { 299 window.setTimeout(removeNotice, 100); 300 }); 301 } 292 302 }); 293 303 } -
abientot/tags/1.1.4/assets/frontend/booking-form.css
r3764499 r3767446 36 36 --abientot-map-height: 220px; 37 37 --abientot-shadow: 0 18px 56px rgba(19, 31, 41, 0.06); 38 --abientot-soft: color-mix(in srgb, var(--abientot-primary) 6%, #fff); 38 /* Mixed with the form surface, not white: dark looks keep light text on their panels. */ 39 --abientot-soft: color-mix(in srgb, var(--abientot-primary) 6%, rgb(var(--abientot-surface-rgb))); 39 40 color: var(--abientot-text); 40 41 font-size: 16px; … … 183 184 } 184 185 186 /* Theme content styles for headings (boxes, bars, numbering) must not leak into the form. */ 187 .abientot-booking.abientot-booking h2, 188 .abientot-booking.abientot-booking h3 { 189 padding: 0; 190 border: 0; 191 border-radius: 0; 192 background: none; 193 box-shadow: none; 194 counter-increment: none; 195 } 196 197 .abientot-booking.abientot-booking h2::before, 198 .abientot-booking.abientot-booking h2::after, 199 .abientot-booking.abientot-booking h3::before, 200 .abientot-booking.abientot-booking h3::after { 201 content: none; 202 } 203 185 204 .abientot-booking__header h2 { 186 205 margin-block: 0 8px; … … 715 734 border: 1px solid var(--abientot-border); 716 735 border-radius: 6px; 717 background: #f8fafc;736 background: var(--abientot-soft); 718 737 } 719 738 … … 866 885 @media (max-width: 767px) { 867 886 .abientot-booking:not(.abientot-booking--popup).is-service-step.is-selection-docked .abientot-booking__surface { 868 padding-bottom: 112px;887 padding-bottom: calc(112px + var(--abientot-dock-offset, 0px)); 869 888 -webkit-backdrop-filter: none; 870 889 backdrop-filter: none; … … 876 895 877 896 .abientot-booking:not(.abientot-booking--popup).is-time-step.is-step-actions-docked .abientot-booking__surface { 878 padding-bottom: 88px;897 padding-bottom: calc(88px + var(--abientot-dock-offset, 0px)); 879 898 -webkit-backdrop-filter: none; 880 899 backdrop-filter: none; … … 933 952 top: auto; 934 953 right: 0; 935 bottom: 0;954 bottom: var(--abientot-dock-offset, 0px); 936 955 left: 0; 937 956 margin-top: 0; … … 1050 1069 z-index: 99998; 1051 1070 right: 0; 1052 bottom: 0;1071 bottom: var(--abientot-dock-offset, 0px); 1053 1072 left: 0; 1054 1073 margin: 0; … … 1207 1226 border-inline-start: 3px solid var(--abientot-primary); 1208 1227 border-radius: 6px; 1209 background: color-mix(in srgb, var(--abientot-primary) 6%, #fff);1228 background: var(--abientot-soft); 1210 1229 color: var(--abientot-text); 1211 1230 } -
abientot/tags/1.1.4/assets/frontend/booking-form.js
r3764499 r3767446 22 22 return document.createTextNode(value || ''); 23 23 } 24 25 /* 26 * Public requests carry the page's booking nonce. A page cache can serve that page long 27 * after the nonce expires (12-24 h), so a stale token is renewed from an uncached 28 * endpoint before sending, and a request refused for its token (403 "-1", nothing 29 * processed) is retried once with a fresh one. 30 */ 31 var nonceRefresh = null; 32 var nonceRetryAfter = 0; 33 var NONCE_MAX_AGE = 10 * 3600; 34 35 function refreshNonce() { 36 if (!nonceRefresh) { 37 var body = new FormData(); 38 body.set('action', 'abientot_public_nonce'); 39 nonceRefresh = fetch(abientotBooking.ajaxUrl, { 40 method: 'POST', 41 credentials: 'same-origin', 42 cache: 'no-store', 43 body: body 44 }) 45 .then(function (response) { 46 return response.json(); 47 }) 48 .then(function (response) { 49 if (!response || !response.success || !response.data || !response.data.nonce) { 50 throw new Error('nonce'); 51 } 52 abientotBooking.nonce = response.data.nonce; 53 // Device time, not server time: a skewed device clock must not keep the fresh nonce "stale". 54 abientotBooking.nonceTime = Math.floor(Date.now() / 1000); 55 }) 56 .then( 57 function () { 58 nonceRefresh = null; 59 }, 60 function () { 61 // Refresh refused (e.g. rate limit): send with the current nonce and wait a minute before retrying. 62 nonceRetryAfter = Date.now() + 60000; 63 nonceRefresh = null; 64 } 65 ); 66 } 67 return nonceRefresh; 68 } 69 70 function nonceIsStale() { 71 var issued = parseInt(abientotBooking.nonceTime, 10) || 0; 72 return issued > 0 && Date.now() >= nonceRetryAfter && Date.now() / 1000 - issued > NONCE_MAX_AGE; 73 } 74 75 // Pro handlers that verify the nonce themselves answer {"success":false,"data":{"code":"nonce"}}. 76 function nonceRefusal(body) { 77 try { 78 var parsed = JSON.parse(body); 79 return !!(parsed && parsed.data && 'nonce' === parsed.data.code); 80 } catch (error) { 81 return false; 82 } 83 } 84 85 function bookingPost(data, signal) { 86 function send() { 87 data.set('nonce', abientotBooking.nonce); 88 if (abientotBooking.lang && !data.has('lang')) { 89 data.set('lang', abientotBooking.lang); 90 } 91 return fetch(abientotBooking.ajaxUrl, { 92 method: 'POST', 93 credentials: 'same-origin', 94 signal: signal, 95 body: data 96 }); 97 } 98 return (nonceIsStale() ? refreshNonce().then(send) : send()).then(function (response) { 99 if (403 !== response.status) { 100 return response; 101 } 102 return response 103 .clone() 104 .text() 105 .then(function (body) { 106 return '-1' === body.trim() || nonceRefusal(body) ? refreshNonce().then(send) : response; 107 }); 108 }); 109 } 110 111 window.abientotBookingPost = bookingPost; 24 112 25 113 function message(form, value, isError) { … … 134 222 } 135 223 224 /* 225 * Themes often pin a menu bar to the bottom of phone screens (Cocoon's footer buttons, for 226 * example). The docked selection and step buttons sit above such a bar instead of under it. 227 */ 228 function updateDockOffset(root) { 229 var offset = 0; 230 if (document.elementsFromPoint && !root.classList.contains('abientot-booking--popup')) { 231 document.elementsFromPoint(window.innerWidth / 2, window.innerHeight - 2).some(function (element) { 232 if (root.contains(element)) { 233 return false; 234 } 235 for (var node = element; node && node !== document.body; node = node.parentElement) { 236 if ('fixed' === window.getComputedStyle(node).position) { 237 var rect = node.getBoundingClientRect(); 238 if (rect.bottom >= window.innerHeight - 2 && rect.top > window.innerHeight / 2) { 239 offset = Math.ceil(window.innerHeight - rect.top); 240 return true; 241 } 242 return false; 243 } 244 } 245 return false; 246 }); 247 } 248 root.style.setProperty('--abientot-dock-offset', offset + 'px'); 249 } 250 136 251 function updateMobileSelectionDock(root) { 137 252 var services = root.querySelector('.abientot-booking__services'); … … 140 255 var rect; 141 256 257 updateDockOffset(root); 258 142 259 if (!isMobile || !services || !selection || selection.hidden || !root.classList.contains('is-service-step')) { 143 260 root.classList.remove('is-selection-docked'); … … 159 276 var isMobile = window.matchMedia('(max-width: 767px)').matches; 160 277 var rect; 278 279 updateDockOffset(root); 161 280 162 281 if (!isMobile || !panel || !slots || !root.classList.contains('is-time-step')) { … … 274 393 slots.textContent = searchNext ? abientotBooking.strings.searchingDates : abientotBooking.strings.loading; 275 394 data.append('action', 'abientot_public_slots'); 276 data.append('nonce', abientotBooking.nonce);277 395 data.append('service_id', service.value); 278 396 root.dispatchEvent(new CustomEvent('abientot:availability-request', { detail: { data: data } })); … … 303 421 } 304 422 function request() { 305 return fetch(abientotBooking.ajaxUrl, { 306 method: 'POST', 307 credentials: 'same-origin', 308 signal: controller ? controller.signal : undefined, 309 body: data 310 }) 423 return bookingPost(data, controller ? controller.signal : undefined) 311 424 .then(function (response) { 312 425 return response.json(); … … 528 641 var data = seriesData(root); 529 642 data.set('action', 'abientot_customer_series_preview'); 530 data.set('nonce', abientotBooking.nonce); 531 fetch(abientotBooking.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data }) 643 bookingPost(data) 532 644 .then(function (response) { 533 645 return response.json(); … … 638 750 639 751 data.append('action', 'abientot_public_book'); 640 data.append('nonce', abientotBooking.nonce);641 752 submit.disabled = true; 642 753 message(form, '', false); 643 754 644 fetch(abientotBooking.ajaxUrl, { 645 method: 'POST', 646 credentials: 'same-origin', 647 body: data 648 }) 755 bookingPost(data) 649 756 .then(function (response) { 650 757 return response.json(); … … 1071 1178 var data = new FormData(); 1072 1179 data.set('action', 'abientot_public_slots'); 1073 data.set('nonce', abientotBooking.nonce);1074 1180 data.set('service_id', service.value); 1075 1181 data.set('date', isoDate(month)); … … 1087 1193 data.set('location_id', location.value); 1088 1194 } 1089 fetch(abientotBooking.ajaxUrl, { 1090 method: 'POST', 1091 credentials: 'same-origin', 1092 body: data, 1093 signal: controller ? controller.signal : undefined 1094 }) 1195 bookingPost(data, controller ? controller.signal : undefined) 1095 1196 .then(function (response) { 1096 1197 return response.json(); … … 1438 1539 1439 1540 if (open && close && surface) { 1541 // Safety net: focus that leaves the open dialog (for example through a theme script) comes back. 1542 document.addEventListener('focusin', function (event) { 1543 if (!surface.hidden && !surface.contains(event.target) && event.target !== open) { 1544 var target = surface.querySelector('[data-abientot-popup-close]'); 1545 if (target) { 1546 target.focus(); 1547 } 1548 } 1549 }); 1440 1550 surface.addEventListener('keydown', function (event) { 1441 1551 if (event.key === 'Escape') { … … 1453 1563 ).filter(function (item) { 1454 1564 return item.tabIndex >= 0 && item.getClientRects().length > 0 && !item.closest('[hidden]'); 1565 }); 1566 // A radio group is one tab stop: keep only its focused, checked or first radio, as browsers do. 1567 var stops = {}; 1568 items.forEach(function (item) { 1569 if ('radio' !== item.type || !item.name) { 1570 return; 1571 } 1572 var current = stops[item.name]; 1573 if ( 1574 !current || 1575 item === document.activeElement || 1576 (current !== document.activeElement && item.checked && !current.checked) 1577 ) { 1578 stops[item.name] = item; 1579 } 1580 }); 1581 items = items.filter(function (item) { 1582 return 'radio' !== item.type || !item.name || stops[item.name] === item; 1455 1583 }); 1456 1584 var first = items[0]; -
abientot/tags/1.1.4/includes/Admin/Admin.php
r3764499 r3767446 57 57 $this->admin_data_manager->register(); 58 58 RecordPicker::register(); 59 ReviewRequest::register(); 59 60 add_action( 'admin_post_abientot_save_settings', array( $this->settings, 'save' ) ); 60 61 add_action( 'admin_post_abientot_form_create', array( $this->form_manager, 'create_form' ) ); … … 133 134 wp_enqueue_style( 'abientot-workspace', ABIENTOT_PLUGIN_URL . 'assets/admin/workspace.css', array( 'abientot-admin' ), $this->asset_version( 'assets/admin/workspace.css' ) ); 134 135 wp_enqueue_script( 'abientot-workspace', ABIENTOT_PLUGIN_URL . 'assets/admin/workspace.js', array( 'abientot-admin' ), $this->asset_version( 'assets/admin/workspace.js' ), true ); 136 wp_enqueue_script( 'abientot-qrcode', ABIENTOT_PLUGIN_URL . 'assets/admin/vendor/qrcode-generator/qrcode.js', array(), '1.4.4', true ); 137 wp_enqueue_script( 'abientot-share-kit', ABIENTOT_PLUGIN_URL . 'assets/admin/share-kit.js', array( 'abientot-qrcode' ), $this->asset_version( 'assets/admin/share-kit.js' ), true ); 135 138 136 139 wp_enqueue_style( -
abientot/tags/1.1.4/includes/Admin/BookingsManager.php
r3764499 r3767446 1228 1228 <dl class="abientot-detail-list"> 1229 1229 <dt><?php esc_html_e( 'When', 'abientot' ); ?></dt><dd><?php echo esc_html( $this->local_datetime_label( (string) $booking['start_utc'], (string) $booking['timezone'] ) ); ?></dd> 1230 <dt><?php esc_html_e( 'Customer', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['customer_name'] ); ?>< /dd>1230 <dt><?php esc_html_e( 'Customer', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['customer_name'] ); ?><?php echo wp_kses( \Abientot\Support\WhatsApp::button( (string) ( $booking['customer_phone'] ?? '' ), (string) ( $booking['first_name'] ?? '' ) !== '' ? (string) $booking['first_name'] : (string) $booking['customer_name'], $this->local_datetime_label( (string) $booking['start_utc'], (string) $booking['timezone'] ) ), array( 'a' => array( 'class' => true, 'href' => true, 'target' => true, 'rel' => true ) ) ); ?></dd> 1231 1231 <dt><?php esc_html_e( 'Service', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['service_name'] ); ?></dd> 1232 1232 <dt><?php esc_html_e( 'Notes', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['internal_note'] ); ?></dd> -
abientot/tags/1.1.4/includes/Admin/DashboardManager.php
r3764499 r3767446 162 162 ); 163 163 } 164 } 165 if ( $context->owner && current_user_can( 'abientot_manage_settings' ) ) { 166 $cards['share'] = array( 167 'title' => __( 'Share your booking page', 'abientot' ), 168 'description' => __( 'Your public booking link, a QR code and where to post them.', 'abientot' ), 169 'icon' => 'dashicons-share', 170 'span' => 6, 171 'order' => 6, 172 'shareable' => false, 173 'render' => array( ShareKit::class, 'render' ) 174 ); 164 175 } 165 176 $cards = (array) apply_filters( 'abientot_report_cards', $cards, $context ); -
abientot/tags/1.1.4/includes/Admin/ProBadge.php
r3764499 r3767446 13 13 14 14 final class ProBadge { 15 /** Public plans and prices on the official site (Pro is sold through Freemius checkout there). */ 16 private const PRICING_URL = 'https://abientot.app/#pricing'; 17 15 18 public static function badge( string $label = '', bool $linked = true ): string { 16 19 $text = '' !== $label ? $label : __( 'PRO', 'abientot' ); … … 70 73 array( 71 74 array( __( 'Customer payments', 'abientot' ), __( 'Pay on site and manual payment records', 'abientot' ), __( 'Also adds Stripe payments, deposits, balances and refunds', 'abientot' ) ), 72 array( __( 'Email notifications', 'abientot' ), __( ' Booking confirmation and update emails', 'abientot' ), __( 'Also adds scheduled reminders anddelivery activity', 'abientot' ) ),75 array( __( 'Email notifications', 'abientot' ), __( 'Confirmation and update emails, plus one optional reminder before each appointment', 'abientot' ), __( 'Also adds notification delivery activity', 'abientot' ) ), 73 76 array( __( 'Preparation and follow-up', 'abientot' ), __( 'Not included', 'abientot' ), __( 'Service-specific instructions and consent-based follow-up', 'abientot' ) ), 74 77 array( __( 'SMS notifications', 'abientot' ), __( 'Not included', 'abientot' ), __( 'Configured provider and customer opt-in', 'abientot' ) ), … … 89 92 <div class="abientot-edition-summaries"> 90 93 <section><h4><?php esc_html_e( 'Abientot Free', 'abientot' ); ?></h4><strong><?php esc_html_e( 'For a solo business', 'abientot' ); ?></strong><p><?php esc_html_e( 'One staff profile and one location, with everything needed to accept and manage bookings.', 'abientot' ); ?></p></section> 91 <section><h4><?php esc_html_e( 'Abientot Pro', 'abientot' ); ?></h4><strong><?php esc_html_e( 'For a growing business', 'abientot' ); ?></strong><p><?php esc_html_e( 'Everything in Free, plus team management, flexible booking options and additional customer tools.', 'abientot' ); ?></p></section> 94 <section><h4><?php esc_html_e( 'Abientot Pro', 'abientot' ); ?></h4><strong><?php esc_html_e( 'For a growing business', 'abientot' ); ?></strong><p><?php esc_html_e( 'Everything in Free, plus team management, flexible booking options and additional customer tools.', 'abientot' ); ?></p><?php if ( ! \Abientot\Edition::is_pro() ) : 95 ?><p><a class="button button-primary" href="<?php echo esc_url( self::PRICING_URL ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'See Pro plans and prices', 'abientot' ); ?></a></p><?php 96 endif; ?></section> 92 97 </div> 93 98 <section class="abientot-edition-shared"><h4><?php esc_html_e( 'Included in both editions', 'abientot' ); ?></h4><p><?php esc_html_e( 'Customizable booking forms, service and category photos, availability rules, bookings, calendar, customers, basic reports, booking emails, pay-on-site records and privacy tools.', 'abientot' ); ?></p></section> … … 103 108 </section> 104 109 <?php endforeach; ?> 105 <section class="abientot-edition-notes"><h4><?php esc_html_e( 'Before choosing Pro', 'abientot' ); ?></h4><p><?php esc_html_e( 'Online payments, SMS and calendar connections require provider setup. SMS provider charges are separate.', 'abientot' ); ?></p><p><?php esc_html_e( 'Switching editions preserves your existing bookings and settings. Do not uninstall to switch editions.', 'abientot' ); ?></p><?php if ( ! \Abientot\Edition::is_pro() ) : 106 ?><p><?php esc_html_e( 'These capabilities are implemented in the Pro edition. Online purchasing is not available yet.', 'abientot' ); ?></p><?php 107 endif; ?></section> 110 <section class="abientot-edition-notes"><h4><?php esc_html_e( 'Before choosing Pro', 'abientot' ); ?></h4><p><?php esc_html_e( 'Online payments, SMS and calendar connections require provider setup. SMS provider charges are separate.', 'abientot' ); ?></p><p><?php esc_html_e( 'Switching editions preserves your existing bookings and settings. Do not uninstall to switch editions.', 'abientot' ); ?></p></section> 108 111 </div> 109 112 <?php -
abientot/tags/1.1.4/includes/Admin/TodayManager.php
r3764499 r3767446 73 73 <?php if ( $rows ) : 74 74 ?><aside class="abientot-panel abientot-today-details abientot-drawer" data-today-details><div class="abientot-drawer-header"><span class="abientot-drawer-icon dashicons dashicons-calendar-alt" aria-hidden="true"></span><div class="abientot-drawer-heading"><h2><?php esc_html_e( 'Booking details', 'abientot' ); ?></h2></div><button type="button" class="button abientot-today-close abientot-drawer-close" data-today-close aria-label="<?php esc_attr_e( 'Close details', 'abientot' ); ?>"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span><span class="screen-reader-text"><?php esc_html_e( 'Close details', 'abientot' ); ?></span></button></div><?php foreach ( $rows as$i => $r ) : 75 ?><section class="abientot-drawer-body" id="abientot-today-detail-<?php echo esc_attr( (string) $r['id'] ); ?>" data-today-detail="<?php echo esc_attr( (string) $r['id'] ); ?>" <?php echo $i === 0 ? '' : 'hidden'; ?>><h3><?php echo esc_html( $r['customer_name'] ); ?></h3><p class="description"><?php echo esc_html( $r['service_name'] ); ?></p><dl><dt><?php esc_html_e( 'When', 'abientot' ); ?></dt><dd><?php echo esc_html( DateTimeHelper::format_utc_datetime( $r['start_utc'], $zone->getName() ) ); ?></dd><dt><?php esc_html_e( 'Staff', 'abientot' ); ?></dt><dd><?php echo esc_html( $r['staff_name'] . ' · ' . $r['location_name'] ); ?></dd><dt><?php esc_html_e( 'Appointment', 'abientot' ); ?></dt><dd data-today-status-label><?php echo esc_html( $r['status_label'] ); ?></dd><dt><?php esc_html_e( 'Payment', 'abientot' ); ?></dt><dd data-today-payment-label><?php echo esc_html( $this->payment( $r ) ); ?></dd><dt><?php esc_html_e( 'Total', 'abientot' ); ?></dt><dd><?php echo esc_html( \Abientot\Support\Currency::format( (int) $r['price_cents'], $r['currency'] ) ); ?></dd></dl><a class="button button-primary" href="<?php echo esc_url( admin_url( 'admin.php?page=abientot-bookings&booking_id=' . (int) $r['id'] ) ); ?>"><?php echo esc_html( $r['status_slug'] === 'pending' ? __( 'Review booking', 'abientot' ) : __( 'Manage booking', 'abientot' ) ); ?></a>< details><summary><?php esc_html_e( 'Customer & notes', 'abientot' ); ?></summary><p><?php echo esc_html( $r['customer_email'] ); ?><br><?php echo esc_html( $r['customer_phone'] ); ?></p><?php if ( ! empty( $r['internal_note'] ) ) :75 ?><section class="abientot-drawer-body" id="abientot-today-detail-<?php echo esc_attr( (string) $r['id'] ); ?>" data-today-detail="<?php echo esc_attr( (string) $r['id'] ); ?>" <?php echo $i === 0 ? '' : 'hidden'; ?>><h3><?php echo esc_html( $r['customer_name'] ); ?></h3><p class="description"><?php echo esc_html( $r['service_name'] ); ?></p><dl><dt><?php esc_html_e( 'When', 'abientot' ); ?></dt><dd><?php echo esc_html( DateTimeHelper::format_utc_datetime( $r['start_utc'], $zone->getName() ) ); ?></dd><dt><?php esc_html_e( 'Staff', 'abientot' ); ?></dt><dd><?php echo esc_html( $r['staff_name'] . ' · ' . $r['location_name'] ); ?></dd><dt><?php esc_html_e( 'Appointment', 'abientot' ); ?></dt><dd data-today-status-label><?php echo esc_html( $r['status_label'] ); ?></dd><dt><?php esc_html_e( 'Payment', 'abientot' ); ?></dt><dd data-today-payment-label><?php echo esc_html( $this->payment( $r ) ); ?></dd><dt><?php esc_html_e( 'Total', 'abientot' ); ?></dt><dd><?php echo esc_html( \Abientot\Support\Currency::format( (int) $r['price_cents'], $r['currency'] ) ); ?></dd></dl><a class="button button-primary" href="<?php echo esc_url( admin_url( 'admin.php?page=abientot-bookings&booking_id=' . (int) $r['id'] ) ); ?>"><?php echo esc_html( $r['status_slug'] === 'pending' ? __( 'Review booking', 'abientot' ) : __( 'Manage booking', 'abientot' ) ); ?></a><?php echo wp_kses( \Abientot\Support\WhatsApp::button( (string) ( $r['customer_phone'] ?? '' ), (string) ( $r['first_name'] ?? '' ) !== '' ? (string) $r['first_name'] : (string) $r['customer_name'], DateTimeHelper::format_utc_datetime( $r['start_utc'], $zone->getName() ) ), array( 'a' => array( 'class' => true, 'href' => true, 'target' => true, 'rel' => true ) ) ); ?><details><summary><?php esc_html_e( 'Customer & notes', 'abientot' ); ?></summary><p><?php echo esc_html( $r['customer_email'] ); ?><br><?php echo esc_html( $r['customer_phone'] ); ?></p><?php if ( ! empty( $r['internal_note'] ) ) : 76 76 ?><p><?php echo esc_html( $r['internal_note'] ); ?></p><?php 77 77 endif; ?></details></section><?php -
abientot/tags/1.1.4/includes/Database/Seeder.php
r3764499 r3767446 43 43 44 44 update_option( 'abientot_settings', array_merge( $defaults, $settings ), false ); 45 } 46 47 public const STATUS_LABELS_OPTION = 'abientot_status_labels_locale'; 48 49 /** 50 * English labels of the system statuses, as first installed. 51 * 52 * @return array<string,string> 53 */ 54 public static function default_status_labels(): array { 55 return array( 56 'pending' => 'Pending', 57 'confirmed' => 'Confirmed', 58 'completed' => 'Completed', 59 'cancelled' => 'Cancelled', 60 'no_show' => 'No-show', 61 ); 62 } 63 64 /** 65 * System status labels in the current language. 66 * 67 * @return array<string,string> 68 */ 69 public static function translated_status_labels(): array { 70 return array( 71 'pending' => _x( 'Pending', 'booking status', 'abientot' ), 72 'confirmed' => _x( 'Confirmed', 'booking status', 'abientot' ), 73 'completed' => _x( 'Completed', 'booking status', 'abientot' ), 74 'cancelled' => _x( 'Cancelled', 'booking status', 'abientot' ), 75 'no_show' => _x( 'No-show', 'booking status', 'abientot' ), 76 ); 77 } 78 79 /** 80 * Status names are business data shown in admin screens and customer emails. They are stored 81 * in English at installation; this writes them in the site language. Only a label that is still 82 * a default (English, or the default of the previous site language) changes: names the business 83 * edited are kept. Runs again only when the site language changes. 84 */ 85 public static function localize_status_labels(): void { 86 // Same language as the email templates: Abientot's language setting, else the site language. 87 $locale = \Abientot\Admin\Settings::configured_locale(); 88 $locale = '' !== $locale ? $locale : get_locale(); 89 $state = get_option( self::STATUS_LABELS_OPTION ); 90 if ( ( is_array( $state ) && ( $state['locale'] ?? '' ) === $locale ) || get_transient( self::STATUS_LABELS_OPTION . '_retry' ) ) { 91 return; 92 } 93 try { 94 $labels = \Abientot\Support\Translations::with_locale( $locale, array( self::class, 'translated_status_labels' ) ); 95 } catch ( \InvalidArgumentException $error ) { 96 $labels = self::default_status_labels(); // No Abientot catalogue for this language. 97 } 98 if ( 0 !== strpos( $locale, 'en_' ) && self::default_status_labels() === $labels ) { 99 // No translation installed for this language yet (language pack pending): check again tomorrow. 100 set_transient( self::STATUS_LABELS_OPTION . '_retry', 1, DAY_IN_SECONDS ); 101 return; 102 } 103 global $wpdb; 104 $previous = is_array( $state ) && is_array( $state['labels'] ?? null ) ? $state['labels'] : array(); 105 foreach ( self::default_status_labels() as $slug => $english ) { 106 $old = isset( $previous[ $slug ] ) && is_string( $previous[ $slug ] ) ? $previous[ $slug ] : $english; 107 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time update of the plugin's own status rows. 108 $result = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_booking_statuses SET label = %s, updated_at = %s WHERE slug = %s AND is_system = 1 AND label IN ( %s, %s )", $labels[ $slug ], gmdate( 'Y-m-d H:i:s' ), $slug, $english, $old ) ); 109 if ( false === $result ) { 110 return; // Try again on the next admin request. 111 } 112 } 113 update_option( 114 self::STATUS_LABELS_OPTION, 115 array( 116 'locale' => $locale, 117 'labels' => $labels, 118 ), 119 true 120 ); 45 121 } 46 122 -
abientot/tags/1.1.4/includes/Frontend/BookingForm.php
r3764499 r3767446 90 90 private static ?string $availability_session_value = null; 91 91 92 /** Booking form of the request being handled (0 = main form). */ 93 private int $form_id = 0; 94 92 95 /** Fields of the booking request being handled, set after its nonce was verified. */ 93 96 private ?Input $input = null; … … 114 117 add_action( 'wp_ajax_abientot_public_book', array( $this, 'ajax_book' ) ); 115 118 add_action( 'wp_ajax_nopriv_abientot_public_book', array( $this, 'ajax_book' ) ); 119 add_action( 'wp_ajax_abientot_public_nonce', array( $this, 'ajax_nonce' ) ); 120 add_action( 'wp_ajax_nopriv_abientot_public_nonce', array( $this, 'ajax_nonce' ) ); 116 121 } 117 122 … … 195 200 } 196 201 202 /** 203 * Hands out a fresh public booking nonce, uncached, so a form served from an aged page 204 * cache can still check availability and book. It reads no input and changes nothing: 205 * the token is the same one any visitor gets by loading the page. 206 */ 207 public function ajax_nonce(): void { 208 nocache_headers(); 209 if ( ! $this->rate_limit( 'nonce', 30, MINUTE_IN_SECONDS ) ) { 210 wp_send_json_error( array( 'message' => __( 'Too many availability requests. Please wait a minute, then retry.', 'abientot' ) ), 429 ); 211 } 212 wp_send_json_success( 213 array( 214 'nonce' => wp_create_nonce( 'abientot_public_booking' ), 215 'time' => time(), 216 ) 217 ); 218 } 219 197 220 public function ajax_slots(): void { 198 221 check_ajax_referer( 'abientot_public_booking', 'nonce' ); … … 391 414 private function book_under_lock( string $lock_name ): void { 392 415 // The submitted form decides customer fields, consent and messages; unknown ids use the main form. 393 $settings = \Abientot\Support\BookingForms::settings( \Abientot\Support\BookingForms::resolve( $this->input()->has( 'abientot_form_id' ) ? $this->input()->key( 'abientot_form_id' ) : 0 ) ); 416 $this->form_id = \Abientot\Support\BookingForms::resolve( $this->input()->has( 'abientot_form_id' ) ? $this->input()->key( 'abientot_form_id' ) : 0 ); 417 $settings = \Abientot\Support\BookingForms::settings( $this->form_id ); 394 418 $service_id = $this->input()->int( 'service_id' ); 395 419 $start_local = $this->normalize_start_local( $this->input()->text( 'start_local' ) ); … … 749 773 private function public_confirmation( array $booking, array $settings ): array { 750 774 $id = (int) $booking['id']; 751 $service = $this->services->find( (int) $booking['service_id'] ) ?: array(); 775 $settings = \Abientot\Support\MultilingualText::form_settings( $settings, $this->form_id ); 776 $service = \Abientot\Support\MultilingualText::service( $this->services->find( (int) $booking['service_id'] ) ?: array() ); 752 777 $staff = $this->staff->find( (int) $booking['staff_id'] ) ?: array(); 753 $location = $this->locations->find( (int) $booking['location_id'] ) ?: array();778 $location = \Abientot\Support\MultilingualText::location( $this->locations->find( (int) $booking['location_id'] ) ?: array() ); 754 779 $status = $this->statuses->find( (int) $booking['status_id'] ) ?: array(); 755 780 $calendar = CalendarFile::render( … … 787 812 788 813 private function render_form( string $mode = '', int $form_id = 0 ): string { 789 $settings = \Abientot\Support\ BookingForms::settings($form_id );814 $settings = \Abientot\Support\MultilingualText::form_settings( \Abientot\Support\BookingForms::settings( $form_id ), $form_id ); 790 815 $mode = in_array( $mode, array( 'embedded', 'popup' ), true ) ? $mode : (string) $settings['form_display_mode']; 791 816 $services = $this->public_services(); … … 800 825 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 801 826 'nonce' => wp_create_nonce( 'abientot_public_booking' ), 827 'nonceTime' => time(), 828 'lang' => \Abientot\Support\MultilingualText::current_language(), 802 829 'displayFormats' => array( 803 830 'date' => DateTimeHelper::date_display_format(), … … 1313 1340 */ 1314 1341 private function public_services(): array { 1315 $rows = $this->services->list_for_admin();1342 $rows = array_map( array( \Abientot\Support\MultilingualText::class, 'service' ), $this->services->list_for_admin() ); 1316 1343 return array_values( 1317 1344 array_filter( … … 1477 1504 private function default_location(): array { 1478 1505 $location = $this->locations->default_location(); 1479 return is_array( $location ) ? $location: array(1506 return is_array( $location ) ? \Abientot\Support\MultilingualText::location( $location ) : array( 1480 1507 'id' => 0, 1481 1508 'name' => '', -
abientot/tags/1.1.4/includes/Plugin.php
r3764499 r3767446 39 39 public static function activate() : void { 40 40 Installer::activate(); 41 try { 42 \Abientot\Reminders\Module::restore_schedule(); 43 } catch ( \Throwable $error ) { 44 unset($error); 45 // The reminder page shows a missing schedule; saving its settings retries. 46 } 41 47 // ABIENTOT_LOCAL_PREMIUM_END 42 48 } … … 95 101 // ABIENTOT_LOCAL_PREMIUM_END 96 102 ( new BookingForm() )->register(); 103 \Abientot\Support\MultilingualText::register_hooks(); 104 \Abientot\Repositories\BookingPagesRepository::register_hooks(); 105 add_action( 'admin_init', array(\Abientot\Database\Seeder::class, 'localize_status_labels') ); 97 106 ( new SelfServicePage() )->register(); 107 // One optional email reminder is part of Free (decision #4); its settings default to off. 108 add_action( 'init', static function () : void { 109 ( new \Abientot\Reminders\Module() )->register(); 110 }, 20 ); 98 111 ( new \Abientot\Support\ActivityObserver() )->register(); 99 112 add_action( 'admin_bar_menu', array($this, 'dashboard_toolbar_link'), 90 ); -
abientot/tags/1.1.4/includes/Uninstaller.php
r3764499 r3767446 142 142 delete_option( 'abientot_notification_preferences' ); 143 143 delete_metadata( 'user', 0, $wpdb->prefix . 'abientot_activity_dismissed', '', true ); 144 delete_metadata( 'user', 0, $wpdb->prefix . 'abientot_review_request', '', true ); 145 delete_option( 'abientot_review_request_ready' ); 146 delete_transient( 'abientot_review_request_ready' ); 144 147 delete_option( 'abientot_location_weekly_schedule' ); 145 148 delete_option( 'abientot_portal_schema_version' ); … … 172 175 delete_option( 'abientot_reminders_scan_cursor' ); 173 176 delete_option( 'abientot_reminders_last_run' ); 177 delete_transient( 'abientot_reminders_schema_retry' ); 178 delete_transient( 'abientot_booking_page_ids' ); 179 delete_option( 'abientot_wpml_strings_fingerprint' ); 180 delete_option( 'abientot_status_labels_locale' ); 181 delete_transient( 'abientot_status_labels_locale_retry' ); 174 182 wp_clear_scheduled_hook( 'abientot_reminders_tick' ); 175 183 wp_clear_scheduled_hook( 'abientot_instructions_tick' ); -
abientot/tags/1.1.4/readme.txt
r3765051 r3767446 1 === Abientot ===1 === Abientot – Appointment Booking === 2 2 Contributors: absoludev, freemius 3 3 Tags: booking, appointments, scheduling, reservations, calendar … … 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1.1. 37 Stable tag: 1.1.4 8 8 License: GPLv3 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-3.0.html 10 10 11 A local-first booking plugin for services, appointments, customers, calendars, and solo business scheduling.11 Appointment booking for service businesses: unlimited bookings, no booking fees or commission, and your data stays on your own WordPress site. 12 12 13 13 == Description == 14 14 15 15 Abientot helps service businesses accept and manage appointments from WordPress without relying on remote booking services by default. 16 17 The free plugin has no limit on services or bookings and charges no booking fees or commission. It is made for a solo business: one staff member and one location. 16 18 17 19 The free plugin includes: … … 21 23 * Admin booking management with lifecycle statuses, internal notes, activity history, manual pay-on-site status, and CSV export. 22 24 * Calendar, customer database, dashboard reports, and local chart summaries. 23 * A public booking shortcode and block. 24 * Customer email notifications through WordPress mail. 25 * A public booking shortcode and block that keeps working on pages served by a page cache. 26 * A share kit on the dashboard: your booking link, a QR code made on your site, and where to post them. 27 * Customer email notifications through WordPress mail, including one optional reminder email before each appointment. 28 * A "Message on WhatsApp" button on bookings with a phone number (opens WhatsApp with a prefilled message; nothing is sent by the plugin). 29 * WPML and Polylang: service, category and location names and the booking form texts can be translated for visitors. 25 30 * A local calendar download after booking, with tentative status while approval is pending. 26 31 * Editable cancellation and rescheduling contact instructions in customer confirmation emails. … … 49 54 No. The free plugin supports manual pay-on-site status and instructions only. 50 55 56 = Is there a limit on bookings or services? = 57 58 No. The free plugin has no monthly booking cap and no limit on services, and Abientot takes no booking fee or commission. Your hosting and email service are the only running costs. 59 51 60 = Can I manage multiple staff members or multiple locations? = 52 61 53 The free plugin manages one staff profile and one location profile. The database is structured for future add-ons, but multiple-staff and multiple-location interfaces are not part of this free plugin.62 The free plugin manages one staff profile and one location profile. Abientot Pro, sold separately on abientot.app, adds unlimited staff and locations on one site for one price, with every Pro feature included. 54 63 55 64 = Can customers cancel or reschedule online in the free plugin? = 56 65 57 66 No. The free plugin can show your business phone and email in confirmation emails so customers know how to contact you. Dedicated customer cancellation and rescheduling pages are not included in the free plugin. 67 68 = Which languages are available? = 69 70 Translations of the free plugin come from translate.wordpress.org and install automatically once a language is approved there. You can help translate Abientot into your language on translate.wordpress.org. 58 71 59 72 = What happens when I deactivate the plugin? = … … 120 133 121 134 == Changelog == 135 136 = 1.1.4 = 137 * New: one optional reminder email before each appointment (Settings > Communication, off by default). Settings and the reminder queue are shared with Pro. 138 * New: share kit on the dashboard with the booking page link, Copy link, a QR code drawn locally (SVG download) and tips on where to post the link; "Create a booking page" when none is published. 139 * New: "Message on WhatsApp" button in the booking details and the Today view for customers with a phone number. 140 * New: WPML and Polylang can translate service, category and location names and the booking form texts on the public form; admin screens keep the original text. 141 * Booking form: a page served from a page cache after its security token expired renews the token and retries once instead of showing no available times. 142 * Admin: confirmation messages always appear as notifications (they could end up hidden); a one-time invitation to review Abientot after ten past bookings. 143 * Booking form: on phones, the docked summary and step buttons stay above a theme's fixed bottom menu bar; theme heading styles no longer change the form's headings. 144 * Booking form: in the popup, keyboard focus stays inside the form and Escape closes it. 145 * Booking form: dark looks keep readable text on the summary, confirmation and payment panels. 146 * Booking statuses (Pending, Confirmed, Completed, Cancelled, No-show) are shown in the site language, also in emails; names you changed are kept. 147 * Pro: Stripe Checkout can offer the payment methods enabled in the Stripe Dashboard (for example Bancontact, iDEAL, MB WAY, EPS) instead of cards only. Earlier payments, refunds and confirmations are unaffected. 148 * Readme: name, description and FAQ updated. 122 149 123 150 = 1.1.3 = … … 236 263 == Upgrade Notice == 237 264 265 = 1.1.4 = 266 Optional appointment reminder email, share kit with QR code, WhatsApp button, WPML/Polylang support for business texts, and booking forms that keep working behind page caches. Adds one table for reminders when first needed. 267 238 268 = 1.1.3 = 239 269 Security hardening of request handling for the WordPress.org review. No database changes. -
abientot/trunk/abientot.php
r3764499 r3767446 2 2 3 3 /** 4 * Plugin Name: Abientot 4 * Plugin Name: Abientot – Appointment Booking 5 5 * Description: A local-first WordPress booking plugin for services, appointments, customers, calendars, and business scheduling. 6 * Version: 1.1. 36 * Version: 1.1.4 7 7 * Author: AbsoluDev 8 8 * Author URI: https://absolu.dev … … 77 77 define( 'ABIENTOT_EDITION', $abientot_edition ); 78 78 unset($abientot_edition); 79 define( 'ABIENTOT_VERSION', '1.1. 3' );79 define( 'ABIENTOT_VERSION', '1.1.4' ); 80 80 define( 'ABIENTOT_PLUGIN_FILE', __FILE__ ); 81 81 define( 'ABIENTOT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); -
abientot/trunk/assets/admin/admin.css
r3764499 r3767446 103 103 } 104 104 105 .abientot-notification-stack .abientot-notification form p { 106 margin-top: 10px; 107 } 108 105 109 .abientot-notification-stack .abientot-notification.is-leaving { 106 110 opacity: 0; … … 6351 6355 font-weight: 650; 6352 6356 } 6357 6358 .abientot-whatsapp-link { 6359 margin-inline-start: 8px; 6360 } 6361 6362 .abientot-detail-list dd .abientot-whatsapp-link { 6363 margin-block-start: 4px; 6364 } 6365 6366 /* Share kit (dashboard card) */ 6367 .abientot-share-kit + .abientot-share-kit { border-top: 1px solid #dcdcde; margin-top: 12px; padding-top: 4px; } 6368 .abientot-share-kit a[data-abientot-share-url] { word-break: break-all; } 6369 .abientot-share-kit__actions .button { margin: 0 4px 4px 0; } 6370 .abientot-share-kit__qr { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; margin: 8px 0; } 6371 .abientot-share-kit__code { width: 180px; max-width: 100%; background: #fff; } 6372 .abientot-share-kit__code svg { display: block; width: 100%; height: auto; } 6373 .abientot-share-kit__tips ul { list-style: disc; margin-left: 1.5em; } -
abientot/trunk/assets/admin/admin.js
r3764499 r3767446 250 250 251 251 function bindAdminNotifications() { 252 // Notices printed by admin_notices sit outside the app; those marked data-abientot-notice belong to it. 252 253 var notices = document.querySelectorAll( 253 '.abientot-admin .notice, .abientot-admin .updated, .abientot-admin .error '254 '.abientot-admin .notice, .abientot-admin .updated, .abientot-admin .error, .notice[data-abientot-notice]' 254 255 ); 255 256 var stack; … … 278 279 }; 279 280 280 notice.classList.add('abientot-notification', 'is-dismissible'); 281 // "inline" keeps WordPress (common.js) from moving the notice back under the page's 282 // screen-reader-only heading when it runs after this. 283 notice.classList.add('abientot-notification', 'is-dismissible', 'inline'); 281 284 if (!closeButton) { 282 285 closeButton = document.createElement('button'); … … 289 292 closeButton.addEventListener('click', removeNotice); 290 293 stack.appendChild(notice); 291 window.setTimeout(removeNotice, 5000); 294 // A notice that asks for a choice stays until it is answered or closed. 295 if (!notice.hasAttribute('data-abientot-persistent')) { 296 window.setTimeout(removeNotice, 5000); 297 } else { 298 notice.addEventListener('submit', function () { 299 window.setTimeout(removeNotice, 100); 300 }); 301 } 292 302 }); 293 303 } -
abientot/trunk/assets/frontend/booking-form.css
r3764499 r3767446 36 36 --abientot-map-height: 220px; 37 37 --abientot-shadow: 0 18px 56px rgba(19, 31, 41, 0.06); 38 --abientot-soft: color-mix(in srgb, var(--abientot-primary) 6%, #fff); 38 /* Mixed with the form surface, not white: dark looks keep light text on their panels. */ 39 --abientot-soft: color-mix(in srgb, var(--abientot-primary) 6%, rgb(var(--abientot-surface-rgb))); 39 40 color: var(--abientot-text); 40 41 font-size: 16px; … … 183 184 } 184 185 186 /* Theme content styles for headings (boxes, bars, numbering) must not leak into the form. */ 187 .abientot-booking.abientot-booking h2, 188 .abientot-booking.abientot-booking h3 { 189 padding: 0; 190 border: 0; 191 border-radius: 0; 192 background: none; 193 box-shadow: none; 194 counter-increment: none; 195 } 196 197 .abientot-booking.abientot-booking h2::before, 198 .abientot-booking.abientot-booking h2::after, 199 .abientot-booking.abientot-booking h3::before, 200 .abientot-booking.abientot-booking h3::after { 201 content: none; 202 } 203 185 204 .abientot-booking__header h2 { 186 205 margin-block: 0 8px; … … 715 734 border: 1px solid var(--abientot-border); 716 735 border-radius: 6px; 717 background: #f8fafc;736 background: var(--abientot-soft); 718 737 } 719 738 … … 866 885 @media (max-width: 767px) { 867 886 .abientot-booking:not(.abientot-booking--popup).is-service-step.is-selection-docked .abientot-booking__surface { 868 padding-bottom: 112px;887 padding-bottom: calc(112px + var(--abientot-dock-offset, 0px)); 869 888 -webkit-backdrop-filter: none; 870 889 backdrop-filter: none; … … 876 895 877 896 .abientot-booking:not(.abientot-booking--popup).is-time-step.is-step-actions-docked .abientot-booking__surface { 878 padding-bottom: 88px;897 padding-bottom: calc(88px + var(--abientot-dock-offset, 0px)); 879 898 -webkit-backdrop-filter: none; 880 899 backdrop-filter: none; … … 933 952 top: auto; 934 953 right: 0; 935 bottom: 0;954 bottom: var(--abientot-dock-offset, 0px); 936 955 left: 0; 937 956 margin-top: 0; … … 1050 1069 z-index: 99998; 1051 1070 right: 0; 1052 bottom: 0;1071 bottom: var(--abientot-dock-offset, 0px); 1053 1072 left: 0; 1054 1073 margin: 0; … … 1207 1226 border-inline-start: 3px solid var(--abientot-primary); 1208 1227 border-radius: 6px; 1209 background: color-mix(in srgb, var(--abientot-primary) 6%, #fff);1228 background: var(--abientot-soft); 1210 1229 color: var(--abientot-text); 1211 1230 } -
abientot/trunk/assets/frontend/booking-form.js
r3764499 r3767446 22 22 return document.createTextNode(value || ''); 23 23 } 24 25 /* 26 * Public requests carry the page's booking nonce. A page cache can serve that page long 27 * after the nonce expires (12-24 h), so a stale token is renewed from an uncached 28 * endpoint before sending, and a request refused for its token (403 "-1", nothing 29 * processed) is retried once with a fresh one. 30 */ 31 var nonceRefresh = null; 32 var nonceRetryAfter = 0; 33 var NONCE_MAX_AGE = 10 * 3600; 34 35 function refreshNonce() { 36 if (!nonceRefresh) { 37 var body = new FormData(); 38 body.set('action', 'abientot_public_nonce'); 39 nonceRefresh = fetch(abientotBooking.ajaxUrl, { 40 method: 'POST', 41 credentials: 'same-origin', 42 cache: 'no-store', 43 body: body 44 }) 45 .then(function (response) { 46 return response.json(); 47 }) 48 .then(function (response) { 49 if (!response || !response.success || !response.data || !response.data.nonce) { 50 throw new Error('nonce'); 51 } 52 abientotBooking.nonce = response.data.nonce; 53 // Device time, not server time: a skewed device clock must not keep the fresh nonce "stale". 54 abientotBooking.nonceTime = Math.floor(Date.now() / 1000); 55 }) 56 .then( 57 function () { 58 nonceRefresh = null; 59 }, 60 function () { 61 // Refresh refused (e.g. rate limit): send with the current nonce and wait a minute before retrying. 62 nonceRetryAfter = Date.now() + 60000; 63 nonceRefresh = null; 64 } 65 ); 66 } 67 return nonceRefresh; 68 } 69 70 function nonceIsStale() { 71 var issued = parseInt(abientotBooking.nonceTime, 10) || 0; 72 return issued > 0 && Date.now() >= nonceRetryAfter && Date.now() / 1000 - issued > NONCE_MAX_AGE; 73 } 74 75 // Pro handlers that verify the nonce themselves answer {"success":false,"data":{"code":"nonce"}}. 76 function nonceRefusal(body) { 77 try { 78 var parsed = JSON.parse(body); 79 return !!(parsed && parsed.data && 'nonce' === parsed.data.code); 80 } catch (error) { 81 return false; 82 } 83 } 84 85 function bookingPost(data, signal) { 86 function send() { 87 data.set('nonce', abientotBooking.nonce); 88 if (abientotBooking.lang && !data.has('lang')) { 89 data.set('lang', abientotBooking.lang); 90 } 91 return fetch(abientotBooking.ajaxUrl, { 92 method: 'POST', 93 credentials: 'same-origin', 94 signal: signal, 95 body: data 96 }); 97 } 98 return (nonceIsStale() ? refreshNonce().then(send) : send()).then(function (response) { 99 if (403 !== response.status) { 100 return response; 101 } 102 return response 103 .clone() 104 .text() 105 .then(function (body) { 106 return '-1' === body.trim() || nonceRefusal(body) ? refreshNonce().then(send) : response; 107 }); 108 }); 109 } 110 111 window.abientotBookingPost = bookingPost; 24 112 25 113 function message(form, value, isError) { … … 134 222 } 135 223 224 /* 225 * Themes often pin a menu bar to the bottom of phone screens (Cocoon's footer buttons, for 226 * example). The docked selection and step buttons sit above such a bar instead of under it. 227 */ 228 function updateDockOffset(root) { 229 var offset = 0; 230 if (document.elementsFromPoint && !root.classList.contains('abientot-booking--popup')) { 231 document.elementsFromPoint(window.innerWidth / 2, window.innerHeight - 2).some(function (element) { 232 if (root.contains(element)) { 233 return false; 234 } 235 for (var node = element; node && node !== document.body; node = node.parentElement) { 236 if ('fixed' === window.getComputedStyle(node).position) { 237 var rect = node.getBoundingClientRect(); 238 if (rect.bottom >= window.innerHeight - 2 && rect.top > window.innerHeight / 2) { 239 offset = Math.ceil(window.innerHeight - rect.top); 240 return true; 241 } 242 return false; 243 } 244 } 245 return false; 246 }); 247 } 248 root.style.setProperty('--abientot-dock-offset', offset + 'px'); 249 } 250 136 251 function updateMobileSelectionDock(root) { 137 252 var services = root.querySelector('.abientot-booking__services'); … … 140 255 var rect; 141 256 257 updateDockOffset(root); 258 142 259 if (!isMobile || !services || !selection || selection.hidden || !root.classList.contains('is-service-step')) { 143 260 root.classList.remove('is-selection-docked'); … … 159 276 var isMobile = window.matchMedia('(max-width: 767px)').matches; 160 277 var rect; 278 279 updateDockOffset(root); 161 280 162 281 if (!isMobile || !panel || !slots || !root.classList.contains('is-time-step')) { … … 274 393 slots.textContent = searchNext ? abientotBooking.strings.searchingDates : abientotBooking.strings.loading; 275 394 data.append('action', 'abientot_public_slots'); 276 data.append('nonce', abientotBooking.nonce);277 395 data.append('service_id', service.value); 278 396 root.dispatchEvent(new CustomEvent('abientot:availability-request', { detail: { data: data } })); … … 303 421 } 304 422 function request() { 305 return fetch(abientotBooking.ajaxUrl, { 306 method: 'POST', 307 credentials: 'same-origin', 308 signal: controller ? controller.signal : undefined, 309 body: data 310 }) 423 return bookingPost(data, controller ? controller.signal : undefined) 311 424 .then(function (response) { 312 425 return response.json(); … … 528 641 var data = seriesData(root); 529 642 data.set('action', 'abientot_customer_series_preview'); 530 data.set('nonce', abientotBooking.nonce); 531 fetch(abientotBooking.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data }) 643 bookingPost(data) 532 644 .then(function (response) { 533 645 return response.json(); … … 638 750 639 751 data.append('action', 'abientot_public_book'); 640 data.append('nonce', abientotBooking.nonce);641 752 submit.disabled = true; 642 753 message(form, '', false); 643 754 644 fetch(abientotBooking.ajaxUrl, { 645 method: 'POST', 646 credentials: 'same-origin', 647 body: data 648 }) 755 bookingPost(data) 649 756 .then(function (response) { 650 757 return response.json(); … … 1071 1178 var data = new FormData(); 1072 1179 data.set('action', 'abientot_public_slots'); 1073 data.set('nonce', abientotBooking.nonce);1074 1180 data.set('service_id', service.value); 1075 1181 data.set('date', isoDate(month)); … … 1087 1193 data.set('location_id', location.value); 1088 1194 } 1089 fetch(abientotBooking.ajaxUrl, { 1090 method: 'POST', 1091 credentials: 'same-origin', 1092 body: data, 1093 signal: controller ? controller.signal : undefined 1094 }) 1195 bookingPost(data, controller ? controller.signal : undefined) 1095 1196 .then(function (response) { 1096 1197 return response.json(); … … 1438 1539 1439 1540 if (open && close && surface) { 1541 // Safety net: focus that leaves the open dialog (for example through a theme script) comes back. 1542 document.addEventListener('focusin', function (event) { 1543 if (!surface.hidden && !surface.contains(event.target) && event.target !== open) { 1544 var target = surface.querySelector('[data-abientot-popup-close]'); 1545 if (target) { 1546 target.focus(); 1547 } 1548 } 1549 }); 1440 1550 surface.addEventListener('keydown', function (event) { 1441 1551 if (event.key === 'Escape') { … … 1453 1563 ).filter(function (item) { 1454 1564 return item.tabIndex >= 0 && item.getClientRects().length > 0 && !item.closest('[hidden]'); 1565 }); 1566 // A radio group is one tab stop: keep only its focused, checked or first radio, as browsers do. 1567 var stops = {}; 1568 items.forEach(function (item) { 1569 if ('radio' !== item.type || !item.name) { 1570 return; 1571 } 1572 var current = stops[item.name]; 1573 if ( 1574 !current || 1575 item === document.activeElement || 1576 (current !== document.activeElement && item.checked && !current.checked) 1577 ) { 1578 stops[item.name] = item; 1579 } 1580 }); 1581 items = items.filter(function (item) { 1582 return 'radio' !== item.type || !item.name || stops[item.name] === item; 1455 1583 }); 1456 1584 var first = items[0]; -
abientot/trunk/includes/Admin/Admin.php
r3764499 r3767446 57 57 $this->admin_data_manager->register(); 58 58 RecordPicker::register(); 59 ReviewRequest::register(); 59 60 add_action( 'admin_post_abientot_save_settings', array( $this->settings, 'save' ) ); 60 61 add_action( 'admin_post_abientot_form_create', array( $this->form_manager, 'create_form' ) ); … … 133 134 wp_enqueue_style( 'abientot-workspace', ABIENTOT_PLUGIN_URL . 'assets/admin/workspace.css', array( 'abientot-admin' ), $this->asset_version( 'assets/admin/workspace.css' ) ); 134 135 wp_enqueue_script( 'abientot-workspace', ABIENTOT_PLUGIN_URL . 'assets/admin/workspace.js', array( 'abientot-admin' ), $this->asset_version( 'assets/admin/workspace.js' ), true ); 136 wp_enqueue_script( 'abientot-qrcode', ABIENTOT_PLUGIN_URL . 'assets/admin/vendor/qrcode-generator/qrcode.js', array(), '1.4.4', true ); 137 wp_enqueue_script( 'abientot-share-kit', ABIENTOT_PLUGIN_URL . 'assets/admin/share-kit.js', array( 'abientot-qrcode' ), $this->asset_version( 'assets/admin/share-kit.js' ), true ); 135 138 136 139 wp_enqueue_style( -
abientot/trunk/includes/Admin/BookingsManager.php
r3764499 r3767446 1228 1228 <dl class="abientot-detail-list"> 1229 1229 <dt><?php esc_html_e( 'When', 'abientot' ); ?></dt><dd><?php echo esc_html( $this->local_datetime_label( (string) $booking['start_utc'], (string) $booking['timezone'] ) ); ?></dd> 1230 <dt><?php esc_html_e( 'Customer', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['customer_name'] ); ?>< /dd>1230 <dt><?php esc_html_e( 'Customer', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['customer_name'] ); ?><?php echo wp_kses( \Abientot\Support\WhatsApp::button( (string) ( $booking['customer_phone'] ?? '' ), (string) ( $booking['first_name'] ?? '' ) !== '' ? (string) $booking['first_name'] : (string) $booking['customer_name'], $this->local_datetime_label( (string) $booking['start_utc'], (string) $booking['timezone'] ) ), array( 'a' => array( 'class' => true, 'href' => true, 'target' => true, 'rel' => true ) ) ); ?></dd> 1231 1231 <dt><?php esc_html_e( 'Service', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['service_name'] ); ?></dd> 1232 1232 <dt><?php esc_html_e( 'Notes', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['internal_note'] ); ?></dd> -
abientot/trunk/includes/Admin/DashboardManager.php
r3764499 r3767446 162 162 ); 163 163 } 164 } 165 if ( $context->owner && current_user_can( 'abientot_manage_settings' ) ) { 166 $cards['share'] = array( 167 'title' => __( 'Share your booking page', 'abientot' ), 168 'description' => __( 'Your public booking link, a QR code and where to post them.', 'abientot' ), 169 'icon' => 'dashicons-share', 170 'span' => 6, 171 'order' => 6, 172 'shareable' => false, 173 'render' => array( ShareKit::class, 'render' ) 174 ); 164 175 } 165 176 $cards = (array) apply_filters( 'abientot_report_cards', $cards, $context ); -
abientot/trunk/includes/Admin/ProBadge.php
r3764499 r3767446 13 13 14 14 final class ProBadge { 15 /** Public plans and prices on the official site (Pro is sold through Freemius checkout there). */ 16 private const PRICING_URL = 'https://abientot.app/#pricing'; 17 15 18 public static function badge( string $label = '', bool $linked = true ): string { 16 19 $text = '' !== $label ? $label : __( 'PRO', 'abientot' ); … … 70 73 array( 71 74 array( __( 'Customer payments', 'abientot' ), __( 'Pay on site and manual payment records', 'abientot' ), __( 'Also adds Stripe payments, deposits, balances and refunds', 'abientot' ) ), 72 array( __( 'Email notifications', 'abientot' ), __( ' Booking confirmation and update emails', 'abientot' ), __( 'Also adds scheduled reminders anddelivery activity', 'abientot' ) ),75 array( __( 'Email notifications', 'abientot' ), __( 'Confirmation and update emails, plus one optional reminder before each appointment', 'abientot' ), __( 'Also adds notification delivery activity', 'abientot' ) ), 73 76 array( __( 'Preparation and follow-up', 'abientot' ), __( 'Not included', 'abientot' ), __( 'Service-specific instructions and consent-based follow-up', 'abientot' ) ), 74 77 array( __( 'SMS notifications', 'abientot' ), __( 'Not included', 'abientot' ), __( 'Configured provider and customer opt-in', 'abientot' ) ), … … 89 92 <div class="abientot-edition-summaries"> 90 93 <section><h4><?php esc_html_e( 'Abientot Free', 'abientot' ); ?></h4><strong><?php esc_html_e( 'For a solo business', 'abientot' ); ?></strong><p><?php esc_html_e( 'One staff profile and one location, with everything needed to accept and manage bookings.', 'abientot' ); ?></p></section> 91 <section><h4><?php esc_html_e( 'Abientot Pro', 'abientot' ); ?></h4><strong><?php esc_html_e( 'For a growing business', 'abientot' ); ?></strong><p><?php esc_html_e( 'Everything in Free, plus team management, flexible booking options and additional customer tools.', 'abientot' ); ?></p></section> 94 <section><h4><?php esc_html_e( 'Abientot Pro', 'abientot' ); ?></h4><strong><?php esc_html_e( 'For a growing business', 'abientot' ); ?></strong><p><?php esc_html_e( 'Everything in Free, plus team management, flexible booking options and additional customer tools.', 'abientot' ); ?></p><?php if ( ! \Abientot\Edition::is_pro() ) : 95 ?><p><a class="button button-primary" href="<?php echo esc_url( self::PRICING_URL ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'See Pro plans and prices', 'abientot' ); ?></a></p><?php 96 endif; ?></section> 92 97 </div> 93 98 <section class="abientot-edition-shared"><h4><?php esc_html_e( 'Included in both editions', 'abientot' ); ?></h4><p><?php esc_html_e( 'Customizable booking forms, service and category photos, availability rules, bookings, calendar, customers, basic reports, booking emails, pay-on-site records and privacy tools.', 'abientot' ); ?></p></section> … … 103 108 </section> 104 109 <?php endforeach; ?> 105 <section class="abientot-edition-notes"><h4><?php esc_html_e( 'Before choosing Pro', 'abientot' ); ?></h4><p><?php esc_html_e( 'Online payments, SMS and calendar connections require provider setup. SMS provider charges are separate.', 'abientot' ); ?></p><p><?php esc_html_e( 'Switching editions preserves your existing bookings and settings. Do not uninstall to switch editions.', 'abientot' ); ?></p><?php if ( ! \Abientot\Edition::is_pro() ) : 106 ?><p><?php esc_html_e( 'These capabilities are implemented in the Pro edition. Online purchasing is not available yet.', 'abientot' ); ?></p><?php 107 endif; ?></section> 110 <section class="abientot-edition-notes"><h4><?php esc_html_e( 'Before choosing Pro', 'abientot' ); ?></h4><p><?php esc_html_e( 'Online payments, SMS and calendar connections require provider setup. SMS provider charges are separate.', 'abientot' ); ?></p><p><?php esc_html_e( 'Switching editions preserves your existing bookings and settings. Do not uninstall to switch editions.', 'abientot' ); ?></p></section> 108 111 </div> 109 112 <?php -
abientot/trunk/includes/Admin/TodayManager.php
r3764499 r3767446 73 73 <?php if ( $rows ) : 74 74 ?><aside class="abientot-panel abientot-today-details abientot-drawer" data-today-details><div class="abientot-drawer-header"><span class="abientot-drawer-icon dashicons dashicons-calendar-alt" aria-hidden="true"></span><div class="abientot-drawer-heading"><h2><?php esc_html_e( 'Booking details', 'abientot' ); ?></h2></div><button type="button" class="button abientot-today-close abientot-drawer-close" data-today-close aria-label="<?php esc_attr_e( 'Close details', 'abientot' ); ?>"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span><span class="screen-reader-text"><?php esc_html_e( 'Close details', 'abientot' ); ?></span></button></div><?php foreach ( $rows as$i => $r ) : 75 ?><section class="abientot-drawer-body" id="abientot-today-detail-<?php echo esc_attr( (string) $r['id'] ); ?>" data-today-detail="<?php echo esc_attr( (string) $r['id'] ); ?>" <?php echo $i === 0 ? '' : 'hidden'; ?>><h3><?php echo esc_html( $r['customer_name'] ); ?></h3><p class="description"><?php echo esc_html( $r['service_name'] ); ?></p><dl><dt><?php esc_html_e( 'When', 'abientot' ); ?></dt><dd><?php echo esc_html( DateTimeHelper::format_utc_datetime( $r['start_utc'], $zone->getName() ) ); ?></dd><dt><?php esc_html_e( 'Staff', 'abientot' ); ?></dt><dd><?php echo esc_html( $r['staff_name'] . ' · ' . $r['location_name'] ); ?></dd><dt><?php esc_html_e( 'Appointment', 'abientot' ); ?></dt><dd data-today-status-label><?php echo esc_html( $r['status_label'] ); ?></dd><dt><?php esc_html_e( 'Payment', 'abientot' ); ?></dt><dd data-today-payment-label><?php echo esc_html( $this->payment( $r ) ); ?></dd><dt><?php esc_html_e( 'Total', 'abientot' ); ?></dt><dd><?php echo esc_html( \Abientot\Support\Currency::format( (int) $r['price_cents'], $r['currency'] ) ); ?></dd></dl><a class="button button-primary" href="<?php echo esc_url( admin_url( 'admin.php?page=abientot-bookings&booking_id=' . (int) $r['id'] ) ); ?>"><?php echo esc_html( $r['status_slug'] === 'pending' ? __( 'Review booking', 'abientot' ) : __( 'Manage booking', 'abientot' ) ); ?></a>< details><summary><?php esc_html_e( 'Customer & notes', 'abientot' ); ?></summary><p><?php echo esc_html( $r['customer_email'] ); ?><br><?php echo esc_html( $r['customer_phone'] ); ?></p><?php if ( ! empty( $r['internal_note'] ) ) :75 ?><section class="abientot-drawer-body" id="abientot-today-detail-<?php echo esc_attr( (string) $r['id'] ); ?>" data-today-detail="<?php echo esc_attr( (string) $r['id'] ); ?>" <?php echo $i === 0 ? '' : 'hidden'; ?>><h3><?php echo esc_html( $r['customer_name'] ); ?></h3><p class="description"><?php echo esc_html( $r['service_name'] ); ?></p><dl><dt><?php esc_html_e( 'When', 'abientot' ); ?></dt><dd><?php echo esc_html( DateTimeHelper::format_utc_datetime( $r['start_utc'], $zone->getName() ) ); ?></dd><dt><?php esc_html_e( 'Staff', 'abientot' ); ?></dt><dd><?php echo esc_html( $r['staff_name'] . ' · ' . $r['location_name'] ); ?></dd><dt><?php esc_html_e( 'Appointment', 'abientot' ); ?></dt><dd data-today-status-label><?php echo esc_html( $r['status_label'] ); ?></dd><dt><?php esc_html_e( 'Payment', 'abientot' ); ?></dt><dd data-today-payment-label><?php echo esc_html( $this->payment( $r ) ); ?></dd><dt><?php esc_html_e( 'Total', 'abientot' ); ?></dt><dd><?php echo esc_html( \Abientot\Support\Currency::format( (int) $r['price_cents'], $r['currency'] ) ); ?></dd></dl><a class="button button-primary" href="<?php echo esc_url( admin_url( 'admin.php?page=abientot-bookings&booking_id=' . (int) $r['id'] ) ); ?>"><?php echo esc_html( $r['status_slug'] === 'pending' ? __( 'Review booking', 'abientot' ) : __( 'Manage booking', 'abientot' ) ); ?></a><?php echo wp_kses( \Abientot\Support\WhatsApp::button( (string) ( $r['customer_phone'] ?? '' ), (string) ( $r['first_name'] ?? '' ) !== '' ? (string) $r['first_name'] : (string) $r['customer_name'], DateTimeHelper::format_utc_datetime( $r['start_utc'], $zone->getName() ) ), array( 'a' => array( 'class' => true, 'href' => true, 'target' => true, 'rel' => true ) ) ); ?><details><summary><?php esc_html_e( 'Customer & notes', 'abientot' ); ?></summary><p><?php echo esc_html( $r['customer_email'] ); ?><br><?php echo esc_html( $r['customer_phone'] ); ?></p><?php if ( ! empty( $r['internal_note'] ) ) : 76 76 ?><p><?php echo esc_html( $r['internal_note'] ); ?></p><?php 77 77 endif; ?></details></section><?php -
abientot/trunk/includes/Database/Seeder.php
r3764499 r3767446 43 43 44 44 update_option( 'abientot_settings', array_merge( $defaults, $settings ), false ); 45 } 46 47 public const STATUS_LABELS_OPTION = 'abientot_status_labels_locale'; 48 49 /** 50 * English labels of the system statuses, as first installed. 51 * 52 * @return array<string,string> 53 */ 54 public static function default_status_labels(): array { 55 return array( 56 'pending' => 'Pending', 57 'confirmed' => 'Confirmed', 58 'completed' => 'Completed', 59 'cancelled' => 'Cancelled', 60 'no_show' => 'No-show', 61 ); 62 } 63 64 /** 65 * System status labels in the current language. 66 * 67 * @return array<string,string> 68 */ 69 public static function translated_status_labels(): array { 70 return array( 71 'pending' => _x( 'Pending', 'booking status', 'abientot' ), 72 'confirmed' => _x( 'Confirmed', 'booking status', 'abientot' ), 73 'completed' => _x( 'Completed', 'booking status', 'abientot' ), 74 'cancelled' => _x( 'Cancelled', 'booking status', 'abientot' ), 75 'no_show' => _x( 'No-show', 'booking status', 'abientot' ), 76 ); 77 } 78 79 /** 80 * Status names are business data shown in admin screens and customer emails. They are stored 81 * in English at installation; this writes them in the site language. Only a label that is still 82 * a default (English, or the default of the previous site language) changes: names the business 83 * edited are kept. Runs again only when the site language changes. 84 */ 85 public static function localize_status_labels(): void { 86 // Same language as the email templates: Abientot's language setting, else the site language. 87 $locale = \Abientot\Admin\Settings::configured_locale(); 88 $locale = '' !== $locale ? $locale : get_locale(); 89 $state = get_option( self::STATUS_LABELS_OPTION ); 90 if ( ( is_array( $state ) && ( $state['locale'] ?? '' ) === $locale ) || get_transient( self::STATUS_LABELS_OPTION . '_retry' ) ) { 91 return; 92 } 93 try { 94 $labels = \Abientot\Support\Translations::with_locale( $locale, array( self::class, 'translated_status_labels' ) ); 95 } catch ( \InvalidArgumentException $error ) { 96 $labels = self::default_status_labels(); // No Abientot catalogue for this language. 97 } 98 if ( 0 !== strpos( $locale, 'en_' ) && self::default_status_labels() === $labels ) { 99 // No translation installed for this language yet (language pack pending): check again tomorrow. 100 set_transient( self::STATUS_LABELS_OPTION . '_retry', 1, DAY_IN_SECONDS ); 101 return; 102 } 103 global $wpdb; 104 $previous = is_array( $state ) && is_array( $state['labels'] ?? null ) ? $state['labels'] : array(); 105 foreach ( self::default_status_labels() as $slug => $english ) { 106 $old = isset( $previous[ $slug ] ) && is_string( $previous[ $slug ] ) ? $previous[ $slug ] : $english; 107 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time update of the plugin's own status rows. 108 $result = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_booking_statuses SET label = %s, updated_at = %s WHERE slug = %s AND is_system = 1 AND label IN ( %s, %s )", $labels[ $slug ], gmdate( 'Y-m-d H:i:s' ), $slug, $english, $old ) ); 109 if ( false === $result ) { 110 return; // Try again on the next admin request. 111 } 112 } 113 update_option( 114 self::STATUS_LABELS_OPTION, 115 array( 116 'locale' => $locale, 117 'labels' => $labels, 118 ), 119 true 120 ); 45 121 } 46 122 -
abientot/trunk/includes/Frontend/BookingForm.php
r3764499 r3767446 90 90 private static ?string $availability_session_value = null; 91 91 92 /** Booking form of the request being handled (0 = main form). */ 93 private int $form_id = 0; 94 92 95 /** Fields of the booking request being handled, set after its nonce was verified. */ 93 96 private ?Input $input = null; … … 114 117 add_action( 'wp_ajax_abientot_public_book', array( $this, 'ajax_book' ) ); 115 118 add_action( 'wp_ajax_nopriv_abientot_public_book', array( $this, 'ajax_book' ) ); 119 add_action( 'wp_ajax_abientot_public_nonce', array( $this, 'ajax_nonce' ) ); 120 add_action( 'wp_ajax_nopriv_abientot_public_nonce', array( $this, 'ajax_nonce' ) ); 116 121 } 117 122 … … 195 200 } 196 201 202 /** 203 * Hands out a fresh public booking nonce, uncached, so a form served from an aged page 204 * cache can still check availability and book. It reads no input and changes nothing: 205 * the token is the same one any visitor gets by loading the page. 206 */ 207 public function ajax_nonce(): void { 208 nocache_headers(); 209 if ( ! $this->rate_limit( 'nonce', 30, MINUTE_IN_SECONDS ) ) { 210 wp_send_json_error( array( 'message' => __( 'Too many availability requests. Please wait a minute, then retry.', 'abientot' ) ), 429 ); 211 } 212 wp_send_json_success( 213 array( 214 'nonce' => wp_create_nonce( 'abientot_public_booking' ), 215 'time' => time(), 216 ) 217 ); 218 } 219 197 220 public function ajax_slots(): void { 198 221 check_ajax_referer( 'abientot_public_booking', 'nonce' ); … … 391 414 private function book_under_lock( string $lock_name ): void { 392 415 // The submitted form decides customer fields, consent and messages; unknown ids use the main form. 393 $settings = \Abientot\Support\BookingForms::settings( \Abientot\Support\BookingForms::resolve( $this->input()->has( 'abientot_form_id' ) ? $this->input()->key( 'abientot_form_id' ) : 0 ) ); 416 $this->form_id = \Abientot\Support\BookingForms::resolve( $this->input()->has( 'abientot_form_id' ) ? $this->input()->key( 'abientot_form_id' ) : 0 ); 417 $settings = \Abientot\Support\BookingForms::settings( $this->form_id ); 394 418 $service_id = $this->input()->int( 'service_id' ); 395 419 $start_local = $this->normalize_start_local( $this->input()->text( 'start_local' ) ); … … 749 773 private function public_confirmation( array $booking, array $settings ): array { 750 774 $id = (int) $booking['id']; 751 $service = $this->services->find( (int) $booking['service_id'] ) ?: array(); 775 $settings = \Abientot\Support\MultilingualText::form_settings( $settings, $this->form_id ); 776 $service = \Abientot\Support\MultilingualText::service( $this->services->find( (int) $booking['service_id'] ) ?: array() ); 752 777 $staff = $this->staff->find( (int) $booking['staff_id'] ) ?: array(); 753 $location = $this->locations->find( (int) $booking['location_id'] ) ?: array();778 $location = \Abientot\Support\MultilingualText::location( $this->locations->find( (int) $booking['location_id'] ) ?: array() ); 754 779 $status = $this->statuses->find( (int) $booking['status_id'] ) ?: array(); 755 780 $calendar = CalendarFile::render( … … 787 812 788 813 private function render_form( string $mode = '', int $form_id = 0 ): string { 789 $settings = \Abientot\Support\ BookingForms::settings($form_id );814 $settings = \Abientot\Support\MultilingualText::form_settings( \Abientot\Support\BookingForms::settings( $form_id ), $form_id ); 790 815 $mode = in_array( $mode, array( 'embedded', 'popup' ), true ) ? $mode : (string) $settings['form_display_mode']; 791 816 $services = $this->public_services(); … … 800 825 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 801 826 'nonce' => wp_create_nonce( 'abientot_public_booking' ), 827 'nonceTime' => time(), 828 'lang' => \Abientot\Support\MultilingualText::current_language(), 802 829 'displayFormats' => array( 803 830 'date' => DateTimeHelper::date_display_format(), … … 1313 1340 */ 1314 1341 private function public_services(): array { 1315 $rows = $this->services->list_for_admin();1342 $rows = array_map( array( \Abientot\Support\MultilingualText::class, 'service' ), $this->services->list_for_admin() ); 1316 1343 return array_values( 1317 1344 array_filter( … … 1477 1504 private function default_location(): array { 1478 1505 $location = $this->locations->default_location(); 1479 return is_array( $location ) ? $location: array(1506 return is_array( $location ) ? \Abientot\Support\MultilingualText::location( $location ) : array( 1480 1507 'id' => 0, 1481 1508 'name' => '', -
abientot/trunk/includes/Plugin.php
r3764499 r3767446 39 39 public static function activate() : void { 40 40 Installer::activate(); 41 try { 42 \Abientot\Reminders\Module::restore_schedule(); 43 } catch ( \Throwable $error ) { 44 unset($error); 45 // The reminder page shows a missing schedule; saving its settings retries. 46 } 41 47 // ABIENTOT_LOCAL_PREMIUM_END 42 48 } … … 95 101 // ABIENTOT_LOCAL_PREMIUM_END 96 102 ( new BookingForm() )->register(); 103 \Abientot\Support\MultilingualText::register_hooks(); 104 \Abientot\Repositories\BookingPagesRepository::register_hooks(); 105 add_action( 'admin_init', array(\Abientot\Database\Seeder::class, 'localize_status_labels') ); 97 106 ( new SelfServicePage() )->register(); 107 // One optional email reminder is part of Free (decision #4); its settings default to off. 108 add_action( 'init', static function () : void { 109 ( new \Abientot\Reminders\Module() )->register(); 110 }, 20 ); 98 111 ( new \Abientot\Support\ActivityObserver() )->register(); 99 112 add_action( 'admin_bar_menu', array($this, 'dashboard_toolbar_link'), 90 ); -
abientot/trunk/includes/Uninstaller.php
r3764499 r3767446 142 142 delete_option( 'abientot_notification_preferences' ); 143 143 delete_metadata( 'user', 0, $wpdb->prefix . 'abientot_activity_dismissed', '', true ); 144 delete_metadata( 'user', 0, $wpdb->prefix . 'abientot_review_request', '', true ); 145 delete_option( 'abientot_review_request_ready' ); 146 delete_transient( 'abientot_review_request_ready' ); 144 147 delete_option( 'abientot_location_weekly_schedule' ); 145 148 delete_option( 'abientot_portal_schema_version' ); … … 172 175 delete_option( 'abientot_reminders_scan_cursor' ); 173 176 delete_option( 'abientot_reminders_last_run' ); 177 delete_transient( 'abientot_reminders_schema_retry' ); 178 delete_transient( 'abientot_booking_page_ids' ); 179 delete_option( 'abientot_wpml_strings_fingerprint' ); 180 delete_option( 'abientot_status_labels_locale' ); 181 delete_transient( 'abientot_status_labels_locale_retry' ); 174 182 wp_clear_scheduled_hook( 'abientot_reminders_tick' ); 175 183 wp_clear_scheduled_hook( 'abientot_instructions_tick' ); -
abientot/trunk/readme.txt
r3765051 r3767446 1 === Abientot ===1 === Abientot – Appointment Booking === 2 2 Contributors: absoludev, freemius 3 3 Tags: booking, appointments, scheduling, reservations, calendar … … 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1.1. 37 Stable tag: 1.1.4 8 8 License: GPLv3 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-3.0.html 10 10 11 A local-first booking plugin for services, appointments, customers, calendars, and solo business scheduling.11 Appointment booking for service businesses: unlimited bookings, no booking fees or commission, and your data stays on your own WordPress site. 12 12 13 13 == Description == 14 14 15 15 Abientot helps service businesses accept and manage appointments from WordPress without relying on remote booking services by default. 16 17 The free plugin has no limit on services or bookings and charges no booking fees or commission. It is made for a solo business: one staff member and one location. 16 18 17 19 The free plugin includes: … … 21 23 * Admin booking management with lifecycle statuses, internal notes, activity history, manual pay-on-site status, and CSV export. 22 24 * Calendar, customer database, dashboard reports, and local chart summaries. 23 * A public booking shortcode and block. 24 * Customer email notifications through WordPress mail. 25 * A public booking shortcode and block that keeps working on pages served by a page cache. 26 * A share kit on the dashboard: your booking link, a QR code made on your site, and where to post them. 27 * Customer email notifications through WordPress mail, including one optional reminder email before each appointment. 28 * A "Message on WhatsApp" button on bookings with a phone number (opens WhatsApp with a prefilled message; nothing is sent by the plugin). 29 * WPML and Polylang: service, category and location names and the booking form texts can be translated for visitors. 25 30 * A local calendar download after booking, with tentative status while approval is pending. 26 31 * Editable cancellation and rescheduling contact instructions in customer confirmation emails. … … 49 54 No. The free plugin supports manual pay-on-site status and instructions only. 50 55 56 = Is there a limit on bookings or services? = 57 58 No. The free plugin has no monthly booking cap and no limit on services, and Abientot takes no booking fee or commission. Your hosting and email service are the only running costs. 59 51 60 = Can I manage multiple staff members or multiple locations? = 52 61 53 The free plugin manages one staff profile and one location profile. The database is structured for future add-ons, but multiple-staff and multiple-location interfaces are not part of this free plugin.62 The free plugin manages one staff profile and one location profile. Abientot Pro, sold separately on abientot.app, adds unlimited staff and locations on one site for one price, with every Pro feature included. 54 63 55 64 = Can customers cancel or reschedule online in the free plugin? = 56 65 57 66 No. The free plugin can show your business phone and email in confirmation emails so customers know how to contact you. Dedicated customer cancellation and rescheduling pages are not included in the free plugin. 67 68 = Which languages are available? = 69 70 Translations of the free plugin come from translate.wordpress.org and install automatically once a language is approved there. You can help translate Abientot into your language on translate.wordpress.org. 58 71 59 72 = What happens when I deactivate the plugin? = … … 120 133 121 134 == Changelog == 135 136 = 1.1.4 = 137 * New: one optional reminder email before each appointment (Settings > Communication, off by default). Settings and the reminder queue are shared with Pro. 138 * New: share kit on the dashboard with the booking page link, Copy link, a QR code drawn locally (SVG download) and tips on where to post the link; "Create a booking page" when none is published. 139 * New: "Message on WhatsApp" button in the booking details and the Today view for customers with a phone number. 140 * New: WPML and Polylang can translate service, category and location names and the booking form texts on the public form; admin screens keep the original text. 141 * Booking form: a page served from a page cache after its security token expired renews the token and retries once instead of showing no available times. 142 * Admin: confirmation messages always appear as notifications (they could end up hidden); a one-time invitation to review Abientot after ten past bookings. 143 * Booking form: on phones, the docked summary and step buttons stay above a theme's fixed bottom menu bar; theme heading styles no longer change the form's headings. 144 * Booking form: in the popup, keyboard focus stays inside the form and Escape closes it. 145 * Booking form: dark looks keep readable text on the summary, confirmation and payment panels. 146 * Booking statuses (Pending, Confirmed, Completed, Cancelled, No-show) are shown in the site language, also in emails; names you changed are kept. 147 * Pro: Stripe Checkout can offer the payment methods enabled in the Stripe Dashboard (for example Bancontact, iDEAL, MB WAY, EPS) instead of cards only. Earlier payments, refunds and confirmations are unaffected. 148 * Readme: name, description and FAQ updated. 122 149 123 150 = 1.1.3 = … … 236 263 == Upgrade Notice == 237 264 265 = 1.1.4 = 266 Optional appointment reminder email, share kit with QR code, WhatsApp button, WPML/Polylang support for business texts, and booking forms that keep working behind page caches. Adds one table for reminders when first needed. 267 238 268 = 1.1.3 = 239 269 Security hardening of request handling for the WordPress.org review. No database changes.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)