JezK
Edit File: StatCards.php
<?php namespace WPForms\Admin\Dashboard; use WPForms\Education\ActiveLayer\Helper as ActiveLayerHelper; use WPForms\Helpers\Transient; /** * Dashboard stat cards: catalog, gating, formatting, and row rendering. * * Stat cards are not widgets — they bypass AbstractWidget/WidgetPipeline and * render on their own path (Page → StatCards → stat-card template). One * catalog-driven class; no Pro subclass — values arrive tier-correct from the * cache, and the only tier deltas are presentation. * * @since 2.0.2 */ class StatCards { /** * Container id the class is registered under. * * @since 2.0.2 */ public const ID = 'dashboard_stat_cards'; /** * Transient key caching whether anti-spam tracking is configured. * * @since 2.0.2 */ public const CONFIGURED_TRANSIENT = 'dashboard_stat_cards_anti_spam_configured'; /** * Longest value that still fits a card at its narrowest. * * A card at the four-up minimum leaves about 96px for the value, and the * 24px metric type averages ~12px per glyph. Longer values get a compact * alternative; shorter ones keep their exact figure. * * @since 2.0.2 */ private const VALUE_FIT_CHARS = 8; /** * Number of cards rendered by the last render() call. * * @since 2.0.2 * * @var int */ private $rendered_count = 0; /** * Aggregated dashboard data for the render in progress. * * The cards themselves need only the `stats` block, but the Anti-Spam CTA links to the * form at the top of the Entries widget, which lives in `entries.forms`. * * @since 2.0.2 * * @var array */ private $data = []; /** * Render the stat-card row for the given cached data. * * @since 2.0.2 * * @param array $data Aggregated dashboard data. * @param AccessContext $access Access context. * * @return string */ public function render( array $data, AccessContext $access ): string { $html = ''; $this->rendered_count = 0; $this->data = $data; $stats = is_array( $data['stats'] ?? null ) ? $data['stats'] : []; foreach ( $this->get_catalog() as $id => $card ) { if ( ! $this->is_visible( $card, $access ) ) { continue; } ++$this->rendered_count; $raw = $stats[ $id ] ?? 0; $value = $this->format_value( $raw, $card['format'] ); $html .= wpforms_render( 'admin/dashboard/stat-card', [ 'id' => $id, 'icon' => $card['icon'], 'tint' => $card['tint'], 'label' => $this->get_label( $card, $access ), 'label_short' => $card['label_short'] ?? '', 'value' => $value, 'value_short' => $this->get_short_value( $raw, $card['format'], $value ), 'cta' => $this->get_cta( $card ), ], true ); } return $html; } /** * Get the count-driven row modifier class for the last rendered row. * * The SCSS maps each count to the row distribution (max 4 per row; * 5 → 3+2, 7 → 4+3). * * @since 2.0.2 * * @return string */ public function get_row_class(): string { return $this->rendered_count > 0 ? 'wpforms-dashboard-stat-cards-count-' . $this->rendered_count : ''; } /** * Format the raw stats into display-ready strings for the AJAX payload. * * Shared with the Pro Ajax::get_stats() response so the initial render and * the refresh can never disagree. Never mutates the cached array upstream — * the cache stays raw. * * @since 2.0.2 * * @param array $stats Raw cached `stats` aggregates. * * @return array `[ 'value' => string, 'value_short' => string ]` per card id. */ public function format( array $stats ): array { $formatted = []; foreach ( $this->get_catalog() as $id => $card ) { if ( ! array_key_exists( $id, $stats ) ) { continue; } $value = $this->format_value( $stats[ $id ], $card['format'] ); $formatted[ $id ] = [ 'value' => $value, 'value_short' => $this->get_short_value( $stats[ $id ], $card['format'], $value ), ]; } return $formatted; } /** * The card catalog. * * Keys are the cached `stats` keys and the `data-stat-card` DOM ids. Array * order is the render order. * * @since 2.0.2 * * @return array */ private function get_catalog(): array { /** * Filter the Dashboard stat cards catalog. * * Keys are the cached `stats` keys and the `data-stat-card` DOM ids; * array order is the render order. * * @since 2.0.2 * * @param array $catalog Stat cards catalog. */ return (array) apply_filters( 'wpforms_admin_dashboard_stat_cards_get_catalog', [ 'forms' => [ 'icon' => 'fa-brands fa-wpforms', 'tint' => 'orange', 'label' => __( 'Forms', 'wpforms-lite' ), 'format' => 'count', ], 'total_entries' => [ 'icon' => 'fa-regular fa-copy', 'tint' => 'orange', 'label' => __( 'Total Entries', 'wpforms-lite' ), 'label_lite' => __( 'Entries Backed Up', 'wpforms-lite' ), 'format' => 'count', ], 'total_views' => [ 'icon' => 'fa-regular fa-eye', 'tint' => 'blue', 'label' => __( 'Total Views', 'wpforms-lite' ), 'format' => 'count', ], 'spam_entries' => [ 'icon' => 'fa-solid fa-ban', 'tint' => 'red', 'label' => __( 'Spam Entries', 'wpforms-lite' ), 'format' => 'count', 'show' => 'pro_plugin', 'cta' => 'anti_spam', ], 'total_payments' => [ 'icon' => 'fa-regular fa-credit-card', 'tint' => 'green', 'label' => __( 'Total Payments', 'wpforms-lite' ), 'format' => 'count', 'show' => 'gateway', ], 'total_sales' => [ 'icon' => 'fa-regular fa-money-bill-1', 'tint' => 'green', 'label' => __( 'Total Sales', 'wpforms-lite' ), 'format' => 'amount', 'show' => 'gateway', ], 'coupons_redeemed' => [ 'icon' => 'fa-solid fa-tags', 'tint' => 'green', 'label' => __( 'Coupons Redeemed', 'wpforms-lite' ), 'label_short' => __( 'Coupons', 'wpforms-lite' ), 'format' => 'count', 'show' => 'gateway', 'cta' => 'coupons', ], 'total_refunded' => [ 'icon' => 'fa-solid fa-rotate-left', 'tint' => 'gray', 'label' => __( 'Total Refunded', 'wpforms-lite' ), 'format' => 'amount', 'show' => 'gateway', ], ] ); } /** * Whether a card renders. Evaluated at page render only — the AJAX refresh * never adds or removes cards. * * @since 2.0.2 * * @param array $card Catalog entry. * @param AccessContext $access Access context. * * @return bool */ private function is_visible( array $card, AccessContext $access ): bool { switch ( $card['show'] ?? '' ) { case 'pro_plugin': // Spam storage is paid-plugin-only — the card is Basic+. return $access->is_pro(); case 'gateway': // Payment cards are hidden for now: a 2x4 top row is too busy, and the // Payments widget below already covers these stats. Restore by returning // Helpers::is_payment_gateway_connected() — the UI gate; Cache::has_payment_gateway() // is the orthogonal data gate. return false; default: // No plan or gateway gate — always visible. return true; } } /** * Resolve the card label for the current tier. * * @since 2.0.2 * * @param array $card Catalog entry. * @param AccessContext $access Access context. * * @return string */ private function get_label( array $card, AccessContext $access ): string { return ! $access->is_pro() && isset( $card['label_lite'] ) ? $card['label_lite'] : $card['label']; } /** * Format a raw stat value for display. * * @since 2.0.2 * * @param mixed $value Raw value (int count or string amount). * @param string $format Format type: `count` or `amount`. * * @return string */ private function format_value( $value, string $format ): string { $formatted = $format === 'amount' ? wpforms_format_amount( wpforms_sanitize_amount( (string) $value ), true ) : number_format_i18n( (int) $value ); return html_entity_decode( $formatted, ENT_QUOTES, 'UTF-8' ); } /** * Compact alternative for a value too long to fit a narrow card. * * Returns an empty string when the full value already fits, so short * figures never lose precision they did not need to. A container query in * the stat-card SCSS decides which of the two is shown. * * @since 2.0.2 * * @param mixed $value Raw value (int count or string amount). * @param string $format Format type: `count` or `amount`. * @param string $full Formatted full value. * * @return string */ private function get_short_value( $value, string $format, string $full ): string { if ( mb_strlen( $full ) <= self::VALUE_FIT_CHARS ) { return ''; } $number = $format === 'amount' ? (float) wpforms_sanitize_amount( (string) $value ) : (float) $value; $units = [ /* translators: Abbreviated billions suffix on a dashboard metric, e.g. $1.2B. */ 1000000000 => _x( 'B', 'abbreviated billions', 'wpforms-lite' ), /* translators: Abbreviated millions suffix on a dashboard metric, e.g. $1.2M. */ 1000000 => _x( 'M', 'abbreviated millions', 'wpforms-lite' ), /* translators: Abbreviated thousands suffix on a dashboard metric, e.g. $1.2K. */ 1000 => _x( 'K', 'abbreviated thousands', 'wpforms-lite' ), ]; foreach ( $units as $divisor => $suffix ) { if ( abs( $number ) < $divisor ) { continue; } $mantissa = $number / $divisor; // Hold three significant figures at every scale — `$1.2M`, `$12.3M`, `$576K`. // Dropping the decimal at 100 also sidesteps trimming a locale-aware `.0`. $compact = number_format_i18n( $mantissa, abs( $mantissa ) < 100 ? 1 : 0 ) . $suffix; return $format === 'amount' ? $this->add_currency_symbol( $compact ) : $compact; } // A long value below the smallest unit is a count with wide separators; leave it be. return ''; } /** * Attach the configured currency symbol to an already-abbreviated number. * * Mirrors the symbol placement in wpforms_format_amount(), which cannot be * reused here: it always renders the currency's fixed decimal count, and an * abbreviated figure needs at most one. * * @since 2.0.2 * * @param string $number Abbreviated number, unit suffix included. * * @return string */ private function add_currency_symbol( string $number ): string { $currency = strtoupper( wpforms_get_currency() ); $currencies = wpforms_get_currencies(); $symbol = html_entity_decode( $currencies[ $currency ]['symbol'] ?? '', ENT_QUOTES, 'UTF-8' ); if ( $symbol === '' ) { return $number; } if ( ( $currencies[ $currency ]['symbol_pos'] ?? 'left' ) !== 'right' ) { return $symbol . $number; } /** This filter is documented in wpforms/includes/functions/payments.php. */ $padding = apply_filters( 'wpforms_currency_symbol_padding', ' ' ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName -- Owned by wpforms_format_amount(); re-applied so the abbreviated amount pads identically. return $number . $padding . $symbol; } /** * Resolve a card's conditional CTA. Empty array = no CTA. * * @since 2.0.2 * * @param array $card Catalog entry. * * @return array */ private function get_cta( array $card ): array { $type = $card['cta'] ?? ''; if ( $type === 'anti_spam' ) { return $this->get_anti_spam_cta(); } if ( $type === 'coupons' ) { return $this->get_coupons_cta(); } return []; } /** * The Spam Entries card's Anti-Spam Setup CTA. * * Shown until some form logs spam entries. Value and CTA are independent axes — * a non-zero spam count with a visible CTA is a legitimate state. * * @since 2.0.2 * * @return array */ private function get_anti_spam_cta(): array { if ( ! $this->is_education_available() || $this->is_anti_spam_configured() ) { return []; } $modal = ActiveLayerHelper::get_modal_data(); $action = $modal['attrs']['data-action'] ?? ''; if ( $action === 'install' ) { $modal['link_text'] = __( 'Install ActiveLayer', 'wpforms-lite' ); } elseif ( $action === 'activate' ) { $modal['link_text'] = __( 'Activate ActiveLayer', 'wpforms-lite' ); } else { $modal['link_text'] = __( 'ActiveLayer Dashboard', 'wpforms-lite' ); } return [ 'type' => 'anti_spam', 'label' => __( 'Anti-Spam Setup', 'wpforms-lite' ), 'label_short' => __( 'Setup', 'wpforms-lite' ), 'tooltip' => [ 'text' => sprintf( wp_kses( /* translators: %1$s - Store Spam Entries setting name, %2$s - ActiveLayer plugin name. */ __( 'Enable %1$s in your form settings, or install %2$s for the best protection.', 'wpforms-lite' ), [ 'strong' => [] ] ), $this->get_store_spam_entries_link(), '<strong>ActiveLayer</strong>' ), 'button' => $modal, 'learn_more' => wpforms_utm_link( 'https://wpforms.com/docs/how-to-prevent-spam-in-wpforms/', 'Dashboard - Stat Cards', 'Anti-Spam Setup' ), ], ]; } /** * Get the "Store Spam Entries in the Database" label, linked to a form's spam settings. * * The setting lives on the form, not in global settings, so the promo has to name a * concrete form. It links to the form at the top of the Entries widget — the busiest one * in the selected range, and the one the reader is already looking at. * * @since 2.0.2 * * @return string */ private function get_store_spam_entries_link(): string { $label = '<strong>' . esc_html__( 'Store Spam Entries in the Database', 'wpforms-lite' ) . '</strong>'; $form_id = $this->get_link_form_id(); if ( $form_id === 0 ) { return $label; } $url = add_query_arg( [ 'page' => 'wpforms-builder', 'view' => 'settings', 'section' => 'anti_spam', 'form_id' => $form_id, ], admin_url( 'admin.php' ) ); return '<a href="' . esc_url( $url ) . '">' . $label . '</a>'; } /** * Resolve the form the Anti-Spam promo links to: the first row of the Entries widget. * * `entries.forms` is the count-sorted superset the widget builds its rows from, so its * first key is the range's busiest form. It holds only forms with entries in the range, * so a range with none falls back to the newest published form — any form will do at that * point, since the promo is about switching the setting on at all. * * @since 2.0.2 * * @return int Form ID, or `0` when the site has no form to link to. */ private function get_link_form_id(): int { $forms = is_array( $this->data['entries']['forms'] ?? null ) ? $this->data['entries']['forms'] : []; // `reset()` + `key()` rather than `array_key_first()`, which needs PHP 7.3. if ( $forms ) { reset( $forms ); return absint( key( $forms ) ); } $newest = wpforms()->obj( 'form' )->get( '', [ 'fields' => 'ids', 'orderby' => 'date', 'order' => 'DESC', 'posts_per_page' => 1, // `get_multiple()` defaults to `nopaging`, which would ignore the limit above. 'nopaging' => false, ] ); return empty( $newest ) ? 0 : absint( $newest[0] ); } /** * Whether spam entries are already being logged. * * A cached scan of published forms for the `store_spam_entries` builder toggle, and * nothing else: this card promotes spam-entry logging, which neither ActiveLayer nor a * CAPTCHA provides, so a connected ActiveLayer does not satisfy it. The Spam & Security * Checkup widget below is what promotes those. * * Deliberately ignores `anti_spam.filtering_store_spam` — the builder auto-enables it for * new forms, so counting it would satisfy the condition on virtually every site. * * @since 2.0.2 * * @return bool */ private function is_anti_spam_configured(): bool { $cached = Transient::get( self::CONFIGURED_TRANSIENT ); // Transient::get() returns false on a miss; the yes/no sentinel keeps a "not configured" result distinct from that. if ( $cached !== false ) { return $cached === 'yes'; } $is_configured = $this->query_anti_spam_configured(); Transient::set( self::CONFIGURED_TRANSIENT, $is_configured ? 'yes' : 'no', DAY_IN_SECONDS ); return $is_configured; } /** * Scan published forms for the `store_spam_entries` builder toggle. * * A prepared LIKE pre-filter fetches only the forms that mention the setting, * instead of loading every published form. * * @since 2.0.2 * * @return bool */ private function query_anti_spam_configured(): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $candidates = (array) $wpdb->get_col( $wpdb->prepare( "SELECT post_content FROM {$wpdb->posts} WHERE post_type = 'wpforms' AND post_status = 'publish' AND post_content LIKE %s", '%' . $wpdb->esc_like( 'store_spam_entries' ) . '%' ) ); foreach ( $candidates as $content ) { $form_data = wpforms_decode( $content ); if ( ! empty( $form_data['settings']['store_spam_entries'] ) ) { return true; } } return false; } /** * Clear the cached anti-spam-configured flag. * * @since 2.0.2 */ public static function clear_configured_cache(): void { Transient::delete( self::CONFIGURED_TRANSIENT ); } /** * Whether the admin education system is available. * * @since 2.0.2 * * @return bool */ private function is_education_available(): bool { // Unregistered when the `wpforms_admin_education` kill switch is off. return (bool) wpforms()->obj( 'education_feature_tooltip' ); } /** * The Coupons Redeemed card's per-plan CTA. * * Consumes the Addons API `action` field — no custom entitlement logic. * * @since 2.0.2 * * @return array */ private function get_coupons_cta(): array { $addons = wpforms()->obj( 'addons' ); $addon = $addons ? $addons->get_addon( 'coupons' ) : []; $action = $addon['action'] ?? 'upgrade'; if ( $action === '' || $action === 'incompatible' ) { // Active (or version-blocked edge): render the count, no CTA. return []; } if ( $action === 'install' || $action === 'activate' ) { // The one-click flow rides the education modal JS. if ( ! $this->is_education_available() ) { return []; } // An already-installed-but-inactive addon only needs activating, so the CTA // says so — matching the Payments widget's Coupons tile. Keyed off $action // rather than branched, to keep this method under the complexity ceiling. $labels = [ 'install' => [ 'label' => __( 'Install & Activate', 'wpforms-lite' ), 'short' => __( 'Install', 'wpforms-lite' ), ], 'activate' => [ 'label' => __( 'Activate', 'wpforms-lite' ), 'short' => __( 'Activate', 'wpforms-lite' ), ], ]; return [ 'type' => 'education', 'replace_value' => true, 'label' => $labels[ $action ]['label'], 'label_short' => $labels[ $action ]['short'], 'url' => '#', 'attrs' => [ 'class' => 'education-modal', 'data-action' => $action, 'data-name' => $addon['modal_name'] ?? 'Coupons addon', 'data-path' => $addon['path'] ?? '', 'data-url' => $addon['url'] ?? '', 'data-type' => 'addon', 'data-nonce' => $addon['nonce'] ?? '', ], ]; } // `upgrade` / `license`. return [ 'type' => 'link', 'replace_value' => true, 'label' => __( 'Upgrade', 'wpforms-lite' ), 'url' => wpforms_admin_upgrade_link( 'Dashboard - Stat Cards', 'Coupons Redeemed' ), 'attrs' => [ 'target' => '_blank', 'rel' => 'noopener noreferrer', ], ]; } }