$args Query arguments. * @return string */ private static function url( array $args = array() ) { return add_query_arg( array_merge( array( 'page' => self::PAGE ), $args ), admin_url( 'admin.php' ) ); } /** * Show the "connect your website" notice once, right after activation. */ public static function activate() { set_transient( 'tourtify_activated', 1, MINUTE_IN_SECONDS ); } /** * The one-time notice after activation (it removes itself). */ public static function activated_notice() { if ( ! get_transient( 'tourtify_activated' ) || ! current_user_can( 'manage_options' ) ) { return; } delete_transient( 'tourtify_activated' ); if ( '' !== Tourtify_Client::site() ) { return; } printf( '

%s %s

', esc_html__( 'Tourtify is active.', 'tourtify' ), esc_url( self::url() ), esc_html__( 'Connect your Tourtify website', 'tourtify' ) ); } /** * Suggested text for the site's privacy policy (Settings › Privacy › Policy guide). */ public static function privacy_policy() { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $text = '

' . esc_html__( 'Tourtify takes the bookings made on this site. Add something like this to your privacy policy, and link to your own booking terms.', 'tourtify' ) . '

' . '' . esc_html__( 'Suggested text:', 'tourtify' ) . ' ' . '

' . esc_html__( 'Bookings on this website are handled by Tourtify, our booking system. When you click Book now, the booking window loads from our Tourtify website, and the details you enter there (such as your name, email address, phone number, travellers and payment) are sent to Tourtify to make and manage your booking. The booking also records the page you booked from and any campaign tags in its address. Payments are processed by our payment provider on a secure checkout page.', 'tourtify' ) . '

' . '

' . esc_html__( 'Pages that show our reviews load them from our Tourtify website. Tour lists are loaded by this website’s server and send no information about you.', 'tourtify' ) . '

' . '

' . sprintf( /* translators: %s: link to Tourtify's privacy policy */ esc_html__( 'Tourtify’s privacy policy: %s', 'tourtify' ), 'https://tourtify.com/legal/privacy/' ) . '

'; wp_add_privacy_policy_content( __( 'Tourtify', 'tourtify' ), wp_kses_post( $text ) ); } /** * "Settings" next to Deactivate on the Plugins screen. * * @param array $links Existing links. * @return array */ public static function action_links( $links ) { array_unshift( $links, '' . esc_html__( 'Settings', 'tourtify' ) . '' ); return $links; } /** * The Tourtify menu. */ public static function menu() { // The Tourtify mark (apps/api/public/favicon.svg) in one colour, so WordPress tints it like the other menu // icons: the tile's outline, the near peak, and the far peak a shade lighter (fill-opacity survives the tint). $svg = '' . '' . ''; $icon = 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- a menu icon as a data URI. add_menu_page( __( 'Tourtify', 'tourtify' ), __( 'Tourtify', 'tourtify' ), 'manage_options', self::PAGE, array( __CLASS__, 'page' ), $icon, 58 ); } /** * Styles and the copy buttons, only on the Tourtify screen. * * @param string $hook The admin page's hook name. */ public static function assets( $hook ) { if ( 'toplevel_page_' . self::PAGE !== $hook ) { return; } // The card styles too, for the Display tab's preview. wp_enqueue_style( 'tourtify-admin', TOURTIFY_URL . 'assets/css/admin.css', array( 'tourtify' ), TOURTIFY_VERSION ); wp_enqueue_script( 'tourtify-admin', TOURTIFY_URL . 'assets/js/admin.js', array( 'wp-i18n' ), TOURTIFY_VERSION, true ); wp_set_script_translations( 'tourtify-admin', 'tourtify' ); } /** * The settings, saved through the Settings API. */ public static function settings() { register_setting( self::PAGE, Tourtify_Client::OPTION, array( 'type' => 'array', 'sanitize_callback' => array( __CLASS__, 'sanitize' ), 'default' => array(), ) ); } /** * Check a new address against the Tourtify website before keeping it. * * @param mixed $input The submitted settings. * @return array */ public static function sanitize( $input ) { // The plugin itself saving the connection (Connect with Tourtify, Disconnect): keep it as it is. if ( Tourtify_Client::$writing ) { return array_intersect_key( wp_parse_args( is_array( $input ) ? $input : array(), Tourtify_Client::defaults() ), Tourtify_Client::defaults() ); } $old = Tourtify_Client::settings(); $input = is_array( $input ) ? $input : array(); // The address and key never come from a form: only Connect with Tourtify proves the business is yours. $out = array_merge( $old, array( 'currency' => strtoupper( substr( sanitize_key( (string) ( $input['currency'] ?? $old['currency'] ) ), 0, 3 ) ), 'button_label' => sanitize_text_field( wp_unslash( (string) ( $input['button_label'] ?? $old['button_label'] ) ) ), ) ); // The Display tab: unticked boxes aren't sent, so the form says it was there. if ( ! empty( $input['display'] ) ) { $out['layout'] = 'list' === ( $input['layout'] ?? '' ) ? 'list' : 'grid'; $out['columns'] = max( 1, min( 4, (int) ( $input['columns'] ?? 3 ) ) ); foreach ( array( 'show_image', 'show_meta', 'show_summary', 'show_price', 'show_button', 'load_styles' ) as $key ) { $out[ $key ] = ! empty( $input[ $key ] ); } } return array_intersect_key( $out, Tourtify_Client::defaults() ); } /** * "Connect with Tourtify": send the person to Tourtify to pick their business, with a one-time state to check on return. */ public static function connect_start() { self::guard( 'tourtify_connect' ); $state = wp_generate_password( 32, false, false ); $verifier = wp_generate_password( 64, false, false ); set_transient( 'tourtify_connect_' . get_current_user_id(), array( 'state' => $state, 'verifier' => $verifier, ), 15 * MINUTE_IN_SECONDS ); // PKCE: Tourtify gets only the challenge; the code it sends back is useless without this site's verifier. $challenge = rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url of a hash (PKCE). // The plugin's own admin page on this site, so a redirect to it is never an open redirect. // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Tourtify's console is another site by design. wp_redirect( Tourtify_Client::app_link( '/connect/wordpress', 'connect', array( 'return' => self::url(), 'state' => $state, 'site_name' => wp_strip_all_tags( get_bloginfo( 'name' ) ), 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ) ) ); exit; } /** * Back from Tourtify with a one-time code: check the state, swap the code (with the verifier) for this site's key * and the business's website address, then save them. Nothing in the address bar is trusted. */ public static function connect_return() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the one-time state stands in for a nonce here. if ( ! isset( $_GET['page'], $_GET['tourtify_state'] ) || self::PAGE !== $_GET['page'] || ! current_user_can( 'manage_options' ) ) { return; } // Cancelled in Tourtify: say so, whatever the state (nothing is exchanged). An older tab's Cancel must not // read as an expired link, nor clear the connection started in a newer tab. if ( isset( $_GET['tourtify_error'] ) || ! isset( $_GET['tourtify_code'] ) ) { self::back( 'cancelled' ); } $key = 'tourtify_connect_' . get_current_user_id(); $started = get_transient( $key ); $state = sanitize_text_field( wp_unslash( (string) $_GET['tourtify_state'] ) ); if ( ! is_array( $started ) || ! hash_equals( (string) ( $started['state'] ?? '' ), $state ) ) { self::back( 'expired' ); } delete_transient( $key ); $code = sanitize_text_field( wp_unslash( (string) $_GET['tourtify_code'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $connection = Tourtify_Client::exchange( $code, (string) $started['verifier'] ); if ( is_wp_error( $connection ) ) { self::back( 'failed' ); } Tourtify_Client::save_connection( $connection['site_url'], $connection['key'] ); self::back( 'connected' ); } /** * Disconnect: revoke this site's key in Tourtify, then forget it (display settings are kept). */ public static function disconnect() { self::guard( 'tourtify_disconnect' ); Tourtify_Client::revoke(); Tourtify_Client::save_connection( '', '' ); self::back( 'disconnected' ); } /** * Refresh tours now (instead of within ten minutes). */ public static function refresh() { self::guard( 'tourtify_refresh' ); Tourtify_Client::flush(); self::back( 'refreshed' ); } /** * "Create a Tours page": a draft page with the tours and reviews, opened in the editor. */ public static function create_page() { self::guard( 'tourtify_create_page', 'edit_pages' ); $content = '

' . esc_html__( 'Choose a date and book online in a couple of minutes.', 'tourtify' ) . '

' . '' . '

' . esc_html__( 'What travellers say', 'tourtify' ) . '

' . ''; $id = wp_insert_post( array( 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => __( 'Tours', 'tourtify' ), 'post_content' => $content, ), true ); if ( is_wp_error( $id ) ) { self::back( 'failed' ); } wp_safe_redirect( (string) get_edit_post_link( (int) $id, 'raw' ) ); exit; } /** * Capability and nonce for admin-post actions. * * @param string $action The nonce action. * @param string $capability Who may do it. */ private static function guard( $action, $capability = 'manage_options' ) { if ( ! current_user_can( $capability ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'tourtify' ), '', array( 'response' => 403 ) ); } check_admin_referer( $action ); } /** * Back to the screen with a message. * * @param string $notice Which message. */ private static function back( $notice ) { wp_safe_redirect( self::url( array( 'tourtify_notice' => $notice ) ) ); exit; } /** * A form that posts one admin-post action. * * @param string $action The action (also the nonce action). * @param string $label The button text. * @param string $class Button classes. * @return string */ private static function action_button( $action, $label, $class = 'button' ) { return '
' . '' . wp_nonce_field( $action, '_wpnonce', true, false ) . '
'; } /** * Pages that already use a Tourtify block or shortcode (for the checklist). * * @return WP_Post[] */ private static function pages_using_tourtify() { $found = array(); foreach ( array( 'wp:tourtify/', '[tourtify_' ) as $needle ) { $found = array_merge( $found, get_posts( array( 'post_type' => array( 'page', 'post' ), 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), 's' => $needle, 'posts_per_page' => 3, 'fields' => 'all', ) ) ); } // A page can match both searches. $unique = array(); foreach ( $found as $post ) { $unique[ $post->ID ] = $post; } return array_values( $unique ); } /** * The message for ?tourtify_notice=… */ private static function notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. $code = isset( $_GET['tourtify_notice'] ) ? sanitize_key( wp_unslash( (string) $_GET['tourtify_notice'] ) ) : ''; $messages = array( 'connected' => array( 'success', __( 'Your Tourtify website is connected. Add your tours to a page to start taking bookings.', 'tourtify' ) ), 'disconnected' => array( 'info', __( 'Disconnected. Tourtify blocks and shortcodes show nothing until you connect again.', 'tourtify' ) ), 'refreshed' => array( 'success', __( 'Tours refreshed.', 'tourtify' ) ), 'cancelled' => array( 'info', __( 'Connecting was cancelled.', 'tourtify' ) ), 'expired' => array( 'warning', __( 'That connection link has expired. Please click Connect with Tourtify again.', 'tourtify' ) ), 'failed' => array( 'error', __( 'Tourtify could not confirm the connection. Please click Connect with Tourtify again.', 'tourtify' ) ), ); if ( isset( $messages[ $code ] ) ) { printf( '

%s

', esc_attr( $messages[ $code ][0] ), esc_html( $messages[ $code ][1] ) ); } } /** * The screen. */ public static function page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $site = Tourtify_Client::site(); ?>

__( 'Overview', 'tourtify' ), /* translators: %d: number of published tours */ 'tours' => sprintf( __( 'Tours (%d)', 'tourtify' ), count( $tours ) ), 'display' => __( 'Display', 'tourtify' ), 'developers' => __( 'Developers', 'tourtify' ), ); $tab = isset( $tabs[ $tab ] ) ? $tab : 'overview'; ?>

get_error_message() ); ?>

·

  1. ID ) ) . '">' . esc_html( get_the_title( $p ) ) . ''; }, array_slice( $pages, 0, 3 ) ) ), array( 'a' => array( 'href' => array() ) ) ); ?>

