| 1 |
<?php |
| 2 |
|
| 3 |
namespace gVectors\News; |
| 4 |
|
| 5 |
use gVectors\News\Services\ConsentService; |
| 6 |
use gVectors\News\Services\PrefsService; |
| 7 |
|
| 8 |
// Exit if accessed directly |
| 9 |
if( ! defined( 'ABSPATH' ) ) exit; |
| 10 |
|
| 11 |
/** |
| 12 |
* News & Emails settings page. |
| 13 |
* |
| 14 |
* Every registered gVectors plugin gets its own menu entry, but all entries |
| 15 |
* render the SAME page over the SAME shared options. |
| 16 |
* |
| 17 |
* Two mirrored sections, each a channel × category matrix: |
| 18 |
* A) My Preferences — the logged-in administrator's own choices (user meta): |
| 19 |
* master email unsubscribe + per-category checkboxes for dashboard notices |
| 20 |
* and for emails, including installed/not-installed addon relevance. |
| 21 |
* B) Site-Wide — the same matrix applied to the whole site for all |
| 22 |
* admins, plus the master service switch (full opt-out after opt-in). |
| 23 |
*/ |
| 24 |
class AdminPage { |
| 25 |
|
| 26 |
/** category key → [label, description] */ |
| 27 |
private const CATEGORY_LABELS = [ |
| 28 |
'expiry_reminder' => [ 'License expiry & billing reminders', 'Transactional — expiring/expired license warnings and failed-payment notices. Email only.' ], |
| 29 |
'renewal_offer' => [ 'Renewal discount offers', 'Reminders about unused personal renewal discounts reserved for this site, before they expire. Email only.' ], |
| 30 |
'abandoned_checkout' => [ 'Unfinished purchase reminders', 'Reminders when a checkout you started was not completed, including any recovery discount. Sent only to the administrator who started the purchase. Email only.' ], |
| 31 |
'blocked_update' => [ 'Addon updates blocked by site permissions', 'Notice when WordPress found new versions of your licensed addons but this site does not allow it to install plugins. Sent once per new version. Email only.' ], |
| 32 |
'recommendations' => [ 'Addon recommendations', 'Personalized suggestions of addons other sites with a similar setup use. Each recommendation is sent at most once. Email only.' ], |
| 33 |
'new_addon' => [ 'New addon announcements', 'Marketing — when a new addon is released.' ], |
| 34 |
'new_feature' => [ 'New feature announcements', 'Marketing — when an addon gets a major new feature.' ], |
| 35 |
'new_version' => [ 'New version releases', 'Version releases — changelogs, scheduled release dates, and actions required before or after an update.' ], |
| 36 |
'discount' => [ 'Discounts & promotions', 'Marketing — sales, coupon codes and limited-time offers.' ], |
| 37 |
'announcement' => [ 'General announcements', 'Marketing — other news from gVectors.' ], |
| 38 |
'addons_installed' => [ 'News about installed addons', 'Addon-targeted news for addons this site has installed.' ], |
| 39 |
'addons_not_installed' => [ 'News about addons not installed here', 'Addon-targeted news for addons this site does not have — disable to skip irrelevant news.' ], |
| 40 |
]; |
| 41 |
|
| 42 |
private $config; |
| 43 |
private $consent; |
| 44 |
private $prefs; |
| 45 |
|
| 46 |
public function __construct( Config $config, ConsentService $consent, PrefsService $prefs ) { |
| 47 |
$this->config = $config; |
| 48 |
$this->consent = $consent; |
| 49 |
$this->prefs = $prefs; |
| 50 |
|
| 51 |
// Form handlers are shared — registered once, whichever menu the form was opened from |
| 52 |
add_action( 'admin_post_' . $this->config->get_prefix() . 'save_my_email_prefs', [ $this, 'handle_save_my_prefs' ] ); |
| 53 |
add_action( 'admin_post_' . $this->config->get_prefix() . 'save_site_email_settings', [ $this, 'handle_save_site_settings' ] ); |
| 54 |
|
| 55 |
$this->add_context( $config ); |
| 56 |
} |
| 57 |
|
| 58 |
/** |
| 59 |
* Register one gVectors plugin's "News & Emails" menu item. |
| 60 |
* Every registered plugin gets its own menu entry under its own admin menu, |
| 61 |
* but they all render the SAME page and store the SAME shared options. |
| 62 |
*/ |
| 63 |
public function add_context( Config $config ): void { |
| 64 |
add_action( $config->get_dashboard_menu_hook(), function( $parent_slug ) use ( $config ) { |
| 65 |
add_submenu_page( |
| 66 |
$parent_slug, |
| 67 |
__( 'News & Emails', 'gvectors' ), |
| 68 |
__( 'News & Emails', 'gvectors' ), |
| 69 |
'activate_plugins', |
| 70 |
$config->get_settings_page_slug(), |
| 71 |
function() { |
| 72 |
$this->render_page(); |
| 73 |
} |
| 74 |
); |
| 75 |
} ); |
| 76 |
} |
| 77 |
|
| 78 |
// ========================================== |
| 79 |
// Form handlers (admin-post.php) |
| 80 |
// ========================================== |
| 81 |
|
| 82 |
/** |
| 83 |
* Save the current administrator's own preference matrix. |
| 84 |
*/ |
| 85 |
public function handle_save_my_prefs(): void { |
| 86 |
if( ! current_user_can( 'activate_plugins' ) ) { |
| 87 |
wp_die( esc_html__( 'Permission denied', 'gvectors' ) ); |
| 88 |
} |
| 89 |
check_admin_referer( $this->config->get_prefix() . 'save_my_email_prefs' ); |
| 90 |
|
| 91 |
$this->prefs->save_user_prefs( |
| 92 |
get_current_user_id(), |
| 93 |
! empty( $_POST['gv_unsubscribed'] ), |
| 94 |
$this->read_matrix_input( 'my' ) |
| 95 |
); |
| 96 |
|
| 97 |
$this->redirect_back( 'prefs' ); |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Save the site-wide matrix + the master service switch. |
| 102 |
*/ |
| 103 |
public function handle_save_site_settings(): void { |
| 104 |
if( ! current_user_can( 'activate_plugins' ) ) { |
| 105 |
wp_die( esc_html__( 'Permission denied', 'gvectors' ) ); |
| 106 |
} |
| 107 |
check_admin_referer( $this->config->get_prefix() . 'save_site_email_settings' ); |
| 108 |
|
| 109 |
$was_enabled = $this->consent->is_enabled(); |
| 110 |
$enable = ! empty( $_POST['gv_service_enabled'] ); |
| 111 |
|
| 112 |
update_option( $this->config->get_service_enabled_option(), $enable ); |
| 113 |
$this->prefs->save_site_prefs( $this->read_matrix_input( 'site' ) ); |
| 114 |
|
| 115 |
if( $enable && ! $was_enabled ) { |
| 116 |
// Opt-in from this page counts as consent — suppress the opt-in notice |
| 117 |
// and run the first sync right away instead of waiting for the daily cron. |
| 118 |
update_option( $this->config->get_optin_notice_dismissed_option(), true ); |
| 119 |
if( ! wp_next_scheduled( $this->config->get_cron_hook() ) ) { |
| 120 |
wp_schedule_single_event( time() + 10, $this->config->get_cron_hook() ); |
| 121 |
} |
| 122 |
} elseif( ! $enable && $was_enabled ) { |
| 123 |
// Opt-out: stop the daily sync immediately (the scheduler on `init` |
| 124 |
// would also clear it, but don't leave a scheduled event behind). |
| 125 |
wp_clear_scheduled_hook( $this->config->get_cron_hook() ); |
| 126 |
} |
| 127 |
|
| 128 |
$this->redirect_back( 'site' ); |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Collect checked channel/category checkboxes from the submitted form. |
| 133 |
* Field names: gv_{scope}_{channel}_{category}, e.g. gv_my_notices_new_addon. |
| 134 |
*/ |
| 135 |
private function read_matrix_input( string $scope ): array { |
| 136 |
$enabled = []; |
| 137 |
foreach( PrefsService::CHANNELS as $channel ) { |
| 138 |
$enabled[ $channel ] = []; |
| 139 |
foreach( PrefsService::channel_categories( $channel ) as $category ) { |
| 140 |
if( ! empty( $_POST[ 'gv_' . $scope . '_' . $channel . '_' . $category ] ) ) { |
| 141 |
$enabled[ $channel ][] = $category; |
| 142 |
} |
| 143 |
} |
| 144 |
} |
| 145 |
|
| 146 |
return $enabled; |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Settings page URL to return to after saving — the page the form was |
| 151 |
* submitted from (each registered plugin has its own menu entry). |
| 152 |
*/ |
| 153 |
private function get_return_url(): string { |
| 154 |
$requested = isset( $_POST['gv_return'] ) ? sanitize_key( $_POST['gv_return'] ) : ''; |
| 155 |
foreach( NewsModule::get_contexts() as $config ) { |
| 156 |
if( $requested === $config->get_settings_page_slug() ) { |
| 157 |
return $config->get_settings_page_url(); |
| 158 |
} |
| 159 |
} |
| 160 |
|
| 161 |
return $this->config->get_settings_page_url(); |
| 162 |
} |
| 163 |
|
| 164 |
private function redirect_back( string $section ): void { |
| 165 |
wp_safe_redirect( add_query_arg( 'updated', $section, $this->get_return_url() ) ); |
| 166 |
exit; |
| 167 |
} |
| 168 |
|
| 169 |
// ========================================== |
| 170 |
// Page rendering |
| 171 |
// ========================================== |
| 172 |
|
| 173 |
/** |
| 174 |
* One channel × category checkbox matrix. |
| 175 |
* |
| 176 |
* @param string $scope 'my' | 'site' — field name prefix |
| 177 |
* @param array $matrix ['notices' => [cat => bool], 'emails' => [cat => bool]] |
| 178 |
*/ |
| 179 |
private function render_matrix( string $scope, array $matrix ): void { |
| 180 |
$all_categories = array_merge( |
| 181 |
PrefsService::EMAIL_ONLY_CATEGORIES, |
| 182 |
PrefsService::NEWS_CATEGORIES, |
| 183 |
PrefsService::RELEVANCE_CATEGORIES |
| 184 |
); |
| 185 |
?> |
| 186 |
<table class="widefat striped" style="max-width:860px;"> |
| 187 |
<thead> |
| 188 |
<tr> |
| 189 |
<th><?php esc_html_e( 'Category', 'gvectors' ); ?></th> |
| 190 |
<th style="width:130px;text-align:center;"><?php esc_html_e( 'Dashboard notices', 'gvectors' ); ?></th> |
| 191 |
<th style="width:130px;text-align:center;"><?php esc_html_e( 'Emails', 'gvectors' ); ?></th> |
| 192 |
</tr> |
| 193 |
</thead> |
| 194 |
<tbody> |
| 195 |
<?php foreach( $all_categories as $category ) : |
| 196 |
$label = self::CATEGORY_LABELS[ $category ] ?? [ $category, '' ]; |
| 197 |
?> |
| 198 |
<tr> |
| 199 |
<td> |
| 200 |
<strong><?php echo esc_html( __( $label[0], 'gvectors' ) ); ?></strong> |
| 201 |
<?php if( $label[1] ) : ?> |
| 202 |
<br><span class="description"><?php echo esc_html( __( $label[1], 'gvectors' ) ); ?></span> |
| 203 |
<?php endif; ?> |
| 204 |
</td> |
| 205 |
<?php foreach( PrefsService::CHANNELS as $channel ) : ?> |
| 206 |
<td style="text-align:center;"> |
| 207 |
<?php if( in_array( $category, PrefsService::channel_categories( $channel ), true ) ) : ?> |
| 208 |
<input type="checkbox" |
| 209 |
name="gv_<?php echo esc_attr( $scope . '_' . $channel . '_' . $category ); ?>" |
| 210 |
value="1" <?php checked( ! empty( $matrix[ $channel ][ $category ] ) ); ?>> |
| 211 |
<?php else : ?> |
| 212 |
<span aria-hidden="true">—</span> |
| 213 |
<?php endif; ?> |
| 214 |
</td> |
| 215 |
<?php endforeach; ?> |
| 216 |
</tr> |
| 217 |
<?php endforeach; ?> |
| 218 |
</tbody> |
| 219 |
</table> |
| 220 |
<?php |
| 221 |
} |
| 222 |
|
| 223 |
private function render_page(): void { |
| 224 |
if( ! current_user_can( 'activate_plugins' ) ) return; |
| 225 |
|
| 226 |
$user = wp_get_current_user(); |
| 227 |
$user_prefs = $this->prefs->get_user_prefs( $user->ID ); |
| 228 |
$site_prefs = $this->prefs->get_site_prefs(); |
| 229 |
$updated = isset( $_GET['updated'] ) ? sanitize_key( $_GET['updated'] ) : ''; |
| 230 |
$prefix = $this->config->get_prefix(); |
| 231 |
$current = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : ''; |
| 232 |
$products = array_keys( NewsModule::get_contexts() ); |
| 233 |
?> |
| 234 |
<div class="wrap gvectors-news-settings"> |
| 235 |
<h1><?php esc_html_e( 'News & Emails', 'gvectors' ); ?></h1> |
| 236 |
|
| 237 |
<?php if( $updated === 'prefs' ) : ?> |
| 238 |
<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Your preferences have been saved.', 'gvectors' ); ?></p></div> |
| 239 |
<?php elseif( $updated === 'site' ) : ?> |
| 240 |
<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Site-wide settings have been saved.', 'gvectors' ); ?></p></div> |
| 241 |
<?php endif; ?> |
| 242 |
|
| 243 |
<!-- ── Section A: this administrator's own preferences ── --> |
| 244 |
<h2><?php esc_html_e( 'My Preferences', 'gvectors' ); ?></h2> |
| 245 |
<p class="description"> |
| 246 |
<?php |
| 247 |
printf( |
| 248 |
/* translators: %s: the administrator's email address */ |
| 249 |
esc_html__( 'These settings apply only to your own account (%s). Other administrators manage their preferences on this same page. A dashboard notice you dismiss is dismissed only for you.', 'gvectors' ), |
| 250 |
'<strong>' . esc_html( $user->user_email ) . '</strong>' |
| 251 |
); |
| 252 |
?> |
| 253 |
</p> |
| 254 |
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"> |
| 255 |
<input type="hidden" name="action" value="<?php echo esc_attr( $prefix . 'save_my_email_prefs' ); ?>"> |
| 256 |
<input type="hidden" name="gv_return" value="<?php echo esc_attr( $current ); ?>"> |
| 257 |
<?php wp_nonce_field( $prefix . 'save_my_email_prefs' ); ?> |
| 258 |
|
| 259 |
<p> |
| 260 |
<label> |
| 261 |
<input type="checkbox" name="gv_unsubscribed" value="1" <?php checked( $user_prefs['unsubscribed'] ); ?>> |
| 262 |
<strong><?php esc_html_e( 'Unsubscribe my email from ALL news module emails', 'gvectors' ); ?></strong> |
| 263 |
</label> |
| 264 |
<br><span class="description"><?php esc_html_e( 'When checked, you receive no emails from this module regardless of the matrix below. Dashboard notices are not affected.', 'gvectors' ); ?></span> |
| 265 |
</p> |
| 266 |
|
| 267 |
<?php $this->render_matrix( 'my', $user_prefs ); ?> |
| 268 |
|
| 269 |
<p class="submit"><input type="submit" class="button button-primary" value="<?php esc_attr_e( 'Save My Preferences', 'gvectors' ); ?>"></p> |
| 270 |
</form> |
| 271 |
|
| 272 |
<hr> |
| 273 |
|
| 274 |
<!-- ── Section B: site-wide switches (all administrators) ── --> |
| 275 |
<h2><?php esc_html_e( 'Site-Wide Settings', 'gvectors' ); ?></h2> |
| 276 |
<p class="description"> |
| 277 |
<?php esc_html_e( 'These settings apply to the whole site and all administrators: a category disabled here is off for everyone, regardless of personal preferences.', 'gvectors' ); ?> |
| 278 |
<?php |
| 279 |
if( count( $products ) > 1 ) { |
| 280 |
printf( |
| 281 |
/* translators: %s: comma-separated list of gVectors plugin slugs */ |
| 282 |
esc_html__( 'They are shared by all installed gVectors products (%s) — news, emails and license reminders are managed once for all of them.', 'gvectors' ), |
| 283 |
'<strong>' . esc_html( implode( ', ', $products ) ) . '</strong>' |
| 284 |
); |
| 285 |
} |
| 286 |
?> |
| 287 |
</p> |
| 288 |
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"> |
| 289 |
<input type="hidden" name="action" value="<?php echo esc_attr( $prefix . 'save_site_email_settings' ); ?>"> |
| 290 |
<input type="hidden" name="gv_return" value="<?php echo esc_attr( $current ); ?>"> |
| 291 |
<?php wp_nonce_field( $prefix . 'save_site_email_settings' ); ?> |
| 292 |
|
| 293 |
<p> |
| 294 |
<label> |
| 295 |
<input type="checkbox" name="gv_service_enabled" value="1" <?php checked( $this->consent->is_enabled() ); ?>> |
| 296 |
<strong><?php esc_html_e( 'Enable the addon news service', 'gvectors' ); ?></strong> |
| 297 |
</label> |
| 298 |
<br><span class="description"> |
| 299 |
<?php esc_html_e( 'Master switch. When enabled, this site connects to the gVectors server once a day to fetch addon news and check for expiring licenses. When disabled, the daily sync stops and no outbound requests are made.', 'gvectors' ); ?> |
| 300 |
<a href="<?php echo esc_url( $this->config->get_privacy_policy_url() ); ?>" target="_blank" rel="noopener"><?php esc_html_e( 'Privacy policy', 'gvectors' ); ?></a> |
| 301 |
</span> |
| 302 |
</p> |
| 303 |
|
| 304 |
<?php $this->render_matrix( 'site', $site_prefs ); ?> |
| 305 |
|
| 306 |
<p class="submit"><input type="submit" class="button button-primary" value="<?php esc_attr_e( 'Save Site-Wide Settings', 'gvectors' ); ?>"></p> |
| 307 |
</form> |
| 308 |
</div> |
| 309 |
<?php |
| 310 |
} |
| 311 |
} |
| 312 |
|