Settings.
*
* @package GeniusCommerce
*/
namespace GeniusCommerce;
defined( 'ABSPATH' ) || exit;
/**
* GC_Settings class.
*
* Handles the plugin settings page in WooCommerce admin.
*/
class GC_Settings {
/**
* Constructor.
*/
public function __construct() {
add_filter( 'woocommerce_settings_tabs_array', array( $this, 'add_settings_tab' ), 50 );
add_action( 'woocommerce_settings_tabs_genius_commerce', array( $this, 'settings_tab' ) );
add_action( 'woocommerce_update_options_genius_commerce', array( $this, 'update_settings' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );
// Vangnet: een winkel die gekoppeld is maar geen webhooks heeft,
// herstelt zichzelf zodra een beheerder het dashboard opent.
add_action( 'admin_init', array( GC_Webhook_Manager::class, 'maybe_schedule_bootstrap' ) );
// AJAX handlers.
add_action( 'wp_ajax_genius_commerce_connect_store', array( $this, 'ajax_connect_store' ) );
add_action( 'wp_ajax_genius_commerce_disconnect_store', array( $this, 'ajax_disconnect_store' ) );
add_action( 'wp_ajax_genius_commerce_test_connection', array( $this, 'ajax_test_connection' ) );
add_action( 'wp_ajax_genius_commerce_update_webhook_status', array( $this, 'ajax_update_webhook_status' ) );
add_action( 'wp_ajax_genius_commerce_update_webhook_group', array( $this, 'ajax_update_webhook_group' ) );
add_action( 'wp_ajax_genius_commerce_refresh_webhooks', array( $this, 'ajax_refresh_webhooks' ) );
// Template editor AJAX handlers.
add_action( 'wp_ajax_genius_commerce_save_template', array( $this, 'ajax_save_template' ) );
add_action( 'wp_ajax_genius_commerce_reset_template', array( $this, 'ajax_reset_template' ) );
// Story 8.3: force-refresh the scope cache on demand (helps tenant-
// admins debug "why is this endpoint 403?" without waiting for the
// 5-minute TTL).
add_action( 'wp_ajax_genius_commerce_refresh_scopes', array( $this, 'ajax_refresh_scopes' ) );
}
/**
* Add Genius Commerce tab to WooCommerce settings.
*
* @param array $tabs Existing tabs.
* @return array Modified tabs.
*/
public function add_settings_tab( array $tabs ): array {
$tabs['genius_commerce'] = __( 'Genius Commerce', 'genius-commerce' );
return $tabs;
}
/**
* Get available subtabs.
*
* @return array Subtabs configuration.
*/
private function get_subtabs(): array {
return array(
'connection' => __( 'Verbinding', 'genius-commerce' ),
'display' => __( 'Weergave', 'genius-commerce' ),
'webhooks' => __( 'Webhook Configuratie', 'genius-commerce' ),
'scopes' => __( 'Scopes', 'genius-commerce' ),
'templates' => __( 'Template Aanpassingen', 'genius-commerce' ),
'mapping' => __( 'Sync Mapping', 'genius-commerce' ),
);
}
/**
* Get the current subtab.
*
* @return string Current subtab slug.
*/
private function get_current_subtab(): string {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$subtab = isset( $_GET['subtab'] ) ? sanitize_text_field( wp_unslash( $_GET['subtab'] ) ) : 'connection';
$valid_subtabs = array_keys( $this->get_subtabs() );
return in_array( $subtab, $valid_subtabs, true ) ? $subtab : 'connection';
}
/**
* Render subtab navigation.
*
* @return void
*/
private function render_subtab_navigation(): void {
$subtabs = $this->get_subtabs();
$current_subtab = $this->get_current_subtab();
$base_url = admin_url( 'admin.php?page=wc-settings&tab=genius_commerce' );
echo '';
}
/**
* Render the settings tab content.
*
* @return void
*/
public function settings_tab(): void {
$current_subtab = $this->get_current_subtab();
// Always show subtab navigation.
$this->render_subtab_navigation();
// Warn loud when debug-logging is enabled — log-bestanden bevatten
// (geredacteerde) request-data en horen niet maandenlang aan te staan.
$this->render_logging_banner();
// Render content based on current subtab.
switch ( $current_subtab ) {
case 'connection':
$this->render_connect_cta();
$this->render_status_indicator();
woocommerce_admin_fields( $this->get_connection_settings() );
break;
case 'display':
woocommerce_admin_fields( $this->get_display_settings() );
break;
case 'webhooks':
$this->render_webhook_config();
break;
case 'scopes':
$this->render_scopes_section();
break;
case 'templates':
$this->render_template_editor_section();
break;
case 'mapping':
$this->render_mapping_section();
break;
}
}
/**
* Save settings.
*
* @return void
*/
public function update_settings(): void {
// Handle API key separately (custom validation).
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce handles nonce.
if ( isset( $_POST['genius_commerce_api_key'] ) && ! empty( $_POST['genius_commerce_api_key'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$api_key = sanitize_text_field( wp_unslash( $_POST['genius_commerce_api_key'] ) );
$error = GC_API_Key::get_validation_error( $api_key );
if ( $error ) {
\WC_Admin_Settings::add_error( $error );
} else {
GC_API_Key::save( $api_key );
\WC_Admin_Settings::add_message( __( 'API key opgeslagen.', 'genius-commerce' ) );
}
}
// Handle backend URL validation.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( isset( $_POST['genius_commerce_backend_url'] ) && ! empty( $_POST['genius_commerce_backend_url'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$url = esc_url_raw( wp_unslash( $_POST['genius_commerce_backend_url'] ) );
if ( ! empty( $url ) && strpos( $url, 'https://' ) !== 0 ) {
\WC_Admin_Settings::add_error( __( 'Backend URL moet beginnen met https://', 'genius-commerce' ) );
// Don't save invalid URL.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
unset( $_POST['genius_commerce_backend_url'] );
}
}
// Handle IP allowlist validation. Empty value is allowed (= disabled).
// Any non-empty value is split on commas/whitespace; each entry must
// parse as IPv4/IPv6 or `ip/prefix`. On any invalid entry we name it
// in the error and drop the entire field so the old value survives.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( isset( $_POST[ GC_IP_Allowlist::OPTION_ALLOWED_IPS ] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$raw = sanitize_textarea_field( wp_unslash( $_POST[ GC_IP_Allowlist::OPTION_ALLOWED_IPS ] ) );
$invalid = array();
if ( '' !== trim( $raw ) ) {
$tokens = preg_split( '/[\s,]+/', $raw ) ?: array();
foreach ( $tokens as $token ) {
$token = trim( $token );
if ( '' === $token ) {
continue;
}
if ( ! GC_IP_Allowlist::validate( $token ) ) {
$invalid[] = $token;
}
}
}
if ( ! empty( $invalid ) ) {
\WC_Admin_Settings::add_error(
sprintf(
/* translators: %s: comma-separated list of invalid IP/CIDR entries */
__( 'Toegestane IP-adressen: ongeldige invoer geweigerd (%s). Gebruik IPv4/IPv6-adressen of CIDR-notatie (bv. 10.0.0.0/8).', 'genius-commerce' ),
implode( ', ', $invalid )
)
);
// phpcs:ignore WordPress.Security.NonceVerification.Missing
unset( $_POST[ GC_IP_Allowlist::OPTION_ALLOWED_IPS ] );
}
}
// Save standard WooCommerce options.
woocommerce_update_options( $this->get_settings() );
}
/**
* Get all settings (for backward compatibility with update_settings).
*
* @return array All settings configuration.
*/
public function get_settings(): array {
return array_merge(
$this->get_connection_settings(),
$this->get_display_settings()
);
}
/**
* Get connection settings array.
*
* @return array Connection settings configuration.
*/
public function get_connection_settings(): array {
$api_configured = GC_API_Key::is_configured();
return array(
array(
'title' => __( 'Geavanceerd: handmatige verbinding', 'genius-commerce' ),
'type' => 'title',
'desc' => __( 'Alleen nodig als je niet de "Verbinden"-knop hierboven gebruikt. Haal dan zelf een API key op uit je Genius Commerce account en plak deze hieronder.', 'genius-commerce' ),
'id' => 'genius_commerce_settings_section',
),
array(
'title' => __( 'API Key', 'genius-commerce' ),
'type' => 'password',
'id' => 'genius_commerce_api_key',
'default' => '',
'placeholder' => $api_configured
? GC_API_Key::get_masked()
: __( 'Voer de API key in van Genius Commerce', 'genius-commerce' ),
'desc' => $api_configured
? __( 'Key is geconfigureerd. Laat leeg om huidige key te behouden, of voer nieuwe key in om te wijzigen.', 'genius-commerce' )
: __( 'Kopieer de API key uit Genius Commerce en plak deze hier.', 'genius-commerce' ),
'desc_tip' => true,
'custom_attributes' => array(
'autocomplete' => 'off',
'data-configured' => $api_configured ? 'true' : 'false',
),
),
array(
'title' => __( 'Backend URL', 'genius-commerce' ),
'type' => 'url',
'id' => 'genius_commerce_backend_url',
'desc' => __( 'Genius Commerce API URL', 'genius-commerce' ),
'default' => GC_API_Client::DEFAULT_BACKEND_URL,
),
array(
'title' => __( 'Debug logging', 'genius-commerce' ),
'type' => 'checkbox',
'id' => 'genius_commerce_enable_logging',
'desc' => __( 'Log API-calls en errors naar WooCommerce-logs', 'genius-commerce' ),
// Longer description rendered below the checkbox.
'desc_tip' => __( 'Logs landen in wp-content/uploads/wc-logs/. Alleen tijdelijk aanzetten voor debugging; log-bestanden kunnen op nginx-hosts publiek toegankelijk zijn als de webserver .htaccess niet evalueert. Klant-PII (e-mail, NAW, telefoon) wordt automatisch geredacteerd, maar overige debug-data niet.', 'genius-commerce' ),
'default' => 'no',
),
array(
'title' => __( 'Toegestane IP-adressen', 'genius-commerce' ),
'type' => 'textarea',
'id' => GC_IP_Allowlist::OPTION_ALLOWED_IPS,
'desc' => __( 'Comma- of nieuwe-regel-gescheiden lijst van IPv4/IPv6-adressen of CIDR-ranges die toegang krijgen tot de REST-API. Laat leeg om de allowlist uit te schakelen (alleen API-key-check). Productie moet altijd het Genius Commerce egress-IP bevatten. Loopback (127.0.0.1, ::1) wordt NIET automatisch toegestaan — voeg het expliciet toe voor lokale tests.', 'genius-commerce' ),
'desc_tip' => true,
'default' => '',
'placeholder' => '34.241.0.0/16, 203.0.113.5',
'css' => 'min-width: 480px; min-height: 80px;',
),
array(
'title' => __( 'Vertrouw X-Forwarded-For', 'genius-commerce' ),
'type' => 'checkbox',
'id' => GC_IP_Allowlist::OPTION_TRUST_XFF,
'desc' => __( 'Alleen aanzetten als WordPress achter een vertrouwde reverse-proxy draait (Cloudflare, AWS ALB, eigen nginx). Anders is dit een spoofing-risico — een aanvaller kan de header zelf zetten.', 'genius-commerce' ),
'default' => 'no',
),
array(
'type' => 'sectionend',
'id' => 'genius_commerce_settings_section',
),
array(
'title' => __( 'Scope-enforcement', 'genius-commerce' ),
'type' => 'title',
'desc' => __( 'Beperk REST-routes tot wat in de Genius Commerce-configuratie staat. Zet pas aan na verificatie in het tabblad "Scopes".', 'genius-commerce' ),
'id' => 'genius_commerce_scopes_section',
),
array(
'title' => __( 'Scope-enforcement aan', 'genius-commerce' ),
'type' => 'checkbox',
'id' => GC_Scopes::FLAG_OPTION,
'desc' => __( 'Wijst REST-calls af die buiten de scope van de tenant-configuratie vallen (403 INSUFFICIENT_SCOPE). Laat dit uit zolang de scopes nog niet zijn geverifieerd in het tabblad "Scopes".', 'genius-commerce' ),
'default' => 'no',
),
array(
'type' => 'sectionend',
'id' => 'genius_commerce_scopes_section',
),
);
}
/**
* Get display settings array.
*
* @return array Display settings configuration.
*/
public function get_display_settings(): array {
return array(
// Discount Display Settings.
array(
'title' => __( 'Korting Weergave', 'genius-commerce' ),
'type' => 'title',
'desc' => __( 'Instellingen voor het weergeven van klantkorting op product pagina\'s.', 'genius-commerce' ),
'id' => 'genius_commerce_discount_section',
),
array(
'title' => __( 'Korting weergeven', 'genius-commerce' ),
'type' => 'checkbox',
'id' => 'genius_commerce_enable_discount_display',
'desc' => __( 'Toon gepersonaliseerde klantkorting op product pagina\'s voor ingelogde gebruikers', 'genius-commerce' ),
'default' => 'yes',
),
array(
'type' => 'sectionend',
'id' => 'genius_commerce_discount_section',
),
// Delivery Display Settings.
array(
'title' => __( 'Levertijd Weergave', 'genius-commerce' ),
'type' => 'title',
'desc' => __( 'Instellingen voor het weergeven van levertijd informatie op product pagina\'s.', 'genius-commerce' ),
'id' => 'genius_commerce_delivery_section',
),
array(
'title' => __( 'Levertijd weergeven', 'genius-commerce' ),
'type' => 'checkbox',
'id' => 'genius_commerce_enable_delivery_display',
'desc' => __( 'Toon verwachte levertijd op product pagina\'s', 'genius-commerce' ),
'default' => 'yes',
),
array(
'title' => __( 'Standaard levertijd', 'genius-commerce' ),
'type' => 'text',
'id' => 'genius_commerce_default_delivery_time',
'desc' => __( 'Wordt getoond als een product geen specifieke levertijd heeft. Laat leeg om niets te tonen.', 'genius-commerce' ),
'default' => '',
'placeholder' => __( 'bijv. 2-3 werkdagen', 'genius-commerce' ),
),
array(
'title' => __( 'Positie', 'genius-commerce' ),
'type' => 'select',
'id' => 'genius_commerce_delivery_position',
'desc' => __( 'Waar de levertijd wordt getoond op de product pagina.', 'genius-commerce' ),
'default' => 'after_price',
'options' => array(
'after_price' => __( 'Na de prijs', 'genius-commerce' ),
'after_excerpt' => __( 'Na de korte beschrijving', 'genius-commerce' ),
'manual' => __( 'Handmatig (alleen via shortcode)', 'genius-commerce' ),
),
),
array(
'type' => 'sectionend',
'id' => 'genius_commerce_delivery_section',
),
);
}
/**
* Render warning banner when `genius_commerce_enable_logging` is on.
*
* Story 8.5 / F-004: debug-logging schrijft (geredacteerde) request-data
* naar `wc-logs/`. Op nginx-hosts kan die directory publiek bereikbaar
* zijn; vandaar deze blijvende reminder om logging na debugging weer uit
* te zetten via de toggle in de Verbinding-tab.
*
* @return void
*/
private function render_logging_banner(): void {
if ( 'yes' !== get_option( 'genius_commerce_enable_logging', 'no' ) ) {
return;
}
?>
__( 'Ongeldige beveiligingstoken', 'genius-commerce' ) ) );
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Geen rechten', 'genius-commerce' ) ) );
}
$backend_url = GC_API_Client::get_backend_url();
if ( '' === $backend_url || 0 !== strpos( $backend_url, 'https://' ) ) {
wp_send_json_error( array( 'message' => __( 'Backend URL ontbreekt of is geen https-URL. Vul deze eerst in bij de geavanceerde instellingen.', 'genius-commerce' ) ) );
}
// Mint a fresh 32-hex plugin key and persist it BEFORE the backend callback,
// so the backend's ownership callback (X-GC-API-Key) can validate against it.
try {
$api_key = bin2hex( random_bytes( 16 ) );
} catch ( \Exception $e ) {
wp_send_json_error( array( 'message' => __( 'Kon geen veilige sleutel genereren.', 'genius-commerce' ) ) );
}
if ( ! GC_API_Key::save( $api_key ) ) {
wp_send_json_error( array( 'message' => __( 'Kon de sleutel niet opslaan.', 'genius-commerce' ) ) );
}
$handshake_nonce = wp_generate_password( 32, false );
set_transient( 'genius_commerce_connect_nonce', $handshake_nonce, 5 * MINUTE_IN_SECONDS );
$response = wp_remote_post(
$backend_url . '/token-exchange',
array(
'timeout' => 20,
'headers' => array(
'Content-Type' => 'application/json',
'Accept' => 'application/json',
),
'body' => wp_json_encode(
array(
'storeUrl' => untrailingslashit( site_url() ),
'apiKey' => $api_key,
'timestamp' => (int) round( microtime( true ) * 1000 ),
'nonce' => $handshake_nonce,
)
),
)
);
if ( is_wp_error( $response ) ) {
GC_API_Key::delete();
wp_send_json_error( array( 'message' => __( 'Kon Genius Commerce niet bereiken. Probeer het opnieuw.', 'genius-commerce' ) ) );
}
$code = wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $code < 200 || $code >= 300 || empty( $data['redirectUrl'] ) ) {
// Roll back the key so the CTA reappears and the store isn't left half-coupled.
GC_API_Key::delete();
$message = is_array( $data ) && isset( $data['error'] )
? (string) $data['error']
: __( 'Verbinden mislukt. Controleer of de winkel publiek bereikbaar is.', 'genius-commerce' );
wp_send_json_error( array( 'message' => $message ) );
}
// Persist the URL we just proved to work, so the status-overzicht and the
// geavanceerde velden show the same backend the plugin actually calls.
if ( '' === trim( (string) get_option( 'genius_commerce_backend_url', '' ) ) ) {
update_option( 'genius_commerce_backend_url', $backend_url );
}
// Key verified by the backend round-trip — record local connection status.
update_option( 'genius_commerce_connection_status', 'connected' );
update_option( 'genius_commerce_last_connection_check', time() );
// The backend config only exists once the merchant finishes linking in
// Genius Commerce, so the webhook secret cannot be fetched here yet.
// Clear the throttle instead: the first admin page load or backend call
// after linking then registers the webhooks without any manual step.
GC_Webhook_Manager::clear_bootstrap_throttle();
wp_send_json_success( array( 'redirectUrl' => (string) $data['redirectUrl'] ) );
}
/**
* AJAX handler for disconnecting the store (Story 4.11).
*
* Wipes the local coupling state so the "Verbinden"-CTA reappears: deletes the
* stored API key, removes the webhook secret, and unregisters the WooCommerce
* webhooks. Needed because a backend-side disconnect leaves the plugin showing
* "Gekoppeld" with no UI to clear the {@see GC_API_Key::OPTION_KEY} option.
*
* @return void
*/
public function ajax_disconnect_store(): void {
if ( ! check_ajax_referer( 'genius_commerce_disconnect_store', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Ongeldige beveiligingstoken', 'genius-commerce' ) ) );
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Geen rechten', 'genius-commerce' ) ) );
}
// Unregister the WooCommerce webhooks (and clear the stored webhook IDs)
// before we drop the key, so the deletions still authenticate locally.
$webhook_manager = new GC_Webhook_Manager();
$removed = $webhook_manager->cleanup_webhooks();
// Drop the webhook secret and the API key so is_configured() → false.
delete_option( 'genius_commerce_webhook_secret' );
GC_API_Key::delete();
// Reset the derived connection status so the status-overview isn't stale.
update_option( 'genius_commerce_connection_status', 'disconnected' );
delete_option( 'genius_commerce_last_connection_check' );
// Without the key there is nothing to heal; a stale throttle would only
// delay the registration after the next "Verbinden".
GC_Webhook_Manager::clear_bootstrap_throttle();
wp_send_json_success(
array(
'message' => __( 'Koppeling verbroken. Je kunt opnieuw verbinden.', 'genius-commerce' ),
'webhooks_removed' => $removed,
)
);
}
/**
* Render status indicator at top of settings page.
*
* @return void
*/
private function render_status_indicator(): void {
$api_configured = GC_API_Key::is_configured();
$backend_configured = '' !== GC_API_Client::get_backend_url();
$connection_status = get_option( 'genius_commerce_connection_status', 'unknown' );
$last_check = get_option( 'genius_commerce_last_connection_check', 0 );
$can_test = $api_configured && $backend_configured;
$has_webhook_secret = GC_API_Client::has_webhook_secret();
$webhook_count = GC_Webhook_Manager::get_webhook_count();
$has_webhooks = GC_Webhook_Manager::has_registered_webhooks();
// Determine connection status class and text.
$connection_class = 'gc-status-unknown';
$connection_icon = 'dashicons-warning';
$connection_text = __( 'Niet getest', 'genius-commerce' );
if ( 'connected' === $connection_status ) {
$connection_class = 'gc-status-ok';
$connection_icon = 'dashicons-yes-alt';
$connection_text = __( 'Verbonden', 'genius-commerce' );
if ( $last_check ) {
$connection_text .= ' (' . human_time_diff( $last_check ) . ' ' . __( 'geleden', 'genius-commerce' ) . ')';
}
} elseif ( 'disconnected' === $connection_status ) {
$connection_class = 'gc-status-error';
$connection_icon = 'dashicons-dismiss';
$connection_text = __( 'Niet verbonden', 'genius-commerce' );
}
// Determine webhook secret status.
$secret_class = $has_webhook_secret ? 'gc-status-ok' : 'gc-status-missing';
$secret_icon = $has_webhook_secret ? 'dashicons-yes-alt' : 'dashicons-warning';
$secret_text = $has_webhook_secret
? __( 'Opgehaald', 'genius-commerce' )
: __( 'Niet beschikbaar', 'genius-commerce' );
// Determine webhooks registration status.
$webhooks_class = $has_webhooks ? 'gc-status-ok' : 'gc-status-missing';
$webhooks_icon = $has_webhooks ? 'dashicons-yes-alt' : 'dashicons-warning';
$webhooks_text = $has_webhooks
? sprintf(
/* translators: %d: number of registered webhooks */
_n( '%d webhook geregistreerd', '%d webhooks geregistreerd', $webhook_count, 'genius-commerce' ),
$webhook_count
)
: __( 'Niet geregistreerd', 'genius-commerce' );
?>
get_registered_webhooks();
$problematic = $webhook_manager->get_problematic_webhooks();
$has_problems = ! empty( $problematic );
$paused_count = count( array_filter( $problematic, fn( $w ) => 'paused' === $w['status'] ) );
$with_failures_count = count( array_filter( $problematic, fn( $w ) => $w['failure_count'] > 0 ) );
// Build lookup by topic.
$webhook_by_topic = array();
foreach ( $webhooks as $webhook ) {
$webhook_by_topic[ $webhook['topic'] ] = $webhook;
}
?>
$topics ) : ?>
|
|
|
|
|
get_webhook_status_text( $status );
$status_class = $this->get_webhook_status_class( $status );
$failure_count = $webhook ? $webhook['failure_count'] : 0;
$date_modified = $webhook && $webhook['date_modified'] ? $webhook['date_modified'] : null;
$webhook_id = $webhook ? $webhook['id'] : 0;
?>
|
|
|
format_webhook_date( $date_modified ) ); ?>
-
|
0 ) : ?>
0
|
|
diff( $datetime );
// If less than 1 hour ago, show relative time.
if ( $diff->days === 0 && $diff->h === 0 ) {
if ( $diff->i === 0 ) {
return __( 'Zojuist', 'genius-commerce' );
}
return sprintf(
/* translators: %d: number of minutes */
_n( '%d minuut geleden', '%d minuten geleden', $diff->i, 'genius-commerce' ),
$diff->i
);
}
// If less than 24 hours ago, show hours.
if ( $diff->days === 0 ) {
return sprintf(
/* translators: %d: number of hours */
_n( '%d uur geleden', '%d uur geleden', $diff->h, 'genius-commerce' ),
$diff->h
);
}
// Otherwise show date and time.
return wp_date( 'j M Y, H:i', $datetime->getTimestamp() );
} catch ( \Exception $e ) {
return $date;
}
}
/**
* Get human-readable status text.
*
* @param string $status Webhook status.
* @return string Status text.
*/
private function get_webhook_status_text( string $status ): string {
$texts = array(
'active' => __( 'Actief', 'genius-commerce' ),
'paused' => __( 'Gepauzeerd', 'genius-commerce' ),
'disabled' => __( 'Uitgeschakeld', 'genius-commerce' ),
'missing' => __( 'Ontbreekt', 'genius-commerce' ),
);
return $texts[ $status ] ?? $status;
}
/**
* Get CSS class for status badge.
*
* @param string $status Webhook status.
* @return string CSS class.
*/
private function get_webhook_status_class( string $status ): string {
$classes = array(
'active' => 'gc-badge-active',
'paused' => 'gc-badge-paused',
'disabled' => 'gc-badge-disabled',
'missing' => 'gc-badge-missing',
);
return $classes[ $status ] ?? 'gc-badge-unknown';
}
/**
* Enqueue admin scripts and styles.
*
* @param string $hook Current admin page hook.
* @return void
*/
public function enqueue_admin_scripts( string $hook ): void {
// Only load on WooCommerce settings page.
if ( 'woocommerce_page_wc-settings' !== $hook ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$tab = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : '';
// Only load on our tab.
if ( 'genius_commerce' !== $tab ) {
return;
}
wp_enqueue_style(
'genius-commerce-admin',
GENIUS_COMMERCE_PLUGIN_URL . 'admin/css/admin.css',
array(),
GENIUS_COMMERCE_VERSION
);
wp_enqueue_script(
'genius-commerce-admin',
GENIUS_COMMERCE_PLUGIN_URL . 'admin/js/admin.js',
array( 'jquery' ),
GENIUS_COMMERCE_VERSION,
true
);
// Enqueue CodeMirror for template editing.
$code_editor_settings = wp_enqueue_code_editor( array( 'type' => 'text/html' ) );
// Localize script with AJAX data.
wp_localize_script(
'genius-commerce-admin',
'geniusCommerceAdmin',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'genius_commerce_test_connection' ),
'connectNonce' => wp_create_nonce( 'genius_commerce_connect_store' ),
'disconnectNonce' => wp_create_nonce( 'genius_commerce_disconnect_store' ),
'webhookNonce' => wp_create_nonce( 'genius_commerce_webhook_action' ),
'templateNonce' => wp_create_nonce( 'genius_commerce_template_action' ),
'mappingNonce' => wp_create_nonce( 'genius_commerce_mapping_action' ),
'codeEditor' => $code_editor_settings,
'i18n' => array(
'connecting' => __( 'Verbinden...', 'genius-commerce' ),
'connectError' => __( 'Verbinden mislukt', 'genius-commerce' ),
'disconnect' => __( 'Ontkoppelen', 'genius-commerce' ),
'disconnecting' => __( 'Ontkoppelen...', 'genius-commerce' ),
'disconnectConfirm' => __( 'Weet je zeker dat je de koppeling met Genius Commerce wilt verbreken? De lokale sleutel en webhooks worden verwijderd. Je kunt daarna opnieuw verbinden.', 'genius-commerce' ),
'disconnectError' => __( 'Ontkoppelen mislukt', 'genius-commerce' ),
'testing' => __( 'Verbinding testen...', 'genius-commerce' ),
'connected' => __( 'Verbonden', 'genius-commerce' ),
'error' => __( 'Fout', 'genius-commerce' ),
'testAgain' => __( 'Test opnieuw', 'genius-commerce' ),
'updating' => __( 'Bijwerken...', 'genius-commerce' ),
'active' => __( 'Actief', 'genius-commerce' ),
'paused' => __( 'Gepauzeerd', 'genius-commerce' ),
'activate' => __( 'Activeren', 'genius-commerce' ),
'pause' => __( 'Pauzeren', 'genius-commerce' ),
'saving' => __( 'Opslaan...', 'genius-commerce' ),
'saved' => __( 'Opgeslagen', 'genius-commerce' ),
'save' => __( 'Opslaan', 'genius-commerce' ),
'resetting' => __( 'Resetten...', 'genius-commerce' ),
'resetConfirm' => __( 'Weet je zeker dat je deze template wilt resetten naar de standaard?', 'genius-commerce' ),
'templateSaved' => __( 'Template opgeslagen', 'genius-commerce' ),
'templateReset' => __( 'Template gereset naar standaard', 'genius-commerce' ),
'mappingSaved' => __( 'Mapping opgeslagen', 'genius-commerce' ),
'mappingSave' => __( 'Mapping Opslaan', 'genius-commerce' ),
'refreshing' => __( 'Bezig met verversen...', 'genius-commerce' ),
'refreshFailed' => __( 'Verversen mislukt', 'genius-commerce' ),
'networkError' => __( 'Netwerkfout', 'genius-commerce' ),
),
)
);
}
/**
* AJAX handler for connection test.
*
* @return void
*/
public function ajax_test_connection(): void {
try {
// Verify nonce.
if ( ! check_ajax_referer( 'genius_commerce_test_connection', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Ongeldige beveiligingstoken', 'genius-commerce' ) ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Geen rechten', 'genius-commerce' ) ) );
}
// Clear cache for fresh test.
GC_API_Client::clear_connection_cache();
// Test connection.
$client = new GC_API_Client();
$result = $client->test_connection( true );
if ( $result['success'] ) {
// Store connection status.
update_option( 'genius_commerce_connection_status', 'connected' );
update_option( 'genius_commerce_last_connection_check', time() );
// Fetch the secret and register the webhooks — the same path the
// self-healing cron uses, so both routes can't drift apart.
GC_Webhook_Manager::clear_bootstrap_throttle();
$webhook_result = GC_Webhook_Manager::run_bootstrap();
$webhook_secret_fetched = GC_API_Client::has_webhook_secret();
$webhooks_registered = $webhook_result['registered'];
$webhook_errors = $webhook_result['errors'] ?? array();
wp_send_json_success(
array(
'message' => __( 'Verbinding succesvol!', 'genius-commerce' ),
'status' => 'connected',
'webhook_secret_status' => $webhook_secret_fetched ? 'fetched' : 'failed',
'webhooks_registered' => $webhooks_registered,
'webhook_errors' => $webhook_errors,
)
);
} else {
// Store disconnected status.
update_option( 'genius_commerce_connection_status', 'disconnected' );
wp_send_json_error(
array(
'message' => $result['error'] ?? __( 'Onbekende fout', 'genius-commerce' ),
'status' => 'disconnected',
)
);
}
} catch ( \Throwable $e ) {
Genius_Commerce::log( 'Connection test exception: ' . $e->getMessage(), 'error' );
wp_send_json_error(
array(
/* translators: %s: PHP exception message. */
'message' => sprintf( __( 'PHP fout: %s', 'genius-commerce' ), $e->getMessage() ),
'status' => 'error',
)
);
}
}
/**
* AJAX handler for updating individual webhook status.
*
* @return void
*/
public function ajax_update_webhook_status(): void {
// Verify nonce.
if ( ! check_ajax_referer( 'genius_commerce_webhook_action', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Ongeldige beveiligingstoken', 'genius-commerce' ) ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Geen rechten', 'genius-commerce' ) ) );
}
// Get and validate parameters.
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce already verified above.
$topic = isset( $_POST['topic'] ) ? sanitize_text_field( wp_unslash( $_POST['topic'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$status = isset( $_POST['status'] ) ? sanitize_text_field( wp_unslash( $_POST['status'] ) ) : '';
if ( empty( $topic ) || empty( $status ) ) {
wp_send_json_error( array( 'message' => __( 'Ontbrekende parameters', 'genius-commerce' ) ) );
}
$webhook_manager = new GC_Webhook_Manager();
$success = $webhook_manager->update_webhook_status( $topic, $status );
if ( $success ) {
wp_send_json_success(
array(
'message' => sprintf(
/* translators: 1: topic, 2: status */
__( 'Webhook %1$s is nu %2$s', 'genius-commerce' ),
GC_Webhook_Manager::TOPIC_LABELS[ $topic ] ?? $topic,
'active' === $status ? __( 'actief', 'genius-commerce' ) : __( 'gepauzeerd', 'genius-commerce' )
),
'topic' => $topic,
'status' => $status,
)
);
} else {
wp_send_json_error( array( 'message' => __( 'Kon webhook status niet bijwerken', 'genius-commerce' ) ) );
}
}
/**
* AJAX handler for updating webhook group status.
*
* @return void
*/
public function ajax_update_webhook_group(): void {
// Verify nonce.
if ( ! check_ajax_referer( 'genius_commerce_webhook_action', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Ongeldige beveiligingstoken', 'genius-commerce' ) ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Geen rechten', 'genius-commerce' ) ) );
}
// Get and validate parameters.
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce already verified above.
$group = isset( $_POST['group'] ) ? sanitize_text_field( wp_unslash( $_POST['group'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$status = isset( $_POST['status'] ) ? sanitize_text_field( wp_unslash( $_POST['status'] ) ) : '';
if ( empty( $group ) || empty( $status ) ) {
wp_send_json_error( array( 'message' => __( 'Ontbrekende parameters', 'genius-commerce' ) ) );
}
$webhook_manager = new GC_Webhook_Manager();
$updated = $webhook_manager->update_group_status( $group, $status );
if ( $updated > 0 ) {
wp_send_json_success(
array(
'message' => sprintf(
/* translators: 1: count, 2: group name */
__( '%1$d webhooks in %2$s bijgewerkt', 'genius-commerce' ),
$updated,
GC_Webhook_Manager::GROUP_LABELS[ $group ] ?? $group
),
'group' => $group,
'status' => $status,
'updated' => $updated,
)
);
} else {
wp_send_json_error( array( 'message' => __( 'Geen webhooks bijgewerkt', 'genius-commerce' ) ) );
}
}
/**
* AJAX handler for refreshing webhook status.
*
* @return void
*/
public function ajax_refresh_webhooks(): void {
// Verify nonce.
if ( ! check_ajax_referer( 'genius_commerce_webhook_action', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Ongeldige beveiligingstoken', 'genius-commerce' ) ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Geen rechten', 'genius-commerce' ) ) );
}
$webhook_manager = new GC_Webhook_Manager();
$webhooks = $webhook_manager->get_registered_webhooks();
$problematic = $webhook_manager->get_problematic_webhooks();
// Format dates for display.
$formatted_webhooks = array();
foreach ( $webhooks as $webhook ) {
$webhook['date_modified_formatted'] = $webhook['date_modified']
? $this->format_webhook_date( $webhook['date_modified'] )
: '-';
$webhook['admin_url'] = GC_Webhook_Manager::get_webhook_admin_url( $webhook['id'] );
$formatted_webhooks[ $webhook['topic'] ] = $webhook;
}
wp_send_json_success(
array(
'webhooks' => $formatted_webhooks,
'has_problems' => ! empty( $problematic ),
'problematic_count' => count( $problematic ),
'message' => __( 'Webhook status vernieuwd', 'genius-commerce' ),
)
);
}
/**
* Render template editor section.
*
* @return void
*/
private function render_template_editor_section(): void {
$template_engine = new GC_Template_Engine();
$templates = array(
'discount-price.php' => array(
'title' => __( 'Korting Weergave', 'genius-commerce' ),
'description' => __( 'Template voor korting weergave op product pagina\'s.', 'genius-commerce' ),
'placeholders' => array(
'original_price' => __( 'Originele prijs (geformatteerd)', 'genius-commerce' ),
'discount_price' => __( 'Kortingsprijs (geformatteerd)', 'genius-commerce' ),
'discount_percentage' => __( 'Kortingspercentage (bijv. "20%")', 'genius-commerce' ),
'discount_amount' => __( 'Kortingsbedrag (geformatteerd)', 'genius-commerce' ),
'reason' => __( 'Reden voor korting', 'genius-commerce' ),
),
),
'delivery-product.php' => array(
'title' => __( 'Levertijd (Product)', 'genius-commerce' ),
'description' => __( 'Template voor levertijd weergave op product pagina\'s.', 'genius-commerce' ),
'placeholders' => array(
'delivery_time' => __( 'Levertijd tekst (bijv. "2-3 werkdagen")', 'genius-commerce' ),
'delivery_date' => __( 'Verwachte leverdatum', 'genius-commerce' ),
'icon' => __( 'Levertijd icoon HTML', 'genius-commerce' ),
),
),
'delivery-cart.php' => array(
'title' => __( 'Levertijd (Cart)', 'genius-commerce' ),
'description' => __( 'Template voor levertijd in cart/checkout (via shortcode).', 'genius-commerce' ),
'placeholders' => array(
'prefix' => __( 'Tekst voor de datum', 'genius-commerce' ),
'delivery_date' => __( 'Verwachte leverdatum', 'genius-commerce' ),
),
),
);
?>
$config ) : ?>
get_template_source( $template_name );
$is_theme_override = 'theme' === $template_source;
$theme_path = $is_theme_override ? $template_engine->get_theme_template_path( $template_name ) : '';
$custom_content = $template_engine->get_custom_template( $template_name );
$default_content = $template_engine->get_default_template_content( $template_name );
$current_content = $custom_content ?? $default_content;
$is_custom = null !== $custom_content;
$template_key = sanitize_key( str_replace( '.php', '', $template_name ) );
?>
' . esc_html( $theme_path ) . ''
);
?>
render_content();
}
/**
* AJAX handler for saving a template.
*
* @return void
*/
public function ajax_save_template(): void {
// Verify nonce.
if ( ! check_ajax_referer( 'genius_commerce_template_action', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Ongeldige beveiligingstoken', 'genius-commerce' ) ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Geen rechten', 'genius-commerce' ) ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.
$template = isset( $_POST['template'] ) ? sanitize_file_name( wp_unslash( $_POST['template'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.
$content = isset( $_POST['content'] ) ? wp_kses_post( wp_unslash( $_POST['content'] ) ) : '';
if ( empty( $template ) ) {
wp_send_json_error( array( 'message' => __( 'Template naam ontbreekt', 'genius-commerce' ) ) );
}
// Validate template name.
$valid_templates = array( 'discount-price.php', 'delivery-product.php', 'delivery-cart.php' );
if ( ! in_array( $template, $valid_templates, true ) ) {
wp_send_json_error( array( 'message' => __( 'Ongeldige template', 'genius-commerce' ) ) );
}
$template_engine = new GC_Template_Engine();
$success = $template_engine->save_custom_template( $template, $content );
if ( $success ) {
wp_send_json_success(
array(
'message' => __( 'Template opgeslagen', 'genius-commerce' ),
'is_custom' => ! empty( trim( $content ) ),
)
);
} else {
wp_send_json_error( array( 'message' => __( 'Opslaan mislukt', 'genius-commerce' ) ) );
}
}
/**
* AJAX handler for resetting a template to default.
*
* @return void
*/
public function ajax_reset_template(): void {
// Verify nonce.
if ( ! check_ajax_referer( 'genius_commerce_template_action', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Ongeldige beveiligingstoken', 'genius-commerce' ) ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Geen rechten', 'genius-commerce' ) ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.
$template = isset( $_POST['template'] ) ? sanitize_file_name( wp_unslash( $_POST['template'] ) ) : '';
if ( empty( $template ) ) {
wp_send_json_error( array( 'message' => __( 'Template naam ontbreekt', 'genius-commerce' ) ) );
}
// Validate template name.
$valid_templates = array( 'discount-price.php', 'delivery-product.php', 'delivery-cart.php' );
if ( ! in_array( $template, $valid_templates, true ) ) {
wp_send_json_error( array( 'message' => __( 'Ongeldige template', 'genius-commerce' ) ) );
}
// Delete custom template option to revert to default.
$option_key = 'genius_commerce_template_' . sanitize_key( str_replace( '.php', '', $template ) );
delete_option( $option_key );
// Get default template content.
$template_engine = new GC_Template_Engine();
$default_content = $template_engine->get_default_template_content( $template );
wp_send_json_success(
array(
'message' => __( 'Template gereset naar standaard', 'genius-commerce' ),
'content' => $default_content,
)
);
}
/**
* Render the "Actuele scopes" subtab (Story 8.3).
*
* Shows the cached scope-list, the cache's `updated_at` timestamp, a
* "Nu verversen"-button (AJAX) and — when the feature-flag is off — a
* banner explaining that enforcement is in fallback-mode. The banner
* is the loudest signal so a tenant-admin notices the cutover hasn't
* happened yet.
*
* @return void
*/
private function render_scopes_section(): void {
$enforcement_on = GC_Scopes::is_enforcement_enabled();
$cached = GC_Scopes::get_cached();
?>
__( 'Ongeldige beveiligingstoken', 'genius-commerce' ) ) );
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Geen rechten', 'genius-commerce' ) ) );
}
$result = GC_Scopes::force_refresh();
if ( $result instanceof \WP_Error ) {
wp_send_json_error(
array(
'message' => sprintf(
/* translators: %s: backend error message */
__( 'Verversen mislukt: %s', 'genius-commerce' ),
$result->get_error_message()
),
)
);
}
wp_send_json_success(
array(
'message' => __( 'Scopes ververst', 'genius-commerce' ),
'version' => $result['version'],
'scopes' => $result['scopes'],
)
);
}
}