| 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' ); ?> →</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 |
|