PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.1 2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 All 130 releases
wdesignkit / includes / posimyth-sdk / class-posimyth-consent-notice.php

class-posimyth-consent-notice.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/posimyth-sdk/class-posimyth-consent-notice.php

593 lines 27.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * POSIMYTH consent notice — the admin notice that asks for permission to share non-sensitive
4 * setup information. One notice for the whole Nexter suite: the opt-in option, the dismissal
5 * flag and the notice itself are shared, so answering it once answers it for every product.
6 *
7 * @package POSIMYTH\Analytics\SDK
8 */
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 if ( ! class_exists( 'Posimyth_Consent_Notice' ) ) {
15
16 /**
17 * Renders and handles the suite-wide analytics consent notice.
18 */
19 class Posimyth_Consent_Notice {
20
21 const VERSION = '1.2.0';
22
23 /**
24 * Shared across every plugin instance in this suite: only one notice renders per page load.
25 *
26 * @var bool
27 */
28 private static bool $rendered_this_request = false;
29
30 /**
31 * Same idea for the inline CSS/JS (E9): every product instance hooks admin_enqueue_scripts,
32 * so without this the identical stylesheet and script were attached once per active product.
33 *
34 * @var bool
35 */
36 private static bool $assets_enqueued = false;
37
38 /**
39 * Suite_key => opt_in_option, as first registered (E2). The whole design assumes every
40 * product in a suite shares ONE opt-in option; a product passing its own option under the
41 * same suite_key would get permanently locked out — the shared dismissed flag silences its
42 * notice while its private option never gets set. Catch that in development.
43 *
44 * @var array<string,string>
45 */
46 private static array $suite_contracts = array();
47
48 /**
49 * Every product that shares this consent, keyed by plugin slug => plugin name.
50 *
51 * Populated in the constructor, so it is an exact list of the products actually participating —
52 * no guessing from version constants. That matters because sibling POSIMYTH software can be
53 * present without sharing this consent (the Nexter *theme*, for example, defines NXT_VERSION but
54 * ships no tracker), and counting those would wrongly make a single-plugin site look like a
55 * multi-product one. A product suppressed by white-label never reaches the constructor, so it
56 * correctly does not count either.
57 *
58 * @var array<string,string>
59 */
60 private static array $registered_products = array();
61
62 /**
63 * Resolved configuration for this product.
64 *
65 * @var array
66 */
67 private array $config;
68
69 /**
70 * Registers this product as a consent participant and wires the notice up.
71 *
72 * @param array $config Product configuration; see the wp_parse_args() defaults below.
73 */
74 public function __construct( array $config ) {
75 $this->config = wp_parse_args(
76 $config,
77 array(
78 'plugin_name' => '',
79 'plugin_slug' => '',
80 'opt_in_option' => '',
81 'ajax_action' => '',
82 'installed_option' => '',
83 'tracker_cb' => null,
84 'logo_url' => '',
85 // Fallback only — each product passes its own placement-tagged URL.
86 'docs_url' => 'https://nexterwp.com/docs/data-sharing/?utm_source=wpbackend&utm_medium=admin&utm_campaign=datasharingnotice',
87 // Shown instead of plugin_name when more than one Nexter product is active, because the
88 // consent is suite-wide: naming a single plugin would imply the choice only covers that
89 // one. See suite_display_name().
90 'suite_name' => 'Nexter',
91 // Suite-wide dedupe: same key across all Nexter plugins so only ONE notice
92 // ever shows, and dismissing it counts for the whole suite, not one plugin.
93 'suite_key' => 'nexter_suite',
94 /*
95 * LEGACY HOOK ONLY. Emits `<prefix>-notice-wrap`, `<prefix>-nobtn-primary` and
96 * `<prefix>-nobtn-secondary` alongside this SDK's own `posi-*` classes, purely so a host
97 * suite stylesheet that already targets them keeps matching.
98 *
99 * Nothing in inline_css() keys off it any more, and it MUST stay that way. The stylesheet
100 * is emitted once per request behind a static latch (see enqueue()) while the markup is
101 * rendered by a possibly DIFFERENT product's instance behind a second static latch — so
102 * prefix-keyed selectors meant one product's CSS could be paired with another's markup
103 * and match nothing, leaving the notice unstyled. That stayed hidden only because every
104 * sibling inherited the same default prefix. Styling now hangs off the stable `posi-*`
105 * classes, which are identical for every product, so the once-per-request stylesheet is
106 * genuinely interchangeable again.
107 *
108 * Defaulted to this SDK's own neutral rather than any product's, so a sibling that passes
109 * nothing does not end up with another product's name in its admin markup.
110 */
111 'css_prefix' => 'posi',
112 /*
113 * Accent for the left border, the buttons and the icon stroke. Same contract as the
114 * deactivation survey's `accent`: validated as a plain hex.
115 *
116 * Delivered to CSS as the inline custom property `--posi-accent` on the wrapper, exactly
117 * as the survey does it, so the colour travels with the INSTANCE rather than with the
118 * shared stylesheet. This is what makes one stylesheet safe to serve every product.
119 *
120 * The default is this SDK's own neutral, matching the survey's. It is deliberately NOT
121 * any product's brand colour: a passed value always wins, so this only ever paints a
122 * caller that supplies no accent — which on a multi-product site is a sibling whose
123 * notice is rendered by whichever copy won the loader. Defaulting to a real product's
124 * colour there is how this plugin's raspberry ended up on The Plus Addons' notice.
125 * Any product that cares sets its own; every POSIMYTH product should.
126 */
127 'accent' => '#1717cc',
128 )
129 );
130
131 /*
132 * Normalised once, here, because each is interpolated into both an attribute and a CSS
133 * selector/declaration. sanitize_html_class() is what makes the prefix safe in the attribute;
134 * the trailing-dash trim means a caller passing 'she-' and one passing 'she' agree rather than
135 * producing `she--notice-wrap`. Empty or all-invalid falls back to the default rather than
136 * emitting a bare `-notice-wrap`, which is not a valid selector.
137 */
138 $posi_prefix = rtrim( sanitize_html_class( (string) $this->config['css_prefix'] ), '-' );
139 $this->config['css_prefix'] = ( '' !== $posi_prefix ) ? $posi_prefix : 'posi';
140
141 // Validated rather than trusted: it lands in a custom-property declaration and an SVG
142 // attribute, so anything that is not a plain 3- or 6-digit hex falls back to the default.
143 $this->config['accent'] = preg_match( '/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', (string) $this->config['accent'] )
144 ? (string) $this->config['accent']
145 : '#1717cc';
146
147 // Record this product as sharing the consent (see $registered_products).
148 if ( ! empty( $this->config['plugin_slug'] ) ) {
149 self::$registered_products[ $this->config['plugin_slug'] ] = (string) $this->config['plugin_name'];
150 }
151
152 // Contract check (E2): everyone under one suite_key must share ONE opt_in_option, or the
153 // suite-wide dismissed flag permanently locks the odd product out of ever being asked.
154 $suite = (string) $this->config['suite_key'];
155 if ( ! isset( self::$suite_contracts[ $suite ] ) ) {
156 self::$suite_contracts[ $suite ] = (string) $this->config['opt_in_option'];
157 } elseif ( self::$suite_contracts[ $suite ] !== (string) $this->config['opt_in_option'] ) {
158 _doing_it_wrong(
159 __METHOD__,
160 sprintf(
161 'Every product sharing suite_key "%s" must pass the same opt_in_option. Got "%s", the suite already uses "%s" — this product would never be asked for consent.',
162 esc_html( $suite ),
163 esc_html( (string) $this->config['opt_in_option'] ),
164 esc_html( self::$suite_contracts[ $suite ] )
165 ),
166 '2.2.0'
167 );
168 }
169
170 $this->hooks();
171 }
172
173 /**
174 * Registers the notice, its AJAX handler and its inline assets.
175 */
176 private function hooks(): void {
177 add_action( 'admin_notices', array( $this, 'render' ) );
178 add_action( 'wp_ajax_' . $this->config['ajax_action'], array( $this, 'handle_ajax' ) );
179 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
180 }
181
182 /**
183 * Prints the notice's CSS/JS inline, only on screens where the notice itself will render.
184 */
185 public function enqueue(): void {
186 if ( ! $this->should_show() ) {
187 return;
188 }
189 // Once per request, not once per product (E9): each active product's instance runs this
190 // hook, and the CSS/JS are identical, so they were being attached N times.
191 //
192 // "Identical" is load-bearing, and it is a constraint on inline_css() rather than an
193 // observation about it. This latch and render()'s are separate, and this hook runs first, so
194 // the instance that supplies the stylesheet may not be the instance that supplies the markup.
195 // inline_css() must therefore stay free of per-product values — see the note on it.
196 if ( self::$assets_enqueued ) {
197 return;
198 }
199 self::$assets_enqueued = true;
200
201 wp_add_inline_style( 'wp-admin', $this->inline_css() );
202 wp_add_inline_script( 'jquery', $this->inline_js() );
203 }
204
205 /**
206 * Outputs the notice. No-ops when consent is already answered or another suite product
207 * already rendered it this request.
208 */
209 public function render(): void {
210 if ( ! $this->should_show() ) {
211 return;
212 }
213 // Suite-wide dedupe: if TPAE/Nexter Extension/Nexter Blocks are all active and each
214 // instantiates this class, only the first one to run on this page actually renders.
215 if ( self::$rendered_this_request ) {
216 return;
217 }
218 self::$rendered_this_request = true;
219
220 // Kept raw here and escaped at each point of output — pre-escaping made it impossible to
221 // tell (for a reader or for PHPCS) whether a given echo was safe.
222 $slug = $this->config['plugin_slug'];
223 $name = $this->suite_display_name();
224 $docs = $this->config['docs_url'];
225 $nonce = wp_create_nonce( $this->config['ajax_action'] );
226 // Legacy prefix (host-stylesheet hook only) and this product's accent — see the constructor
227 // defaults. The accent is emitted as an inline custom property below, NOT baked into the
228 // shared stylesheet, so each product's notice keeps its own brand colour even though one
229 // stylesheet serves them all.
230 $px = $this->config['css_prefix'];
231 $accent = $this->config['accent'];
232 // Deliberately NOT 'is-dismissible': the notice has its own explicit Dismiss button, and
233 // core's injected "x" would be a second, redundant control sitting right beside it.
234 //
235 // posi-consent-bar is the second STABLE class the layout rules key off. It exists to carry
236 // the two-class specificity those rules need (see inline_css()) without that specificity
237 // depending on the legacy prefix, which is not guaranteed to match the stylesheet's.
238 ?>
239 <div class="notice notice-info <?php echo esc_attr( $px ); ?>-notice-wrap posi-consent-notice posi-consent-bar posi-consent--<?php echo esc_attr( $slug ); ?>" id="posi-consent-<?php echo esc_attr( $slug ); ?>" style="--posi-accent:<?php echo esc_attr( $accent ); ?>" data-nonce="<?php echo esc_attr( $nonce ); ?>" data-action="<?php echo esc_attr( $this->config['ajax_action'] ); ?>">
240 <span class="posi-notice-icon" aria-hidden="true">
241 <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 24 24"><path stroke="<?php echo esc_attr( $accent ); ?>" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 20V12M12 20V4m7 16v-6"/></svg>
242 </span>
243 <span class="posi-notice-text">
244 <?php
245 printf(
246 /* translators: %s: product name */
247 esc_html__( 'Help make %s faster and more stable. Share basic, non-sensitive info so we can catch conflicts and ship fixes quicker. No personal data, ever.', 'wdesignkit' ),
248 '<strong>' . esc_html( $name ) . '</strong>'
249 );
250 ?>
251 <a href="<?php echo esc_url( $docs ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( "See what's shared", 'wdesignkit' ); ?> &rarr;</a>
252 </span>
253 <span class="posi-notice-actions">
254 <button type="button" class="<?php echo esc_attr( $px ); ?>-nobtn-primary posi-consent-allow" data-choice="allow"><?php esc_html_e( 'Allow', 'wdesignkit' ); ?></button>
255 <button type="button" class="<?php echo esc_attr( $px ); ?>-nobtn-secondary posi-consent-skip" data-choice="skip"><?php esc_html_e( 'Dismiss', 'wdesignkit' ); ?></button>
256 </span>
257 </div>
258 <?php
259 }
260
261 /**
262 * How long "Dismiss" hides the notice for, in seconds.
263 *
264 * Dismiss is a SNOOZE, not a permanent no. The ask has to survive being ignored, because a
265 * notice that disappears forever on the first stray click means most sites are never asked and
266 * sharing stays off by default with no decision ever made. Only "Allow", or turning the setting
267 * on from Onboarding or Dashboard → Settings, stops it for good.
268 *
269 * It is a snooze rather than "show on every page load" on purpose: re-asking an administrator
270 * on every single screen is nagging, and WordPress's own guidance is that admin notices must be
271 * dismissible. 30 days keeps the ask alive without becoming that.
272 */
273 const SNOOZE_SECONDS = 30 * DAY_IN_SECONDS;
274
275 /**
276 * How long the notice stays quiet after install, in seconds.
277 *
278 * Filterable via posimyth_consent_notice_grace_seconds — see grace_period_ends().
279 */
280 const GRACE_SECONDS = 2 * DAY_IN_SECONDS;
281
282 /**
283 * Stores the Allow/Dismiss choice, suite-wide.
284 */
285 /**
286 * The capability that may see and answer this notice.
287 *
288 * On multisite the consent is ONE answer for the whole network (the options below are site
289 * options), so only someone who speaks for the network may give it — a subsite admin would
290 * otherwise be answering for every other blog (A6).
291 *
292 * @return string
293 */
294 private function required_capability(): string {
295 return is_multisite() ? 'manage_network_options' : 'manage_options';
296 }
297
298 /**
299 * Stores the Allow/Dismiss choice, suite-wide.
300 */
301 public function handle_ajax(): void {
302 // Nonce AND capability, not nonce alone. Today the nonce is only ever printed inside markup
303 // that render() already gates on the same capability, so this is not exploitable — but that
304 // makes the handler's safety a property of its callers rather than of itself. This flips
305 // consent for the whole site (network, on multisite), so it checks directly.
306 if ( ! current_user_can( $this->required_capability() ) ) {
307 wp_send_json_error( array( 'message' => 'unauthorized' ), 403 );
308 }
309 check_ajax_referer( $this->config['ajax_action'], 'nonce' );
310 $choice = sanitize_key( $_POST['choice'] ?? 'skip' );
311
312 // Both flags are keyed on suite_key, not on the plugin slug, so Nexter Extension and Nexter
313 // Blocks share one answer and the user is never asked twice. Site options on purpose (A6):
314 // on multisite one install means one consent, not one per blog — per-blog options produced
315 // N notices and N heartbeats for a single network-activated install. On a single site,
316 // *_site_option() falls back to the plain option of the same name, so nothing changes.
317 if ( 'allow' === $choice ) {
318 update_site_option( $this->config['opt_in_option'], 1 );
319 // An explicit yes is final: never ask again, even if sharing is later turned back off.
320 update_site_option( 'posi_consent_dismissed_' . $this->config['suite_key'], 1 );
321 if ( is_callable( $this->config['tracker_cb'] ) ) {
322 call_user_func( $this->config['tracker_cb'], 'activate' );
323 }
324 } else {
325 update_site_option( $this->config['opt_in_option'], 0 );
326 // Dismiss only snoozes — see SNOOZE_SECONDS.
327 update_site_option( 'posi_consent_snoozed_until_' . $this->config['suite_key'], time() + self::SNOOZE_SECONDS );
328 }
329
330 wp_send_json_success();
331 }
332
333 /**
334 * Whether the notice should render for the current user, screen and consent state.
335 *
336 * @return bool
337 */
338 private function should_show(): bool {
339 // On multisite only someone who can answer for the whole network is asked — see
340 // required_capability(). All three flags below are site options for the same reason.
341 if ( ! current_user_can( $this->required_capability() ) ) {
342 return false;
343 }
344 // Answered for good: "Allow" was clicked (see handle_ajax). Never ask again.
345 if ( get_site_option( 'posi_consent_dismissed_' . $this->config['suite_key'] ) ) {
346 return false;
347 }
348
349 // This notice, the Dashboard toggle and the Onboarding checkbox are three surfaces for ONE
350 // suite-wide setting — not independent prompts. So if sharing is already enabled there is
351 // nothing left to ask, and showing an "Allow / Dismiss" notice would be asking for
352 // permission the user has already granted (and could even read as though it were off).
353 // This is also what stops the notice when the user says yes from Onboarding or from
354 // Dashboard → Settings rather than from the notice itself.
355 if ( ! empty( $this->config['opt_in_option'] ) && ! empty( get_site_option( $this->config['opt_in_option'] ) ) ) {
356 return false;
357 }
358
359 // Snoozed by a previous "Dismiss" — comes back when the snooze expires.
360 $snoozed_until = (int) get_site_option( 'posi_consent_snoozed_until_' . $this->config['suite_key'], 0 );
361 if ( $snoozed_until > time() ) {
362 return false;
363 }
364
365 // Quiet for the first couple of days after install — see grace_period_ends().
366 if ( $this->grace_period_ends() > time() ) {
367 return false;
368 }
369
370 // NOTE: there is deliberately no "first successful use" gate any more.
371 //
372 // It used to require a saved setting before asking, so a fresh install was never asked at
373 // all until the user happened to save something — and plenty of sites never do. The ask now
374 // starts a couple of days after activation and keeps coming back (snoozed, not silenced)
375 // until the user actually decides, either here or from Onboarding / Dashboard → Settings.
376 //
377 // installed_option is still accepted in the config for backwards compatibility, but it is
378 // no longer consulted; mark_first_use() is now a no-op for gating purposes.
379 return true;
380 }
381
382 /**
383 * When the post-install quiet period ends.
384 *
385 * A consent notice that appears in the same breath as activation competes with whatever the user
386 * actually came to do, and asking someone to share diagnostics about a plugin they have not used
387 * yet is a worse question than asking once they have. So the ask is delayed — not skipped: after
388 * this window it behaves exactly as before, coming back until the user decides.
389 *
390 * The start is stamped on the first admin load that would otherwise have shown the notice,
391 * rather than read from the tracker's install timestamp. That timestamp is a per-site option
392 * holding a date string, and this notice is answered once per network (see required_capability),
393 * so it is the wrong scope and the wrong type to gate a network-wide prompt. On a fresh install
394 * the two are the same moment anyway — activation lands the user on an admin screen — and on a
395 * site upgrading into this version it starts the window now instead of treating a months-old
396 * install as already overdue, which would show the notice on the very next page load.
397 *
398 * @return int Unix timestamp at which the notice may start showing.
399 */
400 private function grace_period_ends(): int {
401 $key = 'posi_consent_grace_start_' . $this->config['suite_key'];
402 $start = (int) get_site_option( $key, 0 );
403
404 if ( $start <= 0 ) {
405 $start = time();
406 update_site_option( $key, $start );
407 }
408
409 /**
410 * Filters how long the data-sharing notice stays quiet after install.
411 *
412 * @param int $seconds Quiet period in seconds. Default 2 days.
413 * @param string $suite_key Suite the notice belongs to.
414 */
415 $grace = (int) apply_filters( 'posimyth_consent_notice_grace_seconds', self::GRACE_SECONDS, (string) $this->config['suite_key'] );
416
417 return $start + max( 0, $grace );
418 }
419
420 /**
421 * Product name to show in the copy.
422 *
423 * One notice is rendered for the whole suite and the consent it stores is suite-wide, so when
424 * more than one Nexter product is active naming just one of them ("Help make Nexter Blocks…")
425 * would misrepresent what the user is agreeing to — and which notice happened to render first
426 * is arbitrary. In that case fall back to the suite name ("Nexter"). With a single product
427 * active, its own name is the clearer, more specific label.
428 *
429 * @return string
430 */
431 private function suite_display_name(): string {
432 if ( count( self::$registered_products ) > 1 && ! empty( $this->config['suite_name'] ) ) {
433 return (string) $this->config['suite_name'];
434 }
435
436 return (string) $this->config['plugin_name'];
437 }
438
439 /**
440 * Call this from the moment that counts as "first successful use" for this plugin
441 * (not activation) — e.g. first CPT/taxonomy/field-group created for Nexter Extension,
442 * first block inserted for Nexter Blocks, first widget dropped for TPAE.
443 */
444 public function mark_first_use(): void {
445 if ( ! get_option( $this->config['installed_option'], 0 ) ) {
446 update_option( $this->config['installed_option'], time(), false );
447 }
448 }
449
450 /**
451 * Compact inline notice, styled entirely from product-neutral classes.
452 *
453 * Deliberately a single row rather than a tall promo/licence block: this is an optional,
454 * low-priority ask that appears on ordinary admin screens, so it should not push the page down.
455 *
456 * CONTAINS NO PER-PRODUCT VALUE, AND MUST NOT. This stylesheet is emitted once per request
457 * behind a static latch (see enqueue()) whereas the markup is emitted behind a SEPARATE static
458 * latch in render(), and admin_enqueue_scripts runs before admin_notices — so on a multi-product
459 * site the copy that supplies the CSS is not necessarily the copy that supplies the markup. Any
460 * selector or colour taken from config therefore risked pairing one product's stylesheet with
461 * another's markup: identical prefixes hid it (everything matched by luck), differing prefixes
462 * would have matched nothing and shipped an unstyled notice.
463 *
464 * So selectors key only on the stable `posi-*` classes, which every product emits identically,
465 * and the one value that legitimately varies — the accent — arrives as the `--posi-accent`
466 * custom property set inline per instance in render(). One stylesheet, N brand colours. The
467 * fallback in each var() is this SDK's neutral and should never be reached, since render()
468 * always sets the property.
469 *
470 * Every declaration is repeated in full because this SDK ships inside products that run with no
471 * suite stylesheet present to supply them.
472 */
473 private function inline_css(): string {
474 return '
475 /* Compact single-row layout: this is an optional ask, so it should not occupy a tall
476 block at the top of every admin screen.
477 The two-class selector is deliberate. A host stylesheet may already define a rule for the
478 wrapper class on its own (one suite stylesheet zeroes its left padding), which has the same
479 specificity as a single class and would otherwise win on load order, leaving the icon jammed
480 against the border. Both classes here are this SDK\'s own and are always emitted together,
481 so the specificity no longer depends on a configured prefix matching. */
482 .posi-consent-notice.posi-consent-bar {
483 border-left-color: var(--posi-accent, #1717cc);
484 display: flex;
485 align-items: center;
486 flex-wrap: nowrap;
487 gap: 12px;
488 padding: 12px 16px;
489 }
490 .posi-consent-notice .posi-notice-icon {
491 flex: none;
492 line-height: 0;
493 display: inline-flex;
494 }
495 .posi-consent-notice .posi-notice-text {
496 flex: 1 1 auto;
497 min-width: 0;
498 font-size: 13px;
499 line-height: 1.5;
500 color: #1c1c1c;
501 }
502 .posi-consent-notice .posi-notice-text a { margin-left: 4px; white-space: nowrap; }
503 .posi-consent-notice .posi-notice-actions {
504 flex: none;
505 display: flex;
506 align-items: center;
507 white-space: nowrap;
508 }
509 .posi-consent-notice .posi-consent-allow,
510 .posi-consent-notice .posi-consent-skip {
511 padding: 7px 16px;
512 border-radius: 4px;
513 font-weight: 600;
514 font-size: 12px;
515 line-height: 18px;
516 text-decoration: none;
517 display: inline-flex;
518 cursor: pointer;
519 box-shadow: none;
520 font-family: inherit;
521 }
522 .posi-consent-notice .posi-consent-allow,
523 .posi-consent-notice .posi-consent-allow:hover,
524 .posi-consent-notice .posi-consent-allow:focus {
525 background-color: var(--posi-accent, #1717cc);
526 border: 1px solid var(--posi-accent, #1717cc);
527 color: #fff;
528 outline: 0;
529 }
530 .posi-consent-notice .posi-consent-skip,
531 .posi-consent-notice .posi-consent-skip:hover,
532 .posi-consent-notice .posi-consent-skip:focus {
533 background-color: #fff;
534 color: var(--posi-accent, #1717cc);
535 border: 1px solid var(--posi-accent, #1717cc);
536 margin-left: 8px;
537 padding: 8px 16px;
538 outline: 0;
539 }
540 @media (max-width: 782px) {
541 .posi-consent-notice.posi-consent-bar { flex-wrap: wrap; }
542 .posi-consent-notice .posi-notice-actions { margin-top: 8px; }
543 }
544 ';
545 }
546
547 /**
548 * Notice behaviour: removes the notice the instant a choice is clicked, then stores that choice
549 * in the background.
550 *
551 * The request deliberately does NOT gate the hide. Waiting for it meant the notice sat there
552 * for the length of a round trip after the user had already answered, which reads as an ignored
553 * click. The click is the answer, so the UI acts on it immediately; if the request somehow
554 * fails, nothing was persisted and the notice simply appears again on the next page load —
555 * which is the correct outcome, not a lost choice.
556 *
557 * @return string
558 */
559 private function inline_js(): string {
560 return "
561 jQuery(function($){
562 function posiSend(notice, choice){
563 if (!notice.length || notice.data('posiSent')) { return; }
564 notice.data('posiSent', true);
565
566 // Read the request details BEFORE touching the DOM: jQuery's .remove() also discards
567 // the element's data, so reading data('action') / data('nonce') afterwards would send
568 // action=undefined and the choice would never be stored.
569 var payload = {
570 action: notice.data('action'),
571 choice: choice,
572 nonce: notice.data('nonce')
573 };
574
575 // Gone immediately — before the request, not after it.
576 notice.remove();
577
578 $.post(ajaxurl, payload);
579 }
580
581 // The notice is not 'is-dismissible' — its own Dismiss button is the only close control,
582 // so there is no core 'x' to bind (and no risk of a close that silently fails to store
583 // the choice).
584 $(document).on('click', '.posi-consent-allow, .posi-consent-skip', function(){
585 var btn = $(this);
586 posiSend(btn.closest('.posi-consent-notice'), btn.data('choice'));
587 });
588 });
589 ";
590 }
591 }
592 }
593