Plugin Directory

Changeset 3767446


Ignore:
Timestamp:
10/26/2026 10:45:41 PM (2 days ago)
Author:
absoludev
Message:

Abientot 1.1.4

Location:
abientot
Files:
34 added
30 edited
1 copied

Legend:

Unmodified
Added
Removed
  • abientot/tags/1.1.4/abientot.php

    r3764499 r3767446  
    22
    33/**
    4  * Plugin Name: Abientot
     4 * Plugin Name: Abientot – Appointment Booking
    55 * Description: A local-first WordPress booking plugin for services, appointments, customers, calendars, and business scheduling.
    6  * Version: 1.1.3
     6 * Version: 1.1.4
    77 * Author: AbsoluDev
    88 * Author URI: https://absolu.dev
    … …  
    7777    define( 'ABIENTOT_EDITION', $abientot_edition );
    7878    unset($abientot_edition);
    79     define( 'ABIENTOT_VERSION', '1.1.3' );
     79    define( 'ABIENTOT_VERSION', '1.1.4' );
    8080    define( 'ABIENTOT_PLUGIN_FILE', __FILE__ );
    8181    define( 'ABIENTOT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
  • abientot/tags/1.1.4/assets/admin/admin.css

    r3764499 r3767446  
    103103}
    104104
     105.abientot-notification-stack .abientot-notification form p {
     106        margin-top: 10px;
     107}
     108
    105109.abientot-notification-stack .abientot-notification.is-leaving {
    106110        opacity: 0;
    … …  
    63516355        font-weight: 650;
    63526356}
     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  
    250250
    251251        function bindAdminNotifications() {
     252                // Notices printed by admin_notices sit outside the app; those marked data-abientot-notice belong to it.
    252253                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]'
    254255                );
    255256                var stack;
    … …  
    278279                        };
    279280
    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');
    281284                        if (!closeButton) {
    282285                                closeButton = document.createElement('button');
    … …  
    289292                        closeButton.addEventListener('click', removeNotice);
    290293                        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                        }
    292302                });
    293303        }
  • abientot/tags/1.1.4/assets/frontend/booking-form.css

    r3764499 r3767446  
    3636        --abientot-map-height: 220px;
    3737        --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)));
    3940        color: var(--abientot-text);
    4041        font-size: 16px;
    … …  
    183184}
    184185
     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
    185204.abientot-booking__header h2 {
    186205        margin-block: 0 8px;
    … …  
    715734        border: 1px solid var(--abientot-border);
    716735        border-radius: 6px;
    717         background: #f8fafc;
     736        background: var(--abientot-soft);
    718737}
    719738
    … …  
    866885@media (max-width: 767px) {
    867886        .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));
    869888                -webkit-backdrop-filter: none;
    870889                backdrop-filter: none;
    … …  
    876895
    877896        .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));
    879898                -webkit-backdrop-filter: none;
    880899                backdrop-filter: none;
    … …  
    933952                top: auto;
    934953                right: 0;
    935                 bottom: 0;
     954                bottom: var(--abientot-dock-offset, 0px);
    936955                left: 0;
    937956                margin-top: 0;
    … …  
    10501069                z-index: 99998;
    10511070                right: 0;
    1052                 bottom: 0;
     1071                bottom: var(--abientot-dock-offset, 0px);
    10531072                left: 0;
    10541073                margin: 0;
    … …  
    12071226        border-inline-start: 3px solid var(--abientot-primary);
    12081227        border-radius: 6px;
    1209         background: color-mix(in srgb, var(--abientot-primary) 6%, #fff);
     1228        background: var(--abientot-soft);
    12101229        color: var(--abientot-text);
    12111230}
  • abientot/tags/1.1.4/assets/frontend/booking-form.js

    r3764499 r3767446  
    2222                return document.createTextNode(value || '');
    2323        }
     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;
    24112
    25113        function message(form, value, isError) {
    … …  
    134222        }
    135223
     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
    136251        function updateMobileSelectionDock(root) {
    137252                var services = root.querySelector('.abientot-booking__services');
    … …  
    140255                var rect;
    141256
     257                updateDockOffset(root);
     258
    142259                if (!isMobile || !services || !selection || selection.hidden || !root.classList.contains('is-service-step')) {
    143260                        root.classList.remove('is-selection-docked');
    … …  
    159276                var isMobile = window.matchMedia('(max-width: 767px)').matches;
    160277                var rect;
     278
     279                updateDockOffset(root);
    161280
    162281                if (!isMobile || !panel || !slots || !root.classList.contains('is-time-step')) {
    … …  
    274393                slots.textContent = searchNext ? abientotBooking.strings.searchingDates : abientotBooking.strings.loading;
    275394                data.append('action', 'abientot_public_slots');
    276                 data.append('nonce', abientotBooking.nonce);
    277395                data.append('service_id', service.value);
    278396                root.dispatchEvent(new CustomEvent('abientot:availability-request', { detail: { data: data } }));
    … …  
    303421                }
    304422                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)
    311424                                .then(function (response) {
    312425                                        return response.json();
    … …  
    528641                var data = seriesData(root);
    529642                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)
    532644                        .then(function (response) {
    533645                                return response.json();
    … …  
    638750
    639751                data.append('action', 'abientot_public_book');
    640                 data.append('nonce', abientotBooking.nonce);
    641752                submit.disabled = true;
    642753                message(form, '', false);
    643754
    644                 fetch(abientotBooking.ajaxUrl, {
    645                         method: 'POST',
    646                         credentials: 'same-origin',
    647                         body: data
    648                 })
     755                bookingPost(data)
    649756                        .then(function (response) {
    650757                                return response.json();
    … …  
    10711178                        var data = new FormData();
    10721179                        data.set('action', 'abientot_public_slots');
    1073                         data.set('nonce', abientotBooking.nonce);
    10741180                        data.set('service_id', service.value);
    10751181                        data.set('date', isoDate(month));
    … …  
    10871193                                data.set('location_id', location.value);
    10881194                        }
    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)
    10951196                                .then(function (response) {
    10961197                                        return response.json();
    … …  
    14381539
    14391540                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                        });
    14401550                        surface.addEventListener('keydown', function (event) {
    14411551                                if (event.key === 'Escape') {
    … …  
    14531563                                ).filter(function (item) {
    14541564                                        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;
    14551583                                });
    14561584                                var first = items[0];
  • abientot/tags/1.1.4/includes/Admin/Admin.php

    r3764499 r3767446  
    5757                $this->admin_data_manager->register();
    5858                RecordPicker::register();
     59                ReviewRequest::register();
    5960                add_action( 'admin_post_abientot_save_settings', array( $this->settings, 'save' ) );
    6061                add_action( 'admin_post_abientot_form_create', array( $this->form_manager, 'create_form' ) );
    … …  
    133134                wp_enqueue_style( 'abientot-workspace', ABIENTOT_PLUGIN_URL . 'assets/admin/workspace.css', array( 'abientot-admin' ), $this->asset_version( 'assets/admin/workspace.css' ) );
    134135                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 );
    135138
    136139                wp_enqueue_style(
  • abientot/tags/1.1.4/includes/Admin/BookingsManager.php

    r3764499 r3767446  
    12281228                        <dl class="abientot-detail-list">
    12291229                                <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>
    12311231                                <dt><?php esc_html_e( 'Service', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['service_name'] ); ?></dd>
    12321232                                <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  
    162162                                );
    163163                        }
     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                        );
    164175                }
    165176                $cards = (array) apply_filters( 'abientot_report_cards', $cards, $context );
  • abientot/tags/1.1.4/includes/Admin/ProBadge.php

    r3764499 r3767446  
    1313
    1414final 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
    1518        public static function badge( string $label = '', bool $linked = true ): string {
    1619                $text = '' !== $label ? $label : __( 'PRO', 'abientot' );
    … …  
    7073                                array(
    7174                                        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 and delivery 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' ) ),
    7376                                        array( __( 'Preparation and follow-up', 'abientot' ), __( 'Not included', 'abientot' ), __( 'Service-specific instructions and consent-based follow-up', 'abientot' ) ),
    7477                                        array( __( 'SMS notifications', 'abientot' ), __( 'Not included', 'abientot' ), __( 'Configured provider and customer opt-in', 'abientot' ) ),
    … …  
    8992                        <div class="abientot-edition-summaries">
    9093                                <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>
    9297                        </div>
    9398                        <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>
    … …  
    103108                        </section>
    104109                        <?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>
    108111                </div>
    109112                <?php
  • abientot/tags/1.1.4/includes/Admin/TodayManager.php

    r3764499 r3767446  
    7373                <?php if ( $rows ) :
    7474                        ?><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'] ) ) :
    7676        ?><p><?php echo esc_html( $r['internal_note'] ); ?></p><?php
    7777        endif; ?></details></section><?php
  • abientot/tags/1.1.4/includes/Database/Seeder.php

    r3764499 r3767446  
    4343
    4444                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                );
    45121        }
    46122
  • abientot/tags/1.1.4/includes/Frontend/BookingForm.php

    r3764499 r3767446  
    9090        private static ?string $availability_session_value = null;
    9191
     92        /** Booking form of the request being handled (0 = main form). */
     93        private int $form_id = 0;
     94
    9295        /** Fields of the booking request being handled, set after its nonce was verified. */
    9396        private ?Input $input = null;
    … …  
    114117                add_action( 'wp_ajax_abientot_public_book', array( $this, 'ajax_book' ) );
    115118                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' ) );
    116121        }
    117122
    … …  
    195200        }
    196201
     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
    197220        public function ajax_slots(): void {
    198221                check_ajax_referer( 'abientot_public_booking', 'nonce' );
    … …  
    391414        private function book_under_lock( string $lock_name ): void {
    392415                // 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 );
    394418                $service_id     = $this->input()->int( 'service_id' );
    395419                $start_local    = $this->normalize_start_local( $this->input()->text( 'start_local' ) );
    … …  
    749773        private function public_confirmation( array $booking, array $settings ): array {
    750774                $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() );
    752777                $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() );
    754779                $status   = $this->statuses->find( (int) $booking['status_id'] ) ?: array();
    755780                $calendar = CalendarFile::render(
    … …  
    787812
    788813        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 );
    790815                $mode     = in_array( $mode, array( 'embedded', 'popup' ), true ) ? $mode : (string) $settings['form_display_mode'];
    791816                $services = $this->public_services();
    … …  
    800825                                'ajaxUrl'             => admin_url( 'admin-ajax.php' ),
    801826                                'nonce'               => wp_create_nonce( 'abientot_public_booking' ),
     827                                'nonceTime'           => time(),
     828                                'lang'                => \Abientot\Support\MultilingualText::current_language(),
    802829                                'displayFormats'      => array(
    803830                                        'date'      => DateTimeHelper::date_display_format(),
    … …  
    13131340         */
    13141341        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() );
    13161343                return array_values(
    13171344                        array_filter(
    … …  
    14771504        private function default_location(): array {
    14781505                $location = $this->locations->default_location();
    1479                 return is_array( $location ) ? $location : array(
     1506                return is_array( $location ) ? \Abientot\Support\MultilingualText::location( $location ) : array(
    14801507                        'id'       => 0,
    14811508                        'name'     => '',
  • abientot/tags/1.1.4/includes/Plugin.php

    r3764499 r3767446  
    3939    public static function activate() : void {
    4040        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        }
    4147        // ABIENTOT_LOCAL_PREMIUM_END
    4248    }
    … …  
    95101        // ABIENTOT_LOCAL_PREMIUM_END
    96102        ( 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') );
    97106        ( 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 );
    98111        ( new \Abientot\Support\ActivityObserver() )->register();
    99112        add_action( 'admin_bar_menu', array($this, 'dashboard_toolbar_link'), 90 );
  • abientot/tags/1.1.4/includes/Uninstaller.php

    r3764499 r3767446  
    142142                delete_option( 'abientot_notification_preferences' );
    143143                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' );
    144147                delete_option( 'abientot_location_weekly_schedule' );
    145148                delete_option( 'abientot_portal_schema_version' );
    … …  
    172175                delete_option( 'abientot_reminders_scan_cursor' );
    173176                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' );
    174182                wp_clear_scheduled_hook( 'abientot_reminders_tick' );
    175183                wp_clear_scheduled_hook( 'abientot_instructions_tick' );
  • abientot/tags/1.1.4/readme.txt

    r3765051 r3767446  
    1 === Abientot ===
     1=== Abientot – Appointment Booking ===
    22Contributors: absoludev, freemius
    33Tags: booking, appointments, scheduling, reservations, calendar
    … …  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.1.3
     7Stable tag: 1.1.4
    88License: GPLv3 or later
    99License URI: https://www.gnu.org/licenses/gpl-3.0.html
    1010
    11 A local-first booking plugin for services, appointments, customers, calendars, and solo business scheduling.
     11Appointment booking for service businesses: unlimited bookings, no booking fees or commission, and your data stays on your own WordPress site.
    1212
    1313== Description ==
    1414
    1515Abientot helps service businesses accept and manage appointments from WordPress without relying on remote booking services by default.
     16
     17The 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.
    1618
    1719The free plugin includes:
    … …  
    2123* Admin booking management with lifecycle statuses, internal notes, activity history, manual pay-on-site status, and CSV export.
    2224* 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.
    2530* A local calendar download after booking, with tentative status while approval is pending.
    2631* Editable cancellation and rescheduling contact instructions in customer confirmation emails.
    … …  
    4954No. The free plugin supports manual pay-on-site status and instructions only.
    5055
     56= Is there a limit on bookings or services? =
     57
     58No. 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
    5160= Can I manage multiple staff members or multiple locations? =
    5261
    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.
     62The 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.
    5463
    5564= Can customers cancel or reschedule online in the free plugin? =
    5665
    5766No. 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
     70Translations 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.
    5871
    5972= What happens when I deactivate the plugin? =
    … …  
    120133
    121134== 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.
    122149
    123150= 1.1.3 =
    … …  
    236263== Upgrade Notice ==
    237264
     265= 1.1.4 =
     266Optional 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
    238268= 1.1.3 =
    239269Security hardening of request handling for the WordPress.org review. No database changes.
  • abientot/trunk/abientot.php

    r3764499 r3767446  
    22
    33/**
    4  * Plugin Name: Abientot
     4 * Plugin Name: Abientot – Appointment Booking
    55 * Description: A local-first WordPress booking plugin for services, appointments, customers, calendars, and business scheduling.
    6  * Version: 1.1.3
     6 * Version: 1.1.4
    77 * Author: AbsoluDev
    88 * Author URI: https://absolu.dev
    … …  
    7777    define( 'ABIENTOT_EDITION', $abientot_edition );
    7878    unset($abientot_edition);
    79     define( 'ABIENTOT_VERSION', '1.1.3' );
     79    define( 'ABIENTOT_VERSION', '1.1.4' );
    8080    define( 'ABIENTOT_PLUGIN_FILE', __FILE__ );
    8181    define( 'ABIENTOT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
  • abientot/trunk/assets/admin/admin.css

    r3764499 r3767446  
    103103}
    104104
     105.abientot-notification-stack .abientot-notification form p {
     106        margin-top: 10px;
     107}
     108
    105109.abientot-notification-stack .abientot-notification.is-leaving {
    106110        opacity: 0;
    … …  
    63516355        font-weight: 650;
    63526356}
     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  
    250250
    251251        function bindAdminNotifications() {
     252                // Notices printed by admin_notices sit outside the app; those marked data-abientot-notice belong to it.
    252253                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]'
    254255                );
    255256                var stack;
    … …  
    278279                        };
    279280
    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');
    281284                        if (!closeButton) {
    282285                                closeButton = document.createElement('button');
    … …  
    289292                        closeButton.addEventListener('click', removeNotice);
    290293                        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                        }
    292302                });
    293303        }
  • abientot/trunk/assets/frontend/booking-form.css

    r3764499 r3767446  
    3636        --abientot-map-height: 220px;
    3737        --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)));
    3940        color: var(--abientot-text);
    4041        font-size: 16px;
    … …  
    183184}
    184185
     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
    185204.abientot-booking__header h2 {
    186205        margin-block: 0 8px;
    … …  
    715734        border: 1px solid var(--abientot-border);
    716735        border-radius: 6px;
    717         background: #f8fafc;
     736        background: var(--abientot-soft);
    718737}
    719738
    … …  
    866885@media (max-width: 767px) {
    867886        .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));
    869888                -webkit-backdrop-filter: none;
    870889                backdrop-filter: none;
    … …  
    876895
    877896        .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));
    879898                -webkit-backdrop-filter: none;
    880899                backdrop-filter: none;
    … …  
    933952                top: auto;
    934953                right: 0;
    935                 bottom: 0;
     954                bottom: var(--abientot-dock-offset, 0px);
    936955                left: 0;
    937956                margin-top: 0;
    … …  
    10501069                z-index: 99998;
    10511070                right: 0;
    1052                 bottom: 0;
     1071                bottom: var(--abientot-dock-offset, 0px);
    10531072                left: 0;
    10541073                margin: 0;
    … …  
    12071226        border-inline-start: 3px solid var(--abientot-primary);
    12081227        border-radius: 6px;
    1209         background: color-mix(in srgb, var(--abientot-primary) 6%, #fff);
     1228        background: var(--abientot-soft);
    12101229        color: var(--abientot-text);
    12111230}
  • abientot/trunk/assets/frontend/booking-form.js

    r3764499 r3767446  
    2222                return document.createTextNode(value || '');
    2323        }
     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;
    24112
    25113        function message(form, value, isError) {
    … …  
    134222        }
    135223
     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
    136251        function updateMobileSelectionDock(root) {
    137252                var services = root.querySelector('.abientot-booking__services');
    … …  
    140255                var rect;
    141256
     257                updateDockOffset(root);
     258
    142259                if (!isMobile || !services || !selection || selection.hidden || !root.classList.contains('is-service-step')) {
    143260                        root.classList.remove('is-selection-docked');
    … …  
    159276                var isMobile = window.matchMedia('(max-width: 767px)').matches;
    160277                var rect;
     278
     279                updateDockOffset(root);
    161280
    162281                if (!isMobile || !panel || !slots || !root.classList.contains('is-time-step')) {
    … …  
    274393                slots.textContent = searchNext ? abientotBooking.strings.searchingDates : abientotBooking.strings.loading;
    275394                data.append('action', 'abientot_public_slots');
    276                 data.append('nonce', abientotBooking.nonce);
    277395                data.append('service_id', service.value);
    278396                root.dispatchEvent(new CustomEvent('abientot:availability-request', { detail: { data: data } }));
    … …  
    303421                }
    304422                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)
    311424                                .then(function (response) {
    312425                                        return response.json();
    … …  
    528641                var data = seriesData(root);
    529642                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)
    532644                        .then(function (response) {
    533645                                return response.json();
    … …  
    638750
    639751                data.append('action', 'abientot_public_book');
    640                 data.append('nonce', abientotBooking.nonce);
    641752                submit.disabled = true;
    642753                message(form, '', false);
    643754
    644                 fetch(abientotBooking.ajaxUrl, {
    645                         method: 'POST',
    646                         credentials: 'same-origin',
    647                         body: data
    648                 })
     755                bookingPost(data)
    649756                        .then(function (response) {
    650757                                return response.json();
    … …  
    10711178                        var data = new FormData();
    10721179                        data.set('action', 'abientot_public_slots');
    1073                         data.set('nonce', abientotBooking.nonce);
    10741180                        data.set('service_id', service.value);
    10751181                        data.set('date', isoDate(month));
    … …  
    10871193                                data.set('location_id', location.value);
    10881194                        }
    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)
    10951196                                .then(function (response) {
    10961197                                        return response.json();
    … …  
    14381539
    14391540                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                        });
    14401550                        surface.addEventListener('keydown', function (event) {
    14411551                                if (event.key === 'Escape') {
    … …  
    14531563                                ).filter(function (item) {
    14541564                                        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;
    14551583                                });
    14561584                                var first = items[0];
  • abientot/trunk/includes/Admin/Admin.php

    r3764499 r3767446  
    5757                $this->admin_data_manager->register();
    5858                RecordPicker::register();
     59                ReviewRequest::register();
    5960                add_action( 'admin_post_abientot_save_settings', array( $this->settings, 'save' ) );
    6061                add_action( 'admin_post_abientot_form_create', array( $this->form_manager, 'create_form' ) );
    … …  
    133134                wp_enqueue_style( 'abientot-workspace', ABIENTOT_PLUGIN_URL . 'assets/admin/workspace.css', array( 'abientot-admin' ), $this->asset_version( 'assets/admin/workspace.css' ) );
    134135                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 );
    135138
    136139                wp_enqueue_style(
  • abientot/trunk/includes/Admin/BookingsManager.php

    r3764499 r3767446  
    12281228                        <dl class="abientot-detail-list">
    12291229                                <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>
    12311231                                <dt><?php esc_html_e( 'Service', 'abientot' ); ?></dt><dd><?php echo esc_html( (string) $booking['service_name'] ); ?></dd>
    12321232                                <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  
    162162                                );
    163163                        }
     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                        );
    164175                }
    165176                $cards = (array) apply_filters( 'abientot_report_cards', $cards, $context );
  • abientot/trunk/includes/Admin/ProBadge.php

    r3764499 r3767446  
    1313
    1414final 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
    1518        public static function badge( string $label = '', bool $linked = true ): string {
    1619                $text = '' !== $label ? $label : __( 'PRO', 'abientot' );
    … …  
    7073                                array(
    7174                                        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 and delivery 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' ) ),
    7376                                        array( __( 'Preparation and follow-up', 'abientot' ), __( 'Not included', 'abientot' ), __( 'Service-specific instructions and consent-based follow-up', 'abientot' ) ),
    7477                                        array( __( 'SMS notifications', 'abientot' ), __( 'Not included', 'abientot' ), __( 'Configured provider and customer opt-in', 'abientot' ) ),
    … …  
    8992                        <div class="abientot-edition-summaries">
    9093                                <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>
    9297                        </div>
    9398                        <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>
    … …  
    103108                        </section>
    104109                        <?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>
    108111                </div>
    109112                <?php
  • abientot/trunk/includes/Admin/TodayManager.php

    r3764499 r3767446  
    7373                <?php if ( $rows ) :
    7474                        ?><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'] ) ) :
    7676        ?><p><?php echo esc_html( $r['internal_note'] ); ?></p><?php
    7777        endif; ?></details></section><?php
  • abientot/trunk/includes/Database/Seeder.php

    r3764499 r3767446  
    4343
    4444                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                );
    45121        }
    46122
  • abientot/trunk/includes/Frontend/BookingForm.php

    r3764499 r3767446  
    9090        private static ?string $availability_session_value = null;
    9191
     92        /** Booking form of the request being handled (0 = main form). */
     93        private int $form_id = 0;
     94
    9295        /** Fields of the booking request being handled, set after its nonce was verified. */
    9396        private ?Input $input = null;
    … …  
    114117                add_action( 'wp_ajax_abientot_public_book', array( $this, 'ajax_book' ) );
    115118                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' ) );
    116121        }
    117122
    … …  
    195200        }
    196201
     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
    197220        public function ajax_slots(): void {
    198221                check_ajax_referer( 'abientot_public_booking', 'nonce' );
    … …  
    391414        private function book_under_lock( string $lock_name ): void {
    392415                // 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 );
    394418                $service_id     = $this->input()->int( 'service_id' );
    395419                $start_local    = $this->normalize_start_local( $this->input()->text( 'start_local' ) );
    … …  
    749773        private function public_confirmation( array $booking, array $settings ): array {
    750774                $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() );
    752777                $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() );
    754779                $status   = $this->statuses->find( (int) $booking['status_id'] ) ?: array();
    755780                $calendar = CalendarFile::render(
    … …  
    787812
    788813        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 );
    790815                $mode     = in_array( $mode, array( 'embedded', 'popup' ), true ) ? $mode : (string) $settings['form_display_mode'];
    791816                $services = $this->public_services();
    … …  
    800825                                'ajaxUrl'             => admin_url( 'admin-ajax.php' ),
    801826                                'nonce'               => wp_create_nonce( 'abientot_public_booking' ),
     827                                'nonceTime'           => time(),
     828                                'lang'                => \Abientot\Support\MultilingualText::current_language(),
    802829                                'displayFormats'      => array(
    803830                                        'date'      => DateTimeHelper::date_display_format(),
    … …  
    13131340         */
    13141341        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() );
    13161343                return array_values(
    13171344                        array_filter(
    … …  
    14771504        private function default_location(): array {
    14781505                $location = $this->locations->default_location();
    1479                 return is_array( $location ) ? $location : array(
     1506                return is_array( $location ) ? \Abientot\Support\MultilingualText::location( $location ) : array(
    14801507                        'id'       => 0,
    14811508                        'name'     => '',
  • abientot/trunk/includes/Plugin.php

    r3764499 r3767446  
    3939    public static function activate() : void {
    4040        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        }
    4147        // ABIENTOT_LOCAL_PREMIUM_END
    4248    }
    … …  
    95101        // ABIENTOT_LOCAL_PREMIUM_END
    96102        ( 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') );
    97106        ( 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 );
    98111        ( new \Abientot\Support\ActivityObserver() )->register();
    99112        add_action( 'admin_bar_menu', array($this, 'dashboard_toolbar_link'), 90 );
  • abientot/trunk/includes/Uninstaller.php

    r3764499 r3767446  
    142142                delete_option( 'abientot_notification_preferences' );
    143143                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' );
    144147                delete_option( 'abientot_location_weekly_schedule' );
    145148                delete_option( 'abientot_portal_schema_version' );
    … …  
    172175                delete_option( 'abientot_reminders_scan_cursor' );
    173176                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' );
    174182                wp_clear_scheduled_hook( 'abientot_reminders_tick' );
    175183                wp_clear_scheduled_hook( 'abientot_instructions_tick' );
  • abientot/trunk/readme.txt

    r3765051 r3767446  
    1 === Abientot ===
     1=== Abientot – Appointment Booking ===
    22Contributors: absoludev, freemius
    33Tags: booking, appointments, scheduling, reservations, calendar
    … …  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.1.3
     7Stable tag: 1.1.4
    88License: GPLv3 or later
    99License URI: https://www.gnu.org/licenses/gpl-3.0.html
    1010
    11 A local-first booking plugin for services, appointments, customers, calendars, and solo business scheduling.
     11Appointment booking for service businesses: unlimited bookings, no booking fees or commission, and your data stays on your own WordPress site.
    1212
    1313== Description ==
    1414
    1515Abientot helps service businesses accept and manage appointments from WordPress without relying on remote booking services by default.
     16
     17The 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.
    1618
    1719The free plugin includes:
    … …  
    2123* Admin booking management with lifecycle statuses, internal notes, activity history, manual pay-on-site status, and CSV export.
    2224* 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.
    2530* A local calendar download after booking, with tentative status while approval is pending.
    2631* Editable cancellation and rescheduling contact instructions in customer confirmation emails.
    … …  
    4954No. The free plugin supports manual pay-on-site status and instructions only.
    5055
     56= Is there a limit on bookings or services? =
     57
     58No. 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
    5160= Can I manage multiple staff members or multiple locations? =
    5261
    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.
     62The 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.
    5463
    5564= Can customers cancel or reschedule online in the free plugin? =
    5665
    5766No. 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
     70Translations 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.
    5871
    5972= What happens when I deactivate the plugin? =
    … …  
    120133
    121134== 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.
    122149
    123150= 1.1.3 =
    … …  
    236263== Upgrade Notice ==
    237264
     265= 1.1.4 =
     266Optional 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
    238268= 1.1.3 =
    239269Security hardening of request handling for the WordPress.org review. No database changes.
Note: See TracChangeset for help on using the changeset viewer.