` block of its own * and **loads no dashicons**, so class hooks and icon fonts are both unavailable. */ class ProElementPreview { /** * Element types this class can mock. * * @return array */ public static function supported_types(): array { return [ 'donors_wall', 'faq', 'video' ]; } /** * Render a mock preview for a Pro element. * * @param string $type Element type. * @param array $settings Element settings as saved in the layout. Honoured * where practical so a downgraded campaign still * shows the author's real configuration. * @param bool $is_preview Must be strictly true. See the class docblock. * @return string HTML, or '' when not a builder preview / unsupported type. */ public static function render( string $type, array $settings, bool $is_preview ): string { if ( true !== $is_preview ) { return ''; } if ( ! in_array( $type, self::supported_types(), true ) ) { return ''; } // Whether the mock needs the "sample data" disclosure under it. Donors // Wall invents donors and FAQ echoes the author's own questions back at a // sample scale, so both must say so. The Video mock displays no data at // all — it is a drawn poster frame with no names, figures or copy that // could be mistaken for the user's — so the note has nothing to disclose // and only adds a line of grey text under the artwork. $show_note = true; switch ( $type ) { case 'donors_wall': $body = self::donors_wall( $settings ); $label = __( 'Donors Wall', 'better-payment' ); break; case 'faq': $body = self::faq( $settings ); $label = __( 'FAQ', 'better-payment' ); break; default: $body = self::video( $settings ); $label = __( 'Video', 'better-payment' ); $show_note = false; break; } return self::wrap( $body, $label, $show_note ); } /** * Chrome around every mock: a dashed frame plus a PRO ribbon, so a demo is * never mistaken for the real thing. * * @param string $body Already-escaped inner HTML. * @param string $label Element label. * @param bool $show_note Whether to print the "sample data" disclosure. * Defaults true: a mock that shows fabricated data * must say so, and that is the common case. Only the * Video frame opts out, because it displays no data * to disclose. The PRO ribbon is NOT optional — it is * what stops any mock being read as the real element. * @return string */ private static function wrap( string $body, string $label, bool $show_note = true ): string { $frame = 'position:relative;border:1px dashed #c3c4c7;border-radius:8px;' . 'padding:28px 16px 16px;margin:0 0 4px;background:#fff;'; // Pro-orange gradient — the exact fill of the "Get PRO to Unlock" button // (`.bp-lc-hotspot__pro-lock-btn`), so the ribbon reads as the same Pro // upsell affordance rather than a second, unrelated brand colour. $ribbon = 'position:absolute;top:0;left:0;display:inline-flex;align-items:center;gap:6px;' . 'background:linear-gradient(135deg,#f6a821,#ec6a2b);color:#fff;font-size:10px;font-weight:700;letter-spacing:.06em;' . 'text-transform:uppercase;padding:3px 10px;border-radius:8px 0 8px 0;'; $note = 'margin:12px 0 0;font-size:11px;line-height:1.5;color:#787c82;text-align:center;'; ob_start(); ?>

$4,820 37

+

` would mean Lite reproducing Pro's URL * parsing and, for self-hosted files, its oEmbed HTTP call — exactly the * business logic that must stay in Pro. It would also let a free install * ship a working Video element, which is the feature being sold. * * The frame is **drawn**, not fetched. It deliberately does not pull the real * thumbnail from `img.youtube.com`, which would have meant: * - parsing a video ID out of the URL — the Pro-owned logic above; * - an outbound request to Google on every builder preview render, carrying * the admin's IP and referrer, which no one opted into; * - a YouTube-only result, since Vimeo thumbnails need an oEmbed API call. * Real artwork for one provider and a grey box for the others is a worse, * less predictable preview than one honest frame for all three. * * What it must NOT do is invent facts. The play button, scrubber and duration * pill are chrome — they say "this is a video player" and cannot be read as a * claim about the user's campaign. A plausible-looking video title or a * "12K views" counter would be, and is the same mistake the fictional donor * names in {@see self::sample_donors()} are careful to avoid. * * That is also why nothing is printed *under* the frame. A channel row (avatar, * title, source host) lived here briefly and was cut: it added a second block * of grey text below the artwork for no information the user cannot see in the * settings panel, and the title line was placeholder copy dressed as content. * The poster is the whole element. Because it displays no data, `render()` * also passes `$show_note = false` to {@see self::wrap()} — there is no sample * data here to disclose, unlike Donors Wall and FAQ. * * With the source line gone, `$settings['url']` is no longer read here at all * — `aspect_ratio` is the only setting that changes what is drawn. That is the * strongest form of the boundary rule above: the mock cannot leak anything * about the URL because it never looks at it. * * @param array $settings * @return string */ private static function video( array $settings ): string { $ratios = [ '16-9' => '56.25%', '4-3' => '75%', '1-1' => '100%', '21-9' => '42.86%', ]; $ratio_key = isset( $settings['aspect_ratio'] ) ? (string) $settings['aspect_ratio'] : '16-9'; $padding = isset( $ratios[ $ratio_key ] ) ? $ratios[ $ratio_key ] : $ratios['16-9']; // Branded artwork over a CSS gradient, in that order. // // The gradient is not decoration for the artwork — it is the fallback that // renders if the SVG 404s (a partial deploy, a CDN rewrite, an install // serving assets/ from somewhere unexpected). Declared underneath rather // than instead of, so a missing file degrades to the plain dark poster // this element had before instead of to a white rectangle with a red play // button floating on it. // // The SVG carries the product's own palette — Better Payment indigo, Pro // orange — plus the heart-in-hands mark shared with ai-default-hero.svg, // so a placeholder video reads as the same family as AI-generated campaign // artwork rather than as generic stock. $poster_layers = 'linear-gradient(135deg,#252c52 0%,#151b31 55%,#0a0e18 100%)'; if ( defined( 'BETTER_PAYMENT_ASSETS' ) ) { $poster_url = BETTER_PAYMENT_ASSETS . '/img/campaign/video-poster.svg'; $poster_layers = "url('" . esc_url( $poster_url ) . "') center/cover no-repeat," . $poster_layers; } ob_start(); ?>
2:14
> */ private static function sample_donors(): array { return [ [ 'name' => __( 'Jordan A.', 'better-payment' ), 'amount' => '$250.00', 'when' => __( '2 hours ago', 'better-payment' ), ], [ 'name' => __( 'Priya S.', 'better-payment' ), 'amount' => '$100.00', 'when' => __( '5 hours ago', 'better-payment' ), ], [ 'name' => __( 'Marco B.', 'better-payment' ), 'amount' => '$75.00', 'when' => __( 'Yesterday', 'better-payment' ), ], [ 'name' => __( 'Anonymous', 'better-payment' ), 'amount' => '$50.00', 'when' => __( 'Yesterday', 'better-payment' ), ], [ 'name' => __( 'Lena K.', 'better-payment' ), 'amount' => '$40.00', 'when' => __( '2 days ago', 'better-payment' ), ], [ 'name' => __( 'Sam O.', 'better-payment' ), 'amount' => '$25.00', 'when' => __( '3 days ago', 'better-payment' ), ], ]; } /** * Accept only a literal hex colour; anything else falls back to the campaign * purple. The value reaches a `style` attribute, so it must not be trusted * even though it comes from an admin-authored layout. * * @param mixed $value * @return string */ private static function safe_color( $value ): string { if ( is_string( $value ) && preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value ) ) { return $value; } return '#6a4bff'; } }