[tourtify_tours limit="6" columns="3"]

[tourtify_book tour="your-tour"]

[tourtify_reviews layout="list" limit="3"]

> $tours The tours. * @param string $site The connected website. */ private static function tab_tours( array $tours, $site ) { ?>

$settings The settings. * @param array|null $feed The tours feed, when the site answered. */ private static function tab_display( array $settings, $feed ) { $option = Tourtify_Client::OPTION; $parts = array( 'show_image' => __( 'Photo', 'tourtify' ), 'show_meta' => __( 'Duration and rating', 'tourtify' ), 'show_summary' => __( 'Short description', 'tourtify' ), 'show_price' => __( '“From” price', 'tourtify' ), 'show_button' => __( 'Book now button', 'tourtify' ), ); ?>

$label ) : ?>

1 ) : ?>

1 ) ) ); if ( is_wp_error( $preview ) || ! $preview ) : ?>

1, 'columns' => 1 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by the renderer. ?>

__( 'A tour list’s options after defaults (layout, columns, sort, parts to show…).', 'tourtify' ), 'tourtify_tours' => __( 'The tours a list shows: add, remove or reorder them.', 'tourtify' ), 'tourtify_card_html' => __( 'One card’s finished HTML.', 'tourtify' ), 'tourtify_template' => __( 'Which template file renders a card.', 'tourtify' ), 'tourtify_button_attributes' => __( 'A Book now button’s attributes (classes, data attributes).', 'tourtify' ), 'tourtify_button_label' => __( 'A Book now button’s text.', 'tourtify' ), 'tourtify_button_html' => __( 'A Book now button’s finished HTML.', 'tourtify' ), 'tourtify_load_styles' => __( 'Whether the plugin’s card styles load (return false to use only your own).', 'tourtify' ), 'tourtify_feed' => __( 'The tours feed as received, before it is cached.', 'tourtify' ), 'tourtify_cache_ttl' => __( 'How long tours are cached, in seconds (default 600).', 'tourtify' ), 'tourtify_request_args' => __( 'The request to your Tourtify website (timeout, headers, proxy).', 'tourtify' ), ); $actions = array( 'tourtify_before_tours' => __( 'Before a list of cards (receives the tours and the options).', 'tourtify' ), 'tourtify_after_tours' => __( 'After a list of cards.', 'tourtify' ), 'tourtify_card_start' => __( 'At the start of each card (receives the tour).', 'tourtify' ), 'tourtify_card_end' => __( 'At the end of each card.', 'tourtify' ), ); $functions = array( 'tourtify_get_tours( $args )' => __( 'Your published tours as arrays, filtered and sorted like a tour list.', 'tourtify' ), 'tourtify_get_tour( $slug )' => __( 'One tour, or null.', 'tourtify' ), 'tourtify_book_button( $slug, $args )' => __( 'A Book now button (args: label, title).', 'tourtify' ), 'tourtify_tours( $args )' => __( 'Tour cards, as the block renders them.', 'tourtify' ), 'tourtify_site_url()' => __( 'Your Tourtify website’s address.', 'tourtify' ), ); ?>

wp-content/plugins/tourtify/templates/card.php → wp-content/themes/your-theme/tourtify/card.php

.tourtify-tours { --tourtify-gap: 2rem; --tourtify-radius: 0; --tourtify-image-ratio: 16 / 9; --tourtify-card-min: 18rem; --tourtify-card-bg: #fafafa; --tourtify-border: none; }

window.addEventListener( 'tourtify:checkout', e => gtag( 'event', 'begin_checkout', { item_id: e.detail.tour } ) );

[tourtify_tours limit="6" columns="3" layout="grid|list" destination="" activity="" category="" tours="slug-1,slug-2" orderby="default|price|duration|rating|title|next_departure" order="asc|desc" show_image="yes" show_meta="yes" show_summary="yes" show_price="yes" show_button="yes" button_label=""]

[tourtify_book tour="slug" label="" title=""]

[tourtify_reviews layout="list|badge" limit="3" tour="" theme="light|dark"]

$rows Name => what it does. */ private static function reference( $title, array $rows ) { ?>

$what ) : ?>
' . '' . ''; } }