__('Trial', 'gresquote'), 'starter' => 'Starter', 'pro' => 'Pro', 'agency' => 'Business', 'business' => 'Business', 'enterprise' => 'Enterprise'];
$plan = strtolower(trim((string) $plan));
return isset($namen[$plan]) ? $namen[$plan] : ucfirst($plan);
}
/**
* 1.9.32: de website is nog niet gekoppeld, maar de backend kent hem al.
*
* Wie eerst een abonnement koopt op wpquotepro.nl en daarna pas de plugin
* installeert, zag hier "Gratis proef starten" als aanbevolen keuze. Die
* proef weigert de backend (de site heeft al een account), dus dat was een
* omweg naar de knop die hij nodig had: Verbinden.
*
* Geeft de kop en de uitleg terug, of null als er niets bekend is. Puur.
*/
public static function bekende_site_melding($tenant_id, $plan) {
if ('' === trim((string) $tenant_id)) {
return null;
}
$plan = strtolower(trim((string) $plan));
if ('' === $plan || 'trial' === $plan) {
return [
'kop' => __('This website already has a trial', 'gresquote'),
'uitleg' => __('A GresQuote trial is already running for this website. Enter the email address the trial was started with to connect the website again.', 'gresquote'),
];
}
return [
'kop' => __('There is already a subscription for this website', 'gresquote'),
/* translators: %s: plan name, for example Starter */
'uitleg' => sprintf(__('GresQuote %s has been bought for this website. Enter the email address you used to buy the subscription; the website is then connected and you can test and go live.', 'gresquote'), self::plan_naam($plan)),
];
}
// TP-34: gezet als de templatecatalogus niet kon worden opgehaald.
private $catalogus_fout = '';
// 1.9.7: backend-templates één keer per paginaweergave ophalen, zie
// get_backend_form_templates().
private $backend_templates_memo = null;
private $registry;
private $rules_table;
private $forms_table;
private $products_table;
private $logs_table;
private $form_product_mappings_table;
private $form_customer_mappings_table;
public function __construct(WPQUOTEPRO_Form_Registry $registry) {
$this->registry = $registry;
$this->rules_table = WPQUOTEPRO_DB::rules_table();
$this->forms_table = WPQUOTEPRO_DB::forms_table();
$this->products_table = WPQUOTEPRO_DB::products_table();
$this->logs_table = WPQUOTEPRO_DB::logs_table();
$this->form_product_mappings_table = WPQUOTEPRO_DB::form_product_mappings_table();
$this->form_customer_mappings_table = WPQUOTEPRO_DB::form_customer_mappings_table();
}
public function init() {
add_action('admin_menu', [$this, 'register_menu']);
add_action('admin_init', [$this, 'stuur_regels_door']);
add_action('admin_init', [$this, 'stuur_vergrendelde_wizardstap_door']);
add_filter('submenu_file', [$this, 'markeer_menu']);
add_action('admin_head', [$this, 'verberg_menu_items']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
// TP-12: de twee wp_ajax-handlers voor de WordPress-credentials zijn hier
// weggehaald, samen met het bijbehorende scherm. Dat scherm werd nergens
// aangeroepen — de instellingenpagina kent alleen de tabbladen 'Algemeen'
// en 'Beheer abonnement' — terwijl de endpoints wél geregistreerd bleven.
// Dat was pure aanvalsoppervlakte: bereikbaar voor een aanvaller (TP-05),
// onbereikbaar voor de beheerder. De hele app-wachtwoordroute wordt
// vervangen door HMAC-ondertekening met het per-tenant webhook_secret.
add_action('admin_post_wpquotepro_save_form', [$this, 'handle_save_form']);
add_action('admin_post_wpquotepro_delete_form', [$this, 'handle_delete_form']);
add_action('admin_post_wpquotepro_save_product', [$this, 'handle_save_product']);
add_action('admin_post_wpquotepro_delete_product', [$this, 'handle_delete_product']);
add_action('admin_post_wpquotepro_toggle_product', [$this, 'handle_toggle_product']);
add_action('admin_post_wpquotepro_save_rule', [$this, 'handle_save_rule']);
add_action('admin_post_wpquotepro_delete_rule', [$this, 'handle_delete_rule']);
add_action('admin_post_wpquotepro_toggle_rule', [$this, 'handle_toggle_rule']);
add_action('admin_post_wpquotepro_save_settings', [$this, 'handle_save_settings']);
add_action('admin_post_wpquotepro_save_accounting', [$this, 'handle_save_accounting']);
add_action('admin_post_wpquotepro_connect_accounting', [$this, 'handle_connect_accounting']);
add_action('admin_post_wpquotepro_save_accounting_settings', [$this, 'handle_save_accounting_settings']);
add_action('admin_post_wpquotepro_disconnect_accounting', [$this, 'handle_disconnect_accounting']);
add_action('admin_post_wpquotepro_register_site', [$this, 'handle_register_site']);
add_action('admin_post_wpquotepro_connect_account', [$this, 'handle_connect_account']);
add_action('admin_post_wpquotepro_sync_templates', [$this, 'handle_sync_templates']);
add_action('admin_post_wpquotepro_save_enrichment', [$this, 'handle_save_enrichment']);
add_action('admin_post_wpquotepro_reopen_quotation', [$this, 'handle_reopen_quotation']);
// 1.0.5: een bijlage bij een aanvraag openen (alleen voor beheerders).
add_action('admin_post_wpquotepro_bijlage', ['WPQUOTEPRO_Bijlagen', 'serveer']);
add_action('admin_post_wpquotepro_wizard_save_step1', [$this, 'handle_wizard_save_step1']);
add_action('admin_post_wpquotepro_wizard_save_step3', [$this, 'handle_wizard_save_step3']);
add_action('admin_post_wpquotepro_wizard_create_blank_form', [$this, 'handle_wizard_create_blank_form']);
add_action('wp_ajax_wpquotepro_wizard_create_product', [$this, 'ajax_wizard_create_product']);
add_action('wp_ajax_wpquotepro_wizard_preview', [$this, 'ajax_wizard_preview']);
add_action('admin_post_wpquotepro_wizard_activate', [$this, 'handle_wizard_activate']);
add_action('admin_post_wpquotepro_form_create_page', [$this, 'handle_form_create_page']);
// 1.9.27: bronformulier verwijderd of formulierplugin uit.
add_action('admin_post_wpquotepro_form_recreate', [$this, 'handle_form_recreate']);
add_action('admin_notices', [$this, 'render_bron_weg_melding']);
// De melding meteen bijwerken als er een formulier of plugin verdwijnt.
foreach (['deleted_post', 'trashed_post', 'untrashed_post', 'activated_plugin', 'deactivated_plugin', 'gform_after_delete_form', 'gform_post_form_trashed', 'gform_post_form_restored', 'gform_post_form_deactivated', 'gform_post_form_activated'] as $haak) {
add_action($haak, [$this, 'vergeet_bron_weg']);
}
add_action('admin_post_wpquotepro_wizard_set_test_mode', [$this, 'handle_wizard_set_test_mode']);
add_action('admin_post_wpquotepro_enable_test_mode', [$this, 'handle_enable_test_mode']);
add_action('admin_post_wpquotepro_manual_test_submit', [$this, 'handle_manual_test_submit']);
add_action('admin_post_wpquotepro_install_template', [$this, 'handle_install_template']);
add_action('wpquotepro_daily_health_check', [WPQuotePro_Health_Check::class, 'periodic_report']);
}
/**
* 1.9.21: het menu van tien naar zes items. Offertes staat bovenaan zodra er
* een formulier live staat; daarvoor opent WPQuotePro op Instellingen, waar
* "Zo ga je live" staat. Nieuw product, Regels, Nieuwe regel en
* Verzendingen gaan uit het menu maar blijven bereikbaar: oude links en
* bladwijzers werken dus nog. Ze staan wel onder het hoofdmenu geregistreerd
* (anders heeft de pagina geen titel en licht het menu niet op) en gaan in
* admin_head uit de zichtbare lijst (verberg_menu_items).
*/
public function register_menu() {
$live = $this->heeft_live_formulier();
$hoofd = $live ? 'wpquotepro-my-quotes' : 'wpquotepro-settings';
$hoofd_scherm = $live ? [$this, 'render_my_quotes_page'] : [$this, 'render_settings_page'];
add_menu_page('GresQuote', 'GresQuote', 'manage_options', $hoofd, $hoofd_scherm, 'dashicons-media-spreadsheet', 58);
$items = [
[__('Quotes', 'gresquote'), 'wpquotepro-my-quotes', 'render_my_quotes_page'],
[__('Forms', 'gresquote'), 'wpquotepro-forms', 'render_forms_page'],
[__('New quote form', 'gresquote'), 'wpquotepro-wizard', 'render_wizard_page'],
[__('Products', 'gresquote'), 'wpquotepro-products', 'render_products_page'],
[__('Templates', 'gresquote'), 'wpquotepro-templates', 'render_templates_library_page'],
[__('Settings', 'gresquote'), 'wpquotepro-settings', 'render_settings_page'],
];
// Niet live: Instellingen eerst, want daar begint het.
if (! $live) {
array_unshift($items, array_pop($items));
}
foreach ($items as $item) {
add_submenu_page($hoofd, $item[0], $item[0], 'manage_options', $item[1], [$this, $item[2]]);
}
$verborgen = [
[__('Product', 'gresquote'), 'wpquotepro-product-editor', 'render_product_editor_page'],
[__('Submissions', 'gresquote'), 'wpquotepro-submissions', 'render_submissions_page'],
[__('Rules', 'gresquote'), 'wpquotepro-rules', 'render_doorverwijzing'],
[__('Rule', 'gresquote'), 'wpquotepro-rule-editor', 'render_doorverwijzing'],
[__('Test form', 'gresquote'), 'wpquotepro-test-flow', 'render_test_flow_page'],
[__('Template live demo', 'gresquote'), 'wpquotepro-template-demo', 'render_template_demo_page'],
[__('Notes on questions', 'gresquote'), 'wpquotepro-enrichment', 'render_enrichment_page'],
];
foreach ($verborgen as $item) {
add_submenu_page($hoofd, $item[0], $item[0], 'manage_options', $item[1], [$this, $item[2]]);
}
$this->verborgen_menu = [$hoofd, array_column($verborgen, 1)];
}
/** @var array{0:string,1:string[]} Hoofdmenu en de pagina's die niet in het menu horen. */
private $verborgen_menu = ['', []];
/**
* Haalt de verborgen pagina's uit het zichtbare menu. Pas in admin_head: dan
* heeft WordPress de titel en het bovenliggende menu al bepaald.
*/
public function verberg_menu_items() {
foreach ($this->verborgen_menu[1] as $slug) {
remove_submenu_page($this->verborgen_menu[0], $slug);
}
}
/** Tabbladen Formulieren | Verzendingen (1.9.21: Verzendingen staat niet meer in het menu). */
private function render_formulieren_tabs($actief) {
$tabs = [
'formulieren' => [__('Forms', 'gresquote'), admin_url('admin.php?page=wpquotepro-forms')],
'verzendingen' => [__('Submissions', 'gresquote'), admin_url('admin.php?page=wpquotepro-submissions')],
];
?>
get_var($wpdb->prepare("SELECT id FROM %i WHERE status = 'active' AND source_type <> 'internal' AND external_form_id <> '' LIMIT 1", $this->forms_table));
}
/**
* Een pagina zonder menu-item laat het goede menu-item oplichten:
* Product bij Producten, Verzendingen en Toelichting bij Formulieren, de demo
* bij Templates.
*/
public function markeer_menu($submenu_file) {
$pagina = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
$bij = [
'wpquotepro-product-editor' => 'wpquotepro-products',
'wpquotepro-submissions' => 'wpquotepro-forms',
'wpquotepro-enrichment' => 'wpquotepro-forms',
'wpquotepro-test-flow' => 'wpquotepro-forms',
'wpquotepro-template-demo' => 'wpquotepro-templates',
];
return isset($bij[$pagina]) ? $bij[$pagina] : $submenu_file;
}
/**
* 1.9.21: Regels en Nieuwe regel bestaan niet meer als losse pagina's.
* Regels horen bij een formulier en staan in de wizard bij Prijs, met een
* voorbeeldofferte ernaast. Oude links (bladwijzers, handleiding, meldingen
* met ?message=) komen daar uit, met de melding erbij.
*/
public function stuur_regels_door() {
$pagina = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
if (! in_array($pagina, ['wpquotepro-rules', 'wpquotepro-rule-editor'], true) || ! current_user_can('manage_options')) {
return;
}
wp_safe_redirect($this->regels_doel_url($pagina, $_GET));
exit;
}
/**
* 1.0.24: een vergrendelde wizardstap stuurt door naar de eerste stap die wel
* open is. Dat gebeurde in render_wizard_page(), toen de pagina al uitvoer had
* ("headers already sent"): de beheerder zag een lege pagina in plaats van de
* melding. Nu vóór de uitvoer, net als stuur_regels_door().
*/
public function stuur_vergrendelde_wizardstap_door() {
$pagina = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- alleen lezen welke pagina wordt getoond
if ('wpquotepro-wizard' !== $pagina || ! current_user_can('manage_options')) {
return;
}
$doel = $this->vergrendelde_wizardstap_doel();
if ('' !== $doel) {
wp_safe_redirect($doel);
exit;
}
}
/** De wizardstap uit de URL, zoals render_wizard_page() hem leest. */
private function gevraagde_wizardstap() {
$requested_step = isset($_GET['step']) ? absint($_GET['step']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- alleen lezen
// 1.9.13: van vijf naar drie stappen. Oude links naar stap 4 (regels) en
// 5 (testen) komen uit in Prijs en Testen & live.
$oude_stappen = [4 => 2, 5 => 3];
if (isset($oude_stappen[$requested_step])) {
$requested_step = $oude_stappen[$requested_step];
}
return max(0, min(3, $requested_step));
}
/** Waarheen als de gevraagde stap nog op slot zit; leeg als hij open is. */
private function vergrendelde_wizardstap_doel() {
$requested_step = $this->gevraagde_wizardstap();
$selected_form_id = isset($_GET['form_id']) ? absint($_GET['form_id']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- alleen lezen
if (! $selected_form_id) {
return '';
}
$state = $this->get_wizard_state($selected_form_id, true);
$current_step = $this->get_first_accessible_wizard_step(max(1, $requested_step), $state['can_access']);
if ($current_step === $requested_step) {
return '';
}
return admin_url('admin.php?page=wpquotepro-wizard&step=' . (int) $current_step . '&form_id=' . (int) $state['selected_form_id'] . '&message=wizard_step_locked');
}
/** Waar een oude regel-URL heen moet. Los van de redirect, zodat hij te testen is. */
private function regels_doel_url($pagina, array $query) {
global $wpdb;
$form_id = isset($query['form_id']) ? absint($query['form_id']) : 0;
$rule_id = isset($query['rule_id']) ? absint($query['rule_id']) : 0;
if ($rule_id && ! $form_id) {
$form_id = (int) $wpdb->get_var($wpdb->prepare("SELECT form_id FROM %i WHERE id = %d", $this->rules_table, $rule_id));
}
if (! $form_id) {
$form_id = (int) $wpdb->get_var($wpdb->prepare("SELECT id FROM %i WHERE source_type <> 'internal' ORDER BY id ASC LIMIT 1", $this->forms_table));
}
if (! $form_id) {
return admin_url('admin.php?page=wpquotepro-wizard&step=0');
}
$args = ['page' => 'wpquotepro-wizard', 'step' => 2, 'form_id' => $form_id];
if (! empty($query['message'])) {
$args['message'] = sanitize_key(wp_unslash($query['message']));
}
$anker = '#wpqp-regels';
if ('wpquotepro-rule-editor' === $pagina) {
$args['regel'] = $rule_id ? $rule_id : 'nieuw';
$anker = '#wpqp-regel-paneel';
}
return add_query_arg($args, admin_url('admin.php')) . $anker;
}
/** Vangnet als de doorverwijzing niet liep (bijvoorbeeld al uitvoer verstuurd). */
public function render_doorverwijzing() {
$pagina = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
echo '
';
}
public function enqueue_assets($hook) {
if (strpos($hook, 'wpquotepro-') === false) {
return;
}
wp_enqueue_style('wpquotepro-admin-css', WPQUOTEPRO_PLUGIN_URL . 'assets/css/admin-rules.css', [], WPQUOTEPRO_VERSION);
// De huisstijl van wpquotepro.nl (Inter + oranje): de wizard sinds 1.9.12,
// Instellingen sinds 1.9.18, alle pluginpagina's sinds 1.9.21.
$huidige_pagina = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
wp_enqueue_style('wpquotepro-huisstijl-css', WPQUOTEPRO_PLUGIN_URL . 'assets/css/wizard-huisstijl.css', ['wpquotepro-admin-css'], WPQUOTEPRO_VERSION);
if (in_array($huidige_pagina, ['wpquotepro-settings', 'wpquotepro-product-editor'], true)) {
// 1.9.18: logo kiezen, "nog niet opgeslagen" en de sleutel tonen.
wp_enqueue_script('wpquotepro-instellingen-js', WPQUOTEPRO_PLUGIN_URL . 'assets/js/instellingen.js', ['jquery'], WPQUOTEPRO_VERSION, true);
wp_localize_script('wpquotepro-instellingen-js', 'wpquoteproInstellingen', [
'locale' => str_replace('_', '-', get_user_locale()),
'i18n' => [
'chooseLogo' => __('Choose logo', 'gresquote'),
'chooseOtherLogo' => __('Choose another logo', 'gresquote'),
'pickLogo' => __('Choose a logo', 'gresquote'),
'useLogo' => __('Use this logo', 'gresquote'),
'tokenHelp' => __('You create this token in your account with your accounting software.', 'gresquote'),
'unsaved' => __('You have changes that have not been saved yet.', 'gresquote'),
'badColour' => __('This is not a colour code. Use # with six characters, for example #0B2E59. Saved like this, it becomes dark blue.', 'gresquote'),
'hide' => __('Hide', 'gresquote'),
'show' => __('Show', 'gresquote'),
],
]);
}
if ('wpquotepro-wizard' === $huidige_pagina) {
// 1.9.13: klantvelden herkennen, product aanmaken in de stap Prijs.
wp_enqueue_script('wpquotepro-wizard-js', WPQUOTEPRO_PLUGIN_URL . 'assets/js/wizard.js', ['jquery', 'wpquotepro-admin-js'], WPQUOTEPRO_VERSION, true);
}
wp_enqueue_script('wpquotepro-admin-js', WPQUOTEPRO_PLUGIN_URL . 'assets/js/admin-rules.js', ['jquery'], WPQUOTEPRO_VERSION, true);
// 1.0.13: zoeken, filters, kopiëren, testformulier en afbeeldingen kiezen;
// stonden eerder als losse scriptblokken in de schermen.
wp_enqueue_script('wpquotepro-admin-pages-js', WPQUOTEPRO_PLUGIN_URL . 'assets/js/admin-pages.js', ['jquery'], WPQUOTEPRO_VERSION, true);
wp_enqueue_media();
wp_localize_script(
'wpquotepro-admin-js',
'WPQUOTEPROAdmin',
[
'externalFormsBySource' => $this->get_external_forms_by_source(),
'fieldSuggestionsByForm' => $this->get_field_suggestions_by_internal_form(),
'externalFieldChoicesBySourceForm' => $this->get_external_field_choices_by_source_form(),
// Engeland, fase 1: teksten voor admin-rules.js, wizard.js en
// admin-pages.js (die laden op dezelfde pagina's), en de taal
// voor getallen en bedragen.
'locale' => str_replace('_', '-', get_user_locale()),
'currency' => WPQUOTEPRO_Regio::huidig()['valuta'],
'i18n' => [
'selectExternalForm' => __('Choose a form', 'gresquote'),
'internalSourceMessage' => __('Internal forms do not use an external source.', 'gresquote'),
'selectFieldValue' => __('Select a value', 'gresquote'),
'enterFieldValue' => __('Type a value manually', 'gresquote'),
'valueModeSelect' => __('Choose from options', 'gresquote'),
'valueModeManual' => __('Enter manually', 'gresquote'),
'externalFormHelp' => __('Choose the external form that is linked to this quote profile.', 'gresquote'),
'actionAddProduct' => __('add product', 'gresquote'),
'actionFixedAmount' => __('add fixed amount', 'gresquote'),
'actionAmountPerQuantity' => __('add amount per quantity', 'gresquote'),
'actionTextLine' => __('add text line', 'gresquote'),
'actionDiscount' => __('give a discount of', 'gresquote'),
'actionSurcharge' => __('charge a surcharge of', 'gresquote'),
'opEquals' => __('is equal to', 'gresquote'),
'opNotEquals' => __('is not equal to', 'gresquote'),
'opGreater' => __('is greater than', 'gresquote'),
'opLess' => __('is less than', 'gresquote'),
'opGreaterOrEqual' => __('is greater than or equal to', 'gresquote'),
'opLessOrEqual' => __('is less than or equal to', 'gresquote'),
'opContains' => __('contains', 'gresquote'),
'opEmpty' => __('is empty', 'gresquote'),
'opNotEmpty' => __('is not empty', 'gresquote'),
'helpAddProduct' => __('For a product that depends on a condition, for example "if area > 20". Does the product belong to a choice in your form? Then link it under Base price.', 'gresquote'),
'helpAppendProduct' => __('For a product that depends on a condition. Does the product belong to a choice in your form? Then link it under Base price.', 'gresquote'),
'helpFixedAmount' => __('Adds a fixed amount to the quote, for example for fitting or transport.', 'gresquote'),
'helpAmountPerQuantity' => __('An amount times a quantity from your form, for example a price per metre.', 'gresquote'),
'helpTextLine' => __('Adds a line of text without an amount to the quote, for example "Price subject to measuring". You enter the text below under Text on the quote.', 'gresquote'),
'helpPercentageDiscount' => __('A percentage discount on the base amount.', 'gresquote'),
'helpDiscountAmount' => __('A fixed amount off, for example a 50 euro introductory discount.', 'gresquote'),
'helpPercentageSurcharge' => __('A percentage surcharge on the amount after discount.', 'gresquote'),
'helpFixedSurcharge' => __('A fixed amount added as a surcharge, for example a 25 euro rush surcharge.', 'gresquote'),
'helpDefault' => __('Choose what should happen when this rule matches.', 'gresquote'),
'quantityOne' => __('Quantity: 1', 'gresquote'),
/* translators: %s: field name */
'quantityFromField' => __('Quantity from field: %s', 'gresquote'),
'quantityFromFieldChoose' => __('Quantity from field: choose one', 'gresquote'),
'fieldFallback' => __('field', 'gresquote'),
'chooseProduct' => __('(choose a product)', 'gresquote'),
/* translators: %s: amount in euros */
'amountEuro' => __('%s euro', 'gresquote'),
'enterText' => __('(enter the text)', 'gresquote'),
'ruleIf' => __('IF', 'gresquote'),
'ruleThen' => __('THEN', 'gresquote'),
'selectField' => __('Select field', 'gresquote'),
'quantityHelpField' => __('The value from this field is used as the quantity of the item.', 'gresquote'),
'quantityHelpOne' => __('The quantity stays 1.', 'gresquote'),
'quantityHelpNone' => __('This action does not need a separate quantity field.', 'gresquote'),
'labelTextOnQuote' => __('Text on the quote', 'gresquote'),
'labelDescriptionOnQuote' => __('Description on quote', 'gresquote'),
'selectChoiceField' => __('Select a choice field', 'gresquote'),
'alwaysOne' => __('Always 1', 'gresquote'),
'selectChoiceFieldFirst' => __('Select a choice field first', 'gresquote'),
'selectOption' => __('Select an option', 'gresquote'),
'doNotLink' => __('Do not link', 'gresquote'),
'checkNameOk' => __('Name has been filled in.', 'gresquote'),
'checkNameMissing' => __('Name has not been filled in yet.', 'gresquote'),
'checkFormOk' => __('A form has been chosen.', 'gresquote'),
'checkFormMissing' => __('Choose a form.', 'gresquote'),
'checkCustomerNameOk' => __('Name is linked (full name, or first name + last name).', 'gresquote'),
'checkCustomerNameMissing' => __('Link the full name, or first name + last name.', 'gresquote'),
'checkEmailOk' => __('Email address is linked.', 'gresquote'),
'checkEmailMissing' => __('Link the email address.', 'gresquote'),
/* translators: %s: number of product links (always 1) */
'activeLinkOne' => __('%s active product link.', 'gresquote'),
/* translators: %s: number of product links */
'activeLinksMany' => __('%s active product links.', 'gresquote'),
'checkLinkMissing' => __('Link at least one choice to a product.', 'gresquote'),
'recognised' => __('✓ recognised', 'gresquote'),
'productNameMissing' => __('Give the product a name.', 'gresquote'),
'productNotSaved' => __('The product could not be saved.', 'gresquote'),
'productNotSavedRetry' => __('The product could not be saved. Please try again.', 'gresquote'),
'unsavedBasePrice' => __('Your changes to Base price have not been saved yet. Continue anyway?', 'gresquote'),
'previewNothing' => __('With these answers nothing appears on the quote yet. Pick another choice, or link this choice to a product.', 'gresquote'),
'exclVat' => __('Excl. VAT', 'gresquote'),
'vat' => __('VAT', 'gresquote'),
'total' => __('Total', 'gresquote'),
'inclVat' => __('incl. VAT', 'gresquote'),
'previewFailed' => __('The calculation could not be retrieved.', 'gresquote'),
'previewFailedRetry' => __('The calculation could not be retrieved. Please try again shortly.', 'gresquote'),
'copied' => __('Copied', 'gresquote'),
'copy' => __('Copy', 'gresquote'),
'testProcessing' => __('Processing test...', 'gresquote'),
'imageAltPlaceholder' => __('What is the customer looking at?', 'gresquote'),
'remove' => __('Remove', 'gresquote'),
'chooseQuestionImages' => __('Choose images for this question', 'gresquote'),
'addToQuestion' => __('Add to the question', 'gresquote'),
],
]
);
}
private function get_form_status_options() {
return [
'draft' => __('Draft', 'gresquote'),
'test' => __('Test', 'gresquote'),
'active' => __('Active', 'gresquote'),
'inactive' => __('Inactive', 'gresquote'),
'archived' => __('Archived', 'gresquote'),
];
}
private function sanitize_form_status($status) {
$status = sanitize_key((string) $status);
return array_key_exists($status, $this->get_form_status_options()) ? $status : 'draft';
}
private function get_form_status_label($status) {
$status = $this->sanitize_form_status($status);
$options = $this->get_form_status_options();
return isset($options[$status]) ? $options[$status] : ucfirst($status);
}
private function get_form_status_css_class($status) {
$status = $this->sanitize_form_status($status);
return 'is-' . $status;
}
private function get_configured_api_key() {
$api_key = trim((string) get_option('wpquotepro_api_key', ''));
if ('' === $api_key) {
$api_key = trim((string) get_option('wpquotepro_api_number', ''));
}
return $api_key;
}
/**
* Meldt de eigen (al bekende) API-sleutel bij de backend-cache, zodat "Mijn
* account" op wpquotepro.nl 'm ook kan tonen voor trial-tenants zonder
* WooCommerce-bestelling — die hebben normaal geen enkel pad waarlangs hun
* volledige sleutel ooit opnieuw op te vragen is. De backend verifieert dit
* zelf door de sleutel te hashen en te matchen tegen de opgeslagen hash, dus
* er is geen apart geheim voor nodig: wie de juiste sleutel al kent, mag 'm
* (opnieuw) cachen. Maximaal 1x per 12 uur bij succes — mislukt de aanroep,
* dan wordt de drempel NIET gezet, zodat de eerstvolgende pagina-load het
* gewoon opnieuw probeert in plaats van 12 uur stil te falen.
*
* Let op de "_v2"-suffix in de transient-naam: een eerdere pluginversie zette
* deze vlag ALTIJD (ook bij een mislukte poging, "fire and forget"). Zo'n
* foutieve vlag overleeft een plugin-update gewoon (transients zijn losse
* database-rijen, niet gekoppeld aan de geïnstalleerde versie) en zou anders
* tot 12 uur lang iedere nieuwe, correcte poging blokkeren. Door de sleutel om
* te dopen vervalt een oude, mogelijk foutieve vlag vanzelf.
*/
private function maybe_cache_api_key_for_account_page($api_key) {
if ('' === $api_key || !defined('WPQUOTEPRO_API_KEY_CACHE_ENDPOINT') || !WPQUOTEPRO_API_KEY_CACHE_ENDPOINT) {
return;
}
if (get_transient('wpquotepro_api_key_cached_recently_v3')) {
return;
}
$resp = wp_remote_post(WPQUOTEPRO_API_KEY_CACHE_ENDPOINT, [
'timeout' => 8,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode(['api_key' => $api_key]),
]);
if (is_wp_error($resp)) {
wpquotepro_log('WP Quote Pro: API-key cachen mislukt (request error): ' . $resp->get_error_message());
return;
}
$code = (int) wp_remote_retrieve_response_code($resp);
if ($code < 200 || $code >= 300) {
wpquotepro_log('WP Quote Pro: API-key cachen mislukt (HTTP ' . $code . '): ' . wp_remote_retrieve_body($resp));
return;
}
set_transient('wpquotepro_api_key_cached_recently_v3', 1, 12 * HOUR_IN_SECONDS);
}
private function get_request_identity_payload($customer_name = '', $customer_email = '', $is_test = false, $woo_order_id = '') {
$product_type = (string) apply_filters('wpquotepro_request_product_type', get_option('wpquotepro_product_type', 'subscription'));
$product_code = trim((string) get_option('wpquotepro_product_code', ''));
if ('' === $product_code) {
$plan = strtoupper(trim((string) get_option('wpquotepro_plan', 'PRO')));
$product_code = 'WPQUOTEPRO_' . preg_replace('/[^A-Z0-9_]/', '_', $plan);
}
$woo_order_id = '' !== (string) $woo_order_id ? (string) $woo_order_id : (string) get_option('wpquotepro_woo_order_id', '');
return [
'site_url' => home_url('/'),
'company_name' => (string) get_option('wpquotepro_company_name', get_bloginfo('name')),
'customer_email' => (string) get_option('wpquotepro_company_email', get_option('admin_email', '')),
'customer_name' => (string) $customer_name,
'wordpress_user_id' => (string) get_current_user_id(),
'woo_order_id' => (string) apply_filters('wpquotepro_request_woo_order_id', $woo_order_id),
'product_type' => $product_type,
'product_code' => (string) apply_filters('wpquotepro_request_product_code', $product_code),
'is_test' => (bool) $is_test,
];
}
/**
* 1.0.0: gekoppeld = er staat een sleutel EN de backend heeft niet gezegd dat
* hij hem niet kent (zie wpquotepro_verwerk_sleutelcontrole()). Tot nu toe
* telde alleen de sleutel, en bleef het scherm "Actief" tonen voor een
* account dat bij ons verwijderd was.
*/
private function is_gekoppeld() {
return self::koppeling_werkt($this->get_configured_api_key(), get_option('wpquotepro_koppeling_onbekend', ''));
}
/** Puur, getest in tests/run-tests.php. */
public static function koppeling_werkt($api_key, $onbekend_sinds) {
return '' !== trim((string) $api_key) && '' === trim((string) $onbekend_sinds);
}
private function get_license_status() {
$api_key = trim((string) get_option('wpquotepro_api_key', ''));
$tenant_id = trim((string) get_option('wpquotepro_tenant_id', ''));
$plan = trim((string) get_option('wpquotepro_plan', ''));
$status = trim((string) get_option('wpquotepro_license_status', ''));
$registered_at = trim((string) get_option('wpquotepro_registered_at', ''));
$trial_limit = (int) get_option('wpquotepro_trial_quote_limit', 25);
return [
'connected' => self::koppeling_werkt($api_key, get_option('wpquotepro_koppeling_onbekend', '')),
'api_key' => $api_key,
'tenant_id' => $tenant_id,
'plan' => '' !== $plan ? $plan : 'niet gekoppeld',
'status' => '' !== $status ? $status : ('trial' === $plan ? 'trial' : 'niet actief'),
'registered_at' => $registered_at,
'trial_limit' => $trial_limit > 0 ? $trial_limit : 25,
];
}
/**
* Waar stuurt de knop rechtsboven de ondernemer heen?
*
* Wie al klant is, wil zijn abonnement beheren of opzeggen -- die hoort in
* Mijn account terecht te komen. Hiervoor ging deze knop altijd naar de
* prijslijst, ook voor bestaande klanten. Zolang er niets te beheren viel
* merkte niemand dat, maar zodra er automatisch wordt afgeschreven is dit
* precies de knop waar iemand op klikt die wil stoppen. Die komt dan op een
* verkooppagina uit, en dat is hoe je een terugboeking verdient in plaats
* van een opzegging.
*
* Wie nog geen klant is hoort juist wel bij de prijzen te beginnen.
*/
private function get_subscription_manage_url($connected = false) {
if ($connected) {
return esc_url_raw(apply_filters('wpquotepro_account_url', WPQUOTEPRO_Regio::site_url('account')));
}
return esc_url_raw(apply_filters('wpquotepro_pricing_url', WPQUOTEPRO_Regio::site_url('prijzen')));
}
/**
* De stand van het abonnement op het tabblad Abonnement (1.9.20): label,
* kleur, melding en de hoofdknop. De backend beslist wat er mag; dit kiest
* alleen de woorden. De getallen komen uit usage-by-site
* (wpquotepro_fetch_and_update_usage_limits).
*/
private function abonnement_stand($license_status) {
$status = strtolower((string) $license_status['status']);
$plan = strtolower((string) $license_status['plan']);
$is_proef = 'trial' === $status || 'trial' === $plan;
$limiet_bereikt = '1' === (string) get_option('wpquotepro_limit_reached', '0');
$meerverbruik = (int) get_option('wpquotepro_overage', 0);
$harde_stop = (int) get_option('wpquotepro_hard_stop_at', 0);
$account = $this->get_subscription_manage_url(true);
$prijzen = $this->get_subscription_manage_url(false);
$stand = [
'label' => __('Active', 'gresquote'),
'soort' => 'is-goed',
'melding' => '',
'knop' => [__('Manage subscription', 'gresquote'), $account],
'extra' => null,
];
if ($limiet_bereikt && $is_proef) {
$stand = ['label' => __('Trial ended', 'gresquote'), 'soort' => 'is-fout', 'knop' => [__('Choose a subscription', 'gresquote'), $prijzen], 'extra' => null,
'melding' => __('Your trial has ended. Choose a subscription to make quotes again; your settings stay as they are.', 'gresquote')];
} elseif ($limiet_bereikt) {
$stand = ['label' => __('Stopped this month', 'gresquote'), 'soort' => 'is-fout', 'knop' => [__('Manage subscription', 'gresquote'), $account], 'extra' => [__('See prices', 'gresquote'), $prijzen],
'melding' => __('This month you have used twice the number of quotes in your plan. Until the new month starts, no new requests are processed unless you choose a larger plan.', 'gresquote')];
} elseif (in_array($status, ['expired', 'verlopen'], true)) {
$stand = ['label' => __('Expired', 'gresquote'), 'soort' => 'is-fout', 'knop' => [__('Renew subscription', 'gresquote'), $account], 'extra' => null,
'melding' => __('Your subscription has expired. Renew it to make quotes again; your settings stay as they are.', 'gresquote')];
} elseif (in_array($status, ['blocked', 'suspended', 'geblokkeerd'], true)) {
$stand = ['label' => __('Blocked', 'gresquote'), 'soort' => 'is-fout', 'knop' => [__('Manage subscription', 'gresquote'), $account], 'extra' => null,
'melding' => __('This connection has been blocked. Please contact GresQuote support.', 'gresquote')];
} elseif ($meerverbruik > 0) {
$stand = ['label' => __('Over your limit', 'gresquote'), 'soort' => 'is-let-op', 'knop' => [__('Manage subscription', 'gresquote'), $account], 'extra' => [__('See prices', 'gresquote'), $prijzen],
/* translators: %s: price of one extra quote, e.g. $0.40 */
'melding' => sprintf(__('You are over the number of quotes in your plan. Quotes carry on as normal; each extra quote costs %s excl. VAT', 'gresquote'), WPQUOTEPRO_Regio::meerverbruik_prijs(' '))
/* translators: %d: maximum number of quotes this month; follows "each extra quote costs $0.40 excl. VAT" */
. ($harde_stop ? sprintf(__(', up to a maximum of %d quotes this month', 'gresquote'), $harde_stop) : '') . '.'];
} elseif ($is_proef) {
$stand = ['label' => __('Trial', 'gresquote'), 'soort' => 'is-let-op', 'knop' => [__('Choose a subscription', 'gresquote'), $prijzen], 'extra' => null, 'melding' => ''];
$eind = strtotime((string) get_option('wpquotepro_trial_ends_at', ''));
if ($eind) {
$dagen = (int) ceil(($eind - time()) / 86400);
if ($dagen > 0) {
/* translators: %d: number of days left in the trial (2 or more) */
$stand['melding'] = 1 === $dagen ? __('1 day left in your trial.', 'gresquote') : sprintf(__('%d days left in your trial.', 'gresquote'), $dagen);
}
}
}
$stand['is_proef'] = $is_proef;
return $stand;
}
/** "a1b2c3…9f0e": genoeg om de sleutel te herkennen, niet om hem te gebruiken. */
private function sleutel_kort($api_key) {
$api_key = (string) $api_key;
if (strlen($api_key) <= 12) {
return str_repeat('•', strlen($api_key));
}
return substr($api_key, 0, 6) . '…' . substr($api_key, -4);
}
/** De tabbladen van Instellingen (1.9.18). Boekhouding niet zonder koppeling of tijdens de proef. */
private function instellingen_tabbladen() {
$tabs = [
'bedrijf' => __('Company', 'gresquote'),
'offerte' => __('Quote', 'gresquote'),
'abonnement' => __('Subscription', 'gresquote'),
];
// Zichtbaar zodra de site gekoppeld is. Of de koppeling met de boekhouding
// bij het abonnement hoort, bepaalt de dienst (die doet ook de
// synchronisatie); het tabblad toont wat de dienst zegt. Tot 1.0.15
// verborg de plugin het tabblad zelf tijdens de proef.
$license = $this->get_license_status();
if (! empty($license['connected'])) {
$tabs['boekhouding'] = __('Accounting', 'gresquote');
}
return $tabs;
}
/**
* Welk tabblad staat open? Zonder keuze opent een site die nog niet
* gekoppeld is op Abonnement (daar staat de proef) en een gekoppelde site
* op Bedrijf. Oude links (tab=general, tab=subscription) blijven werken.
*/
private function huidig_instellingen_tabblad() {
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : '';
$license = $this->get_license_status();
$gekoppeld = ! empty($license['connected']);
$oud = [
'general' => $gekoppeld ? 'bedrijf' : 'abonnement',
'subscription' => 'abonnement',
];
if (isset($oud[$tab])) {
$tab = $oud[$tab];
}
if (in_array($tab, ['bedrijf', 'offerte', 'abonnement', 'boekhouding'], true)) {
return $tab;
}
return $gekoppeld ? 'bedrijf' : 'abonnement';
}
/** Adres van een tabblad van Instellingen, eventueel met een anker. */
private function instellingen_url($tab = '', $anker = '') {
$url = admin_url('admin.php?page=wpquotepro-settings' . ('' !== $tab ? '&tab=' . $tab : ''));
return '' !== $anker ? $url . '#' . $anker : $url;
}
/**
* Link naar één veld van Instellingen, op het goede tabblad. Sinds 1.9.18
* staan de velden niet meer op één pagina; een kale #veld-link kwam dan op
* een tabblad uit waar het veld niet staat.
*/
private function instellingen_url_voor_veld($veld) {
$tab = 'bedrijf';
if (in_array($veld, ['wpquotepro_api_key', 'wpquotepro-proef'], true)) {
$tab = 'abonnement';
} elseif (in_array($veld, ['wpquotepro-upload-logo', 'wpquotepro_pdf_accent_color', 'wpquotepro_quotation_intro_text', 'wpquotepro_quotation_footer_text', 'wpquotepro_quotation_validity_days', 'wpquotepro_quotation_from_email', 'wpquotepro_acceptance_flow_enabled'], true)) {
$tab = 'offerte';
}
return $this->instellingen_url($tab, $veld);
}
private function render_settings_tabs($active_tab) {
?>
get_configured_api_key();
if ('' === $api_key) {
return ['fout' => __('First connect this website on the Subscription tab; without a key we do not know which installation you are.', 'gresquote')];
}
$response = wp_remote_get(rtrim($this->backend_basis(), '/') . '/api/v1/tenant/accounting', [
'timeout' => 15,
'headers' => [
'Accept' => 'application/json',
'Apikey' => $api_key,
'Authorization' => 'Bearer ' . $api_key,
],
]);
if (is_wp_error($response)) {
/* translators: %s: error message */
return ['fout' => sprintf(__('The status of your connection could not be retrieved: %s', 'gresquote'), $response->get_error_message())];
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = json_decode((string) wp_remote_retrieve_body($response), true);
if ($code < 200 || $code >= 300 || ! is_array($body)) {
$melding = (is_array($body) && ! empty($body['error']['message']))
? (string) $body['error']['message']
/* translators: %d: HTTP status code */
: sprintf(__('The backend returned status %d.', 'gresquote'), $code);
/* translators: %s: reason given by the backend */
return ['fout' => sprintf(__('The status of your connection could not be retrieved. %s', 'gresquote'), $melding)];
}
return ['body' => $body];
}
/**
* Eén plek voor alle aanroepen naar het boekhoud-endpoint.
*
* Zonder dit zouden drie handlers elk hun eigen kopjes en foutafhandeling
* krijgen, en dan wijkt er vroeg of laat eentje af.
*/
private function roep_boekhouding_aan($methode, $body = null) {
$api_key = $this->get_configured_api_key();
if ('' === $api_key) {
return ['resultaat' => 'geen-sleutel'];
}
$args = [
'method' => $methode,
'timeout' => 25,
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Apikey' => $api_key,
'Authorization' => 'Bearer ' . $api_key,
],
];
if (null !== $body) {
$args['body'] = wp_json_encode($body);
}
$response = wp_remote_request(rtrim($this->backend_basis(), '/') . '/api/v1/tenant/accounting', $args);
if (is_wp_error($response)) {
return ['resultaat' => 'mislukt'];
}
$code = (int) wp_remote_retrieve_response_code($response);
$lichaam = json_decode((string) wp_remote_retrieve_body($response), true);
if ($code >= 200 && $code < 300) {
return ['resultaat' => 'opgeslagen'];
}
if (403 === $code) {
return ['resultaat' => 'niet-toegestaan'];
}
// De reden doorgeven in plaats van "er ging iets mis": de ondernemer moet
// kunnen zien of het aan zijn token ligt of aan e-Boekhouden.
$melding = (is_array($lichaam) && ! empty($lichaam['error']['message']))
? (string) $lichaam['error']['message']
: '';
return ['resultaat' => 'mislukt', 'melding' => $melding];
}
/** Terug naar het tabblad, met de uitslag in de URL. */
private function terug_naar_boekhouding($uitkomst) {
$terug = admin_url('admin.php?page=wpquotepro-settings&tab=boekhouding');
$terug = add_query_arg('wpqp_boekhouding', $uitkomst['resultaat'], $terug);
if (! empty($uitkomst['melding'])) {
$terug = add_query_arg('wpqp_reden', rawurlencode($uitkomst['melding']), $terug);
}
wp_safe_redirect($terug);
exit;
}
/** Story S4 — pakket kiezen, token koppelen en meteen uitproberen (één stap sinds 1.9.20). */
public function handle_connect_accounting() {
if (! current_user_can('manage_options')) {
wp_die(esc_html__('No access.', 'gresquote'));
}
check_admin_referer('wpquotepro_connect_accounting');
// Bewust geen sanitize_text_field: een token mag tekens bevatten die
// daardoor sneuvelen. Trimmen is genoeg, en het gaat rechtstreeks door
// naar de backend zonder ooit in de database van deze site te belanden.
$token = isset($_POST['token']) ? trim((string) wp_unslash($_POST['token'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- zie hierboven
if ('' === $token) {
$this->terug_naar_boekhouding(['resultaat' => 'mislukt', 'melding' => __('Enter your API token.', 'gresquote')]);
}
// 1.9.20: pakket en token in één formulier. Eerst de keuze vastleggen;
// is het pakket al gekozen, dan verandert de backend niets (PUT met
// dezelfde provider is een no-op). Lukt de keuze niet, dan het token
// ook niet proberen: dat zou een tweede, verwarrende melding geven.
$provider = isset($_POST['provider']) ? sanitize_key((string) wp_unslash($_POST['provider'])) : '';
if ('' !== $provider) {
$keuze = $this->roep_boekhouding_aan('PUT', ['provider' => $provider]);
if ('opgeslagen' !== $keuze['resultaat']) {
$this->terug_naar_boekhouding($keuze);
}
}
$uitkomst = $this->roep_boekhouding_aan('POST', ['token' => $token]);
if ('opgeslagen' === $uitkomst['resultaat']) {
$uitkomst['resultaat'] = 'gekoppeld';
}
$this->terug_naar_boekhouding($uitkomst);
}
/** Story S4 — sjabloon, grootboekrekening en betaaltermijn vastleggen. */
public function handle_save_accounting_settings() {
if (! current_user_can('manage_options')) {
wp_die(esc_html__('No access.', 'gresquote'));
}
check_admin_referer('wpquotepro_save_accounting_settings');
$this->terug_naar_boekhouding($this->roep_boekhouding_aan('PATCH', [
'templateId' => isset($_POST['template_id']) ? (int) $_POST['template_id'] : 0,
'ledgerId' => isset($_POST['ledger_id']) ? (int) $_POST['ledger_id'] : 0,
'termOfPayment' => isset($_POST['term_of_payment']) ? (int) $_POST['term_of_payment'] : 14,
]));
}
/** Story S5 — loskoppelen. Alleen bij ons; in de boekhouding verandert niets. */
public function handle_disconnect_accounting() {
if (! current_user_can('manage_options')) {
wp_die(esc_html__('No access.', 'gresquote'));
}
check_admin_referer('wpquotepro_disconnect_accounting');
$this->terug_naar_boekhouding($this->roep_boekhouding_aan('DELETE'));
}
public function render_accounting_settings_tab() {
$uitkomst = $this->fetch_accounting_state();
$fout = isset($uitkomst['fout']) ? $uitkomst['fout'] : '';
$body = isset($uitkomst['body']) ? $uitkomst['body'] : [];
$toegang = isset($body['access']) && is_array($body['access']) ? $body['access'] : [];
$koppeling = isset($body['connection']) && is_array($body['connection']) ? $body['connection'] : null;
$pakketten = isset($body['providers']) && is_array($body['providers']) ? $body['providers'] : [];
include WPQUOTEPRO_PLUGIN_DIR . 'admin/accounting-tab.php';
}
/**
* De keuze opslaan.
*
* De plugin stuurt alleen door wat er is aangeklikt; of het mag beslist de
* backend. Weigert die, dan zeggen we dat met zoveel woorden in plaats van
* te doen alsof het gelukt is.
*/
public function handle_save_accounting() {
if (! current_user_can('manage_options')) {
wp_die(esc_html__('No access.', 'gresquote'));
}
check_admin_referer('wpquotepro_save_accounting');
$provider = isset($_POST['provider']) ? sanitize_key((string) wp_unslash($_POST['provider'])) : '';
$api_key = $this->get_configured_api_key();
$terug = admin_url('admin.php?page=wpquotepro-settings&tab=boekhouding');
if ('' === $api_key) {
wp_safe_redirect(add_query_arg('wpqp_boekhouding', 'geen-sleutel', $terug));
exit;
}
$response = wp_remote_request(rtrim($this->backend_basis(), '/') . '/api/v1/tenant/accounting', [
'method' => 'PUT',
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Apikey' => $api_key,
'Authorization' => 'Bearer ' . $api_key,
],
'body' => wp_json_encode(['provider' => '' === $provider ? null : $provider]),
]);
$resultaat = 'mislukt';
if (! is_wp_error($response)) {
$code = (int) wp_remote_retrieve_response_code($response);
if ($code >= 200 && $code < 300) {
$resultaat = 'opgeslagen';
} elseif (403 === $code) {
$resultaat = 'niet-toegestaan';
}
}
wp_safe_redirect(add_query_arg('wpqp_boekhouding', $resultaat, $terug));
exit;
}
private function get_template_sync_payload() {
return [
'site_url' => home_url('/'),
'plugin_version' => WPQUOTEPRO_VERSION,
'api_key' => $this->get_configured_api_key(),
'tenant_id' => (string) get_option('wpquotepro_tenant_id', ''),
];
}
// 1.8.7: na het starten van de proef of na Verbinden meldde de plugin nog
// "Site-URL niet ingesteld" (health-check), terwijl de backend de site net
// heeft gecontroleerd. Dezelfde home_url('/') als in de registratie.
private function zet_site_url_als_leeg() {
if ('' === trim((string) get_option('wpquotepro_site_url', ''))) {
update_option('wpquotepro_site_url', esc_url_raw(home_url('/')), false);
}
}
private function build_tenant_register_payload($company_name, $company_email) {
$api_key = $this->get_configured_api_key();
$current_user_id = get_current_user_id();
$entry_reference = 'wp-register-' . gmdate('YmdHis');
return array_merge($this->get_request_identity_payload($company_name, $company_email, false), [
'api_number' => $api_key,
'submitted_at' => current_time('mysql'),
'entry_reference' => $entry_reference,
'is_test' => false,
'request_type' => 'tenant_register',
// 1.8.7: de backend weigert een registratie zonder dit veld (400,
// privacy_agreed). Het ontbrak altijd; het pad werd tot 1.8.6 nooit
// gebruikt. handle_register_site() komt hier alleen na het vinkje.
'privacy_agreed' => true,
// 1.0.22: taal, valuta en land van deze site. De backend legt ze meteen
// vast en maakt het account in de winkel van die taal (gresquote.com
// voor Engels), met de welkomstmail in die taal.
'regio' => class_exists('WPQUOTEPRO_Regio') ? WPQUOTEPRO_Regio::voor_backend() : null,
'tenant' => [
'api_number' => $api_key,
'name' => (string) $company_name,
'email' => (string) $company_email,
'website' => home_url('/'),
'site' => [
'name' => (string) get_bloginfo('name'),
'url' => home_url('/'),
'admin_url' => admin_url(),
],
],
'customer' => [
'full_name' => (string) $company_name,
'email' => (string) $company_email,
'company_name' => (string) $company_name,
],
'purchase' => [
'wordpress_user_id' => (string) $current_user_id,
'woo_order_id' => '',
'product_type' => 'subscription',
'product_code' => 'WPQUOTEPRO_TRIAL',
'source' => 'wordpress-plugin',
'plugin_version' => WPQUOTEPRO_VERSION,
],
]);
}
private function get_setup_status() {
$api_number = trim((string) get_option('wpquotepro_api_number', ''));
$api_key = trim((string) get_option('wpquotepro_api_key', ''));
$company_name = trim((string) get_option('wpquotepro_company_name', ''));
$company_email = trim((string) get_option('wpquotepro_company_email', ''));
$quotation_validity_days = (int) get_option('wpquotepro_quotation_validity_days', 0);
$items = [
'api_number' => [
'label' => __('Connected to GresQuote (subscription or trial)', 'gresquote'),
'ok' => '' !== $api_number || '' !== $api_key,
'field' => 'wpquotepro_api_key',
'action' => __('Connect', 'gresquote'),
],
'company_name' => [
'label' => __('Company name is filled in', 'gresquote'),
'ok' => '' !== $company_name,
'field' => 'wpquotepro_company_name',
'action' => __('Fill in', 'gresquote'),
],
'company_email' => [
'label' => __('Company email is valid', 'gresquote'),
'ok' => '' !== $company_email && is_email($company_email),
'field' => 'wpquotepro_company_email',
'action' => __('Fill in', 'gresquote'),
],
'quotation_validity_days' => [
'label' => __('Quote validity period is filled in', 'gresquote'),
'ok' => $quotation_validity_days > 0,
'field' => 'wpquotepro_quotation_validity_days',
'action' => __('Fill in', 'gresquote'),
],
];
$complete = true;
$completed = 0;
foreach ($items as $item) {
if (! empty($item['ok'])) {
$completed++;
continue;
}
$complete = false;
}
return [
'complete' => $complete,
'completed' => $completed,
'total' => count($items),
'items' => $items,
];
}
/**
* 1.9.4: "Zo ga je live". Het blok Setup hierboven gaat alleen over de
* instellingen; dit volgt de hele weg tot een werkend formulier op de site,
* in dezelfde volgorde als het stappenplan en de filmpjes op
* wpquotepro.nl/aan-de-slag/. Elk punt vinkt zichzelf af.
*/
private function get_go_live_status() {
global $wpdb;
$api_key = (string) get_option('wpquotepro_api_key', '');
$api_number = (string) get_option('wpquotepro_api_number', '');
// Alleen echte formulieren: bij installatie maakt de plugin een interne
// demo-flow aan die ook op 'active' staat, maar die zit aan geen enkel
// formulier op de site.
$actieve = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE status = 'active' AND source_type <> 'internal' AND external_form_id <> '' ORDER BY id ASC", $this->forms_table));
$eerste_actief = ! empty($actieve) ? $actieve[0] : null;
// Staat een actief formulier op een gepubliceerde pagina? Via de knop
// "Maak een pagina met dit formulier", of zelf geplakt.
$op_site = false;
foreach ((array) $actieve as $form) {
if ($this->formulier_pagina($form)) {
$op_site = true;
break;
}
}
$hulp = WPQUOTEPRO_Regio::site_url('aan-de-slag');
$items = [
[
'label' => __('Website connected (free trial or subscription)', 'gresquote'),
'ok' => '' !== $api_key || '' !== $api_number,
'actie' => [__('Connect website', 'gresquote'), $this->instellingen_url('abonnement', 'wpquotepro-proef')],
'film' => $hulp . '#stap-2',
],
[
'label' => __('Form plugin active (WPForms, Contact Form 7, Gravity Forms or Ninja Forms)', 'gresquote'),
'ok' => ! empty($this->get_creatable_source_options()),
'actie' => [__('Install WPForms', 'gresquote'), admin_url('plugin-install.php?s=WPForms&tab=search&type=term')],
'film' => $hulp . '#stap-3',
],
[
// 1.9.18: uit de oude lijst "Setup", die nu is opgegaan in deze.
'label' => __('Company name and email address filled in', 'gresquote'),
'ok' => '' !== trim((string) get_option('wpquotepro_company_name', '')) && is_email((string) get_option('wpquotepro_company_email', '')),
'actie' => [__('Fill in', 'gresquote'), $this->instellingen_url('bedrijf', 'wpquotepro_company_name')],
'film' => $hulp . '#stap-2',
],
[
'label' => __('Quote form active', 'gresquote'),
'ok' => null !== $eerste_actief,
'actie' => [__('New quote form', 'gresquote'), admin_url('admin.php?page=wpquotepro-wizard&step=0')],
'film' => $hulp . '#stap-3',
],
[
'label' => __('Form is on your website', 'gresquote'),
'ok' => $op_site,
'actie' => [__('Put it on a page', 'gresquote'), $eerste_actief ? admin_url('admin.php?page=wpquotepro-wizard&step=3&form_id=' . (int) $eerste_actief->id) : admin_url('admin.php?page=wpquotepro-forms')],
'film' => $hulp . '#stap-4',
],
[
'label' => __('Your logo is on the quote', 'gresquote'),
'ok' => (int) get_option('wpquotepro_logo_id', 0) > 0,
'actie' => [__('Choose logo', 'gresquote'), $this->instellingen_url('offerte', 'wpquotepro-upload-logo')],
'film' => $hulp . '#stap-5',
],
];
$klaar = count(array_filter($items, function ($i) { return ! empty($i['ok']); }));
return ['items' => $items, 'completed' => $klaar, 'total' => count($items), 'complete' => $klaar === count($items)];
}
private function render_go_live_checklist() {
$status = $this->get_go_live_status();
// 1.9.18: klaar = één keer een groene melding, daarna niets meer. Gaat er
// later iets mis (logo weg, formulier uit), dan komt de lijst terug en
// daarna ook weer eenmalig de melding.
if (! empty($status['complete'])) {
if (get_option('wpquotepro_live_klaar_gemeld')) {
return;
}
update_option('wpquotepro_live_klaar_gemeld', 1, false);
?>
['label' => __('First name', 'gresquote')],
'last_name' => ['label' => __('Last name', 'gresquote')],
'full_name' => ['label' => __('Full name', 'gresquote')],
'email' => ['label' => __('Email address', 'gresquote')],
'phone' => ['label' => __('Phone', 'gresquote')],
'mobile' => ['label' => __('Mobile', 'gresquote')],
'company_name' => ['label' => __('Company name', 'gresquote')],
'kvk_number' => ['label' => __('Company number', 'gresquote')],
'vat_number' => ['label' => __('VAT registration number', 'gresquote')],
'address.street' => ['label' => __('Street', 'gresquote')],
'address.house_number' => ['label' => __('House number', 'gresquote')],
'address.house_number_addition' => ['label' => __('House number suffix', 'gresquote')],
'address.postal_code' => ['label' => __('Postcode', 'gresquote')],
'address.city' => ['label' => __('Town', 'gresquote')],
'address.state' => ['label' => __('County / state', 'gresquote')],
'address.country' => ['label' => __('Country', 'gresquote')],
];
}
private function get_wizard_step_definitions() {
return [
// 1.9.13: vier stappen in beeld (Start + deze drie). Formulier en
// klantgegevens zijn één stap, producten en kortingen/toeslagen ook.
1 => ['title' => __('Which form do your customers fill in?', 'gresquote'), 'short' => __('Form & customer', 'gresquote')],
2 => ['title' => __('What does it cost when your customer fills this in?', 'gresquote'), 'short' => __('Price', 'gresquote')],
3 => ['title' => __('Try it out and go live', 'gresquote'), 'short' => __('Test & go live', 'gresquote')],
];
}
private function get_wizard_source_options($behoud = '') {
$sources = $this->registry->get_source_options(true, $behoud);
unset($sources['internal']);
return $sources;
}
private function get_default_wizard_source_type($behoud = '') {
$sources = $this->get_wizard_source_options($behoud);
if (isset($sources['wpforms'])) {
return 'wpforms';
}
if (isset($sources['contact-form-7'])) {
return 'contact-form-7';
}
if (isset($sources['gravity-forms'])) {
return 'gravity-forms';
}
$keys = array_keys($sources);
return ! empty($keys) ? (string) $keys[0] : 'wpforms';
}
private function get_wizard_templates($nocache = false) {
$base_customer_fields = [
['name' => 'full_name', 'label' => __('Name', 'gresquote'), 'type' => 'text', 'required' => true, 'customer_key' => 'full_name'],
['name' => 'email', 'label' => __('Email address', 'gresquote'), 'type' => 'email', 'required' => true, 'customer_key' => 'email'],
['name' => 'phone', 'label' => __('Phone', 'gresquote'), 'type' => 'text', 'required' => false, 'customer_key' => 'phone'],
['name' => 'postal_code', 'label' => __('Postcode', 'gresquote'), 'type' => 'text', 'required' => false, 'customer_key' => 'address.postal_code'],
['name' => 'city', 'label' => __('Town', 'gresquote'), 'type' => 'text', 'required' => false, 'customer_key' => 'address.city'],
];
// Alle templates worden centraal in Supabase onderhouden via backend.
// Lokale templates zijn verwijderd. Alleen 'blank' (utility) blijft voor lege start.
$templates = [
'blank' => [
'title' => __('Start blank', 'gresquote'),
'category' => __('Custom flow', 'gresquote'),
'badge' => '',
'access' => 'free',
'price_label' => __('Free', 'gresquote'),
'icon' => 'dashicons-edit-page',
'description' => __('Start without a template and set up mappings, products and rules yourself.', 'gresquote'),
'default_name' => '',
'default_description' => '',
'fields' => $base_customer_fields,
'products' => [],
'product_mappings' => [],
'rules' => [],
'groups' => [],
'customer_hints' => [],
'product_hints' => [],
'rule_hints' => [],
],
];
// Externe templates: JSON-bestanden in /templates van de plugin én via filter.
// Zo kun je via AI een template laten genereren (op basis van het JSON-schema
// in docs/wpquotepro-wizard-template-schema.json), het .json-bestand in de
// map templates/ plaatsen, en verschijnt hij automatisch in de wizard.
$templates = $this->merge_json_wizard_templates($templates);
// Backend form-templates (centraal beheerd in QuoteEngine) toevoegen/overschrijven.
$backend = $this->get_backend_form_templates($nocache);
if (! empty($backend['templates'])) {
foreach ($backend['templates'] as $key => $tpl) {
$templates[$key] = $tpl;
}
}
/**
* Filter de beschikbare wizard-templates.
* Geef een array terug met dezelfde structuur (key => template-definitie).
*/
$templates = apply_filters('wpquotepro_wizard_templates', $templates);
return is_array($templates) ? $templates : [];
}
/**
* Haalt de centraal beheerde form-templates op bij de backend en mapt ze naar de
* wizard-structuur. Gratis templates bevatten direct hun definitie. Betaalde
* templates verkoopt de website; heeft deze installatie er een, dan levert het
* installed-endpoint de definitie. Betaalde templates die de installatie niet
* heeft, laat de plugin sinds 1.0.15 helemaal niet zien (WordPress.org,
* richtlijn 5: niets in de plugin op slot of met een koopknop).
*
* De publieke catalogus wordt 5 minuten gecachet, maar welke templates deze
* installatie heeft wordt elke keer live opgehaald, zodat een template die op de
* website is gekocht meteen in de wizard staat. Retour: ['templates' => [...],
* 'owned' => [...]].
*/
private function get_backend_form_templates($nocache = false) {
// 1.9.7: deze functie wordt per template meerdere keren aangeroepen (via
// sanitize_wizard_template_key() en get_eigen_template_keys()). Elke
// aanroep vroeg live de gekochte templates op (~0,9 s), zodat de pagina
// Templates met zes templates ruim tien seconden laadde. Binnen één
// paginaweergave verandert het antwoord niet; een aankoop op de website
// staat er nog steeds bij de eerstvolgende pagina. Ook een mislukte
// ophaalactie wordt onthouden, anders wacht een storing 12 s per template.
if (null === $this->backend_templates_memo) {
$this->backend_templates_memo = $this->haal_backend_form_templates($nocache);
}
return $this->backend_templates_memo;
}
private function haal_backend_form_templates($nocache) {
$result = ['templates' => [], 'owned' => []];
if (! defined('WPQUOTEPRO_FORM_TEMPLATES_ENDPOINT') || ! WPQUOTEPRO_FORM_TEMPLATES_ENDPOINT) {
return $result;
}
// 1) Publieke catalogus (gratis met definitie, betaald zonder) — gecachet,
// tenzij $nocache=true (Template Library-pagina forceert altijd vers, zie
// render_templates_library_page). Tot 1.0.15 kon dat ook met ?nocache=1 in
// de URL; dat liet een link van buiten de cache overslaan.
//
// Fase 1 (docs/WERELD.md C5): de catalogus wordt ingevuld voor land, taal
// en valuta van de offertes (?land=&taal=&valuta=). Zonder die parameters
// geeft de backend de Nederlandse templates. De cache onthoudt voor welke
// regio hij is, zodat een andere keuze in Instellingen meteen telt.
$regio_query = WPQUOTEPRO_Regio::catalogus_query();
$gecachet = $nocache ? false : get_transient('wpquotepro_form_templates_catalog');
$catalog = (is_array($gecachet) && isset($gecachet['regio'], $gecachet['templates']) && $gecachet['regio'] === $regio_query && is_array($gecachet['templates']))
? $gecachet['templates']
: false;
if (! is_array($catalog)) {
$resp = wp_remote_get(add_query_arg($regio_query, WPQUOTEPRO_FORM_TEMPLATES_ENDPOINT), [
'timeout' => 12,
'headers' => ['Accept' => 'application/json'],
]);
// TP-34: een mislukte ophaalactie gaf hetzelfde terug als een lege
// catalogus, waardoor de ondernemer "geen templates" zag terwijl de
// backend gewoon onbereikbaar was. Nu onthouden en tonen.
if (is_wp_error($resp)) {
/* translators: %s: error message */
$this->catalogus_fout = sprintf(__('The template catalogue could not be retrieved: GresQuote could not be reached (%s). Please try again in a few minutes.', 'gresquote'), $resp->get_error_message());
return $result;
}
$code = (int) wp_remote_retrieve_response_code($resp);
$body = json_decode((string) wp_remote_retrieve_body($resp), true);
if ($code < 200 || $code >= 300 || ! is_array($body) || ! isset($body['templates']) || ! is_array($body['templates'])) {
/* translators: %d: HTTP status code */
$this->catalogus_fout = sprintf(__('The template catalogue could not be retrieved (status %d). Please try again in a few minutes.', 'gresquote'), $code);
return $result;
}
if (empty($body['templates'])) {
// Echt leeg: geen fout, en ook niet cachen, zodat nieuwe templates
// meteen verschijnen zodra ze er zijn.
return $result;
}
$catalog = $body['templates'];
set_transient('wpquotepro_form_templates_catalog', ['regio' => $regio_query, 'templates' => $catalog], 5 * MINUTE_IN_SECONDS);
}
// 2) Eigendom (definities van templates die deze installatie heeft) — LIVE,
// niet gecachet, zodat een aankoop op de website meteen zichtbaar is.
// Vereist API-key.
$owned_defs = [];
$besloten = [];
$api_key = $this->get_configured_api_key();
if ($api_key && defined('WPQUOTEPRO_FORM_TEMPLATES_INSTALLED_ENDPOINT') && WPQUOTEPRO_FORM_TEMPLATES_INSTALLED_ENDPOINT) {
$iresp = wp_remote_get(
add_query_arg(['site_url' => rawurlencode(home_url('/'))], WPQUOTEPRO_FORM_TEMPLATES_INSTALLED_ENDPOINT),
['timeout' => 12, 'headers' => ['Accept' => 'application/json', 'Apikey' => $api_key, 'Authorization' => 'Bearer ' . $api_key]]
);
if (! is_wp_error($iresp)) {
$icode = (int) wp_remote_retrieve_response_code($iresp);
$ibody = json_decode((string) wp_remote_retrieve_body($iresp), true);
if ($icode >= 200 && $icode < 300 && ! empty($ibody['installed']) && is_array($ibody['installed'])) {
foreach ($ibody['installed'] as $row) {
if (! empty($row['slug']) && ! empty($row['definition'])) {
$owned_defs[sanitize_key($row['slug'])] = $row['definition'];
}
}
$besloten = self::besloten_templates($ibody['installed']);
}
}
}
foreach ($catalog as $t) {
if (empty($t['slug'])) {
continue;
}
$slug = sanitize_key($t['slug']);
$is_free = ! empty($t['isFree']);
if (isset($owned_defs[$slug])) {
$definition = $owned_defs[$slug];
$result['owned'][] = $slug;
} elseif ($is_free) {
$definition = ! empty($t['definition']) ? $t['definition'] : null;
} else {
// Betaald en niet van deze installatie: niet tonen. Die koop je op de
// website; de plugin zet niets op slot en heeft geen koopknop.
continue;
}
$tpl = $this->map_backend_form_template($t, $definition);
$result['templates'][$slug] = $tpl;
}
// 1.0.4: besloten templates. Die staan niet in de openbare catalogus (een
// leverancier laat er een maken met zijn prijslijst, alleen voor zijn
// dealers); het installed-endpoint geeft ze compleet mee aan de
// installatie waaraan ze zijn toegekend. Geen 'counts': dan rekent de
// Live demo met de definitie die we hier al hebben, en niet via de
// openbare voorvertoning op slug, die een besloten template niet kent.
foreach ($besloten as $slug => $row) {
if (isset($result['templates'][$slug])) {
continue;
}
$tpl = $this->map_backend_form_template($row, $row['definition']);
$tpl['badge'] = __('Only for you', 'gresquote');
$tpl['price_label'] = __('Assigned', 'gresquote');
$result['templates'][$slug] = $tpl;
$result['owned'][] = $slug;
}
return $result;
}
/**
* De besloten templates uit het antwoord van het installed-endpoint, per
* slug. Alleen rijen die de backend als besloten aanmerkt en die compleet
* genoeg zijn om te tonen (naam en definitie); een oudere backend stuurt
* die velden niet mee en levert hier dus niets op. Puur, zodat het te
* testen is.
*/
public static function besloten_templates($installed) {
$uit = [];
if (! is_array($installed)) {
return $uit;
}
foreach ($installed as $row) {
if (! is_array($row) || empty($row['besloten']) || true !== $row['besloten']) {
continue;
}
if (empty($row['slug']) || empty($row['name']) || empty($row['definition'])) {
continue;
}
$uit[sanitize_key($row['slug'])] = $row;
}
return $uit;
}
/**
* Mapt één backend form-template (catalogus-item + evt. definitie) naar de
* wizard-structuur die de rest van de plugin verwacht.
*/
private function map_backend_form_template($t, $definition) {
$is_free = ! empty($t['isFree']);
// Een betaalde template staat hier alleen als deze installatie hem heeft.
$price_label = $is_free ? __('Free', 'gresquote') : __('Yours', 'gresquote');
// Parse definition: kan array zijn of JSON-string
if (is_array($definition)) {
$def = $definition;
} elseif (is_string($definition)) {
$def = json_decode($definition, true) ?? [];
} else {
$def = [];
}
$tpl = [
'title' => (string) ($t['name'] ?? 'Template'),
'category' => (string) ($t['category'] ?? __('Sector', 'gresquote')),
'subcategory' => (string) ($t['subcategory'] ?? ''),
'badge' => '',
'access' => $is_free ? 'free' : 'paid',
'price_label' => $price_label,
'icon' => ! empty($t['icon']) ? $t['icon'] : 'dashicons-media-document',
'description' => (string) ($t['description'] ?? ''),
'default_name' => (string) ($def['default_name'] ?? ($t['name'] ?? '')),
'default_description' => (string) ($def['default_description'] ?? ''),
'fields' => isset($def['fields']) && is_array($def['fields']) ? $def['fields'] : [],
'products' => isset($def['products']) && is_array($def['products']) ? $def['products'] : [],
'product_mappings' => isset($def['product_mappings']) && is_array($def['product_mappings']) ? $def['product_mappings'] : [],
'rules' => isset($def['rules']) && is_array($def['rules']) ? $def['rules'] : [],
// Herhaalgroepen. Zonder deze regel valt de groepsdefinitie
// weg tussen backend en plugin en ziet het formulier ze nooit.
'groups' => isset($def['groups']) && is_array($def['groups']) ? $def['groups'] : [],
// De backend telt zelf, zodat de tellers kloppen ook als hij de
// producten en regels van een betaalde template niet meestuurt (BE-23).
'counts' => isset($t['counts']) && is_array($t['counts']) ? $t['counts'] : null,
'customer_hints' => isset($def['customer_hints']) && is_array($def['customer_hints']) ? $def['customer_hints'] : [],
'product_hints' => isset($def['product_hints']) && is_array($def['product_hints']) ? $def['product_hints'] : [],
'rule_hints' => isset($def['rule_hints']) && is_array($def['rule_hints']) ? $def['rule_hints'] : [],
];
return $tpl;
}
/**
* Laadt extra wizard-templates uit JSON-bestanden in de map templates/ van de
* plugin. Elk bestand bevat één template-definitie (zelfde structuur als de
* ingebouwde templates). De bestandsnaam (zonder .json) wordt de template-key,
* tenzij het JSON-object zelf een "key" bevat.
*/
private function merge_json_wizard_templates($templates) {
$dir = trailingslashit(WPQUOTEPRO_PLUGIN_DIR) . 'templates';
if (! is_dir($dir)) {
return $templates;
}
$files = glob($dir . '/*.json');
if (empty($files)) {
return $templates;
}
foreach ($files as $file) {
$raw = file_get_contents($file);
if (false === $raw) {
continue;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
continue;
}
$key = isset($data['key']) ? sanitize_key((string) $data['key']) : sanitize_key(basename($file, '.json'));
unset($data['key']);
if ('' === $key || 'blank' === $key) {
continue;
}
// Minimaal vereiste velden afdwingen met veilige defaults.
$templates[$key] = wp_parse_args($data, [
'title' => $key,
'category' => __('Imported', 'gresquote'),
'access' => 'free',
'price_label' => __('Free', 'gresquote'),
'icon' => 'dashicons-media-document',
'description' => '',
'default_name' => '',
'default_description' => '',
'fields' => [],
'products' => [],
'product_mappings' => [],
'rules' => [],
'groups' => [],
'customer_hints' => [],
'product_hints' => [],
'rule_hints' => [],
]);
}
return $templates;
}
private function sanitize_wizard_template_key($template_key) {
$template_key = sanitize_key((string) $template_key);
$templates = $this->get_wizard_templates();
return isset($templates[$template_key]) ? $template_key : 'blank';
}
private function get_wizard_template($template_key) {
$templates = $this->get_wizard_templates();
$template_key = $this->sanitize_wizard_template_key($template_key);
return $templates[$template_key];
}
private function get_form_template_key($form_id) {
$form_id = absint($form_id);
if (! $form_id) {
return 'blank';
}
return $this->sanitize_wizard_template_key(get_option('wpquotepro_form_template_' . $form_id, 'blank'));
}
/**
* De templates die deze installatie heeft: wat de dienst als "installed"
* teruggeeft (gratis templates die hier gebruikt zijn en templates die op de
* website zijn gekocht), plus het lege formulier. Tot 1.0.15 kwamen hier ook
* lokale opties bij (wpquotepro_owned_/unlocked_templates); die worden niet
* meer gelezen.
*/
private function get_eigen_template_keys() {
$eigen = ['blank'];
$backend = $this->get_backend_form_templates();
if (! empty($backend['owned'])) {
foreach ($backend['owned'] as $slug) {
$eigen[] = sanitize_key($slug);
}
}
return array_values(array_unique($eigen));
}
private function is_eigen_template($template_key) {
$template_key = $this->sanitize_wizard_template_key($template_key);
return in_array($template_key, $this->get_eigen_template_keys(), true);
}
// Los van is_eigen_template() (die bepaalt de "Mijn templates"-weergave): een
// gratis template mag altijd geïnstalleerd worden, ook de allereerste keer
// vóórdat er een eigendomsrij bestaat — het installeren zelf is precies het
// moment waarop track_form_template_use() die eigendomsrij aanmaakt.
private function is_template_free($template_key) {
$template = $this->get_wizard_template($this->sanitize_wizard_template_key($template_key));
if (! $template) {
return false;
}
return isset($template['is_free'])
? (bool) $template['is_free']
: ('free' === ($template['access'] ?? null));
}
/**
* Tellers van een template. De backend stuurt ze mee; alleen voor templates
* die de plugin zelf kent (ingebouwd of een JSON-bestand in templates/)
* tellen we ze hier. Van een betaalde template die nog niet gekocht is komen
* de producten en regels niet mee — daar zou zelf tellen dus 0 opleveren.
*/
private function get_template_counts($template) {
$counts = isset($template['counts']) && is_array($template['counts']) ? $template['counts'] : [];
return [
'fields' => isset($counts['fields']) ? (int) $counts['fields'] : count((array) ($template['fields'] ?? [])),
'products' => isset($counts['products']) ? (int) $counts['products'] : count((array) ($template['products'] ?? [])),
'rules' => isset($counts['rules']) ? (int) $counts['rules'] : count((array) ($template['rules'] ?? [])),
'groups' => isset($counts['groups']) ? (int) $counts['groups'] : count((array) ($template['groups'] ?? [])),
];
}
private function render_template_counts($template) {
$counts = $this->get_template_counts($template);
?>
✔ ✔ ✔
0) : ?>
✔
get_wizard_template($template_key);
if ('blank' === $template_key) {
return;
}
?>
$value) : ?>
:
registry->get_adapters() as $key => $adapter) {
if ('internal' === $key) {
continue;
}
if (method_exists($adapter, 'can_create_form') && $adapter->can_create_form()) {
$options[$key] = $adapter->get_label();
}
}
return $options;
}
private function get_auto_creatable_source() {
$sources = $this->get_creatable_source_options();
if (1 === count($sources)) {
$keys = array_keys($sources);
return $keys[0];
}
return '';
}
/** data-attributen voor het filter in de wizard (1.9.8). */
private function print_template_filter_attrs($template) {
$cat = trim((string) ($template['category'] ?? ''));
$tekst = implode(' ', [
(string) ($template['title'] ?? ''),
(string) ($template['description'] ?? ''),
$cat,
(string) ($template['subcategory'] ?? ''),
]);
$tekst = function_exists('mb_strtolower') ? mb_strtolower($tekst) : strtolower($tekst);
echo ' data-wpqp-tpl="1" data-branche="' . esc_attr(sanitize_title($cat)) . '" data-zoek="' . esc_attr($tekst) . '"';
}
/**
* De templatecatalogus ingedeeld voor de kiezer: eigen en gratis, plus de
* branches voor het filter. Gedeeld door de wizard en de pagina Templates.
* Betaalde templates die deze installatie niet heeft, staan er niet in (1.0.15).
*
* @return array{alle:array, mijn:int, branches:array}
*/
private function template_catalogus($nocache = false) {
$mijn = [];
$gratis = [];
foreach ($this->get_wizard_templates($nocache) as $template_key => $template) {
if ('blank' === $template_key) {
continue;
}
// Gratis volgens het backendformaat (is_free) of het lokale (access).
$is_free = isset($template['is_free']) ? (bool) $template['is_free'] : ('free' === ($template['access'] ?? null));
if ($this->is_eigen_template($template_key)) {
$mijn[$template_key] = ['template' => $template, 'soort' => 'mijn'];
} elseif ($is_free) {
$gratis[$template_key] = ['template' => $template, 'soort' => 'gratis'];
}
}
$alle = $mijn + $gratis;
$branches = [];
foreach ($alle as $item) {
$cat = trim((string) ($item['template']['category'] ?? ''));
if ('' !== $cat) {
$branches[sanitize_title($cat)] = $cat;
}
}
asort($branches);
return ['alle' => $alle, 'mijn' => count($mijn), 'branches' => $branches];
}
/** "Nieuwe formulieren maken we aan in …", alleen als er meer dan één formulierplugin is. */
private function render_template_bron_keuze($source_options) {
if (count($source_options) < 2) {
return;
}
?>
gevraagde_wizardstap();
$selected_form_id = isset($_GET['form_id']) ? absint($_GET['form_id']) : 0;
$template_key = isset($_GET['template']) ? $this->sanitize_wizard_template_key(sanitize_key(wp_unslash($_GET['template']))) : ($selected_form_id ? $this->get_form_template_key($selected_form_id) : 'blank');
$auto_select = ! (0 === $selected_form_id && (0 === $requested_step || 1 === $requested_step));
$state = $this->get_wizard_state($selected_form_id, $auto_select);
$setup_status = $this->get_setup_status();
if (0 === $requested_step && 0 === (int) $selected_form_id) {
$this->render_wizard_template_picker($setup_status);
return;
}
$current_step = $this->get_first_accessible_wizard_step(max(1, $requested_step), $state['can_access']);
// Een vergrendelde stap is al in admin_init doorgestuurd
// (stuur_vergrendelde_wizardstap_door). Komt hij hier toch, dan is er al
// uitvoer: toon de open stap met de melding in plaats van een lege pagina.
if ($current_step !== $requested_step && $selected_form_id) {
echo '
id, 0);
$page = $page_id ? get_post($page_id) : null;
if (! $page || 'trash' === $page->post_status) {
return null;
}
return $page;
}
/**
* 1.9.14: voorbeeldofferte in de stap Prijs. De brug bouwt de aanvraag zoals
* een echte, de backend rekent hem door zonder iets op te slaan.
*/
public function ajax_wizard_preview() {
if (! current_user_can('manage_options')) {
wp_send_json_error(['message' => __('No access.', 'gresquote')], 403);
}
check_ajax_referer('wpquotepro_wizard_preview', 'nonce');
$form_id = isset($_POST['form_id']) ? absint($_POST['form_id']) : 0;
$velden = isset($_POST['velden']) && is_array($_POST['velden']) ? map_deep((array) wp_unslash($_POST['velden']), 'sanitize_text_field') : [];
$schoon = [];
foreach ($velden as $naam => $waarde) {
$naam = sanitize_key((string) $naam);
if ('' !== $naam && ! is_array($waarde)) {
$schoon[$naam] = sanitize_text_field((string) $waarde);
}
}
$bridge = new WPQUOTEPRO_Submission_Bridge($this->registry);
$uitkomst = $bridge->calculate_preview($form_id, $schoon);
if (is_wp_error($uitkomst)) {
wp_send_json_error(['message' => $uitkomst->get_error_message()], 200);
}
$regels = [];
foreach ((array) ($uitkomst['lines'] ?? []) as $regel) {
if (! is_array($regel)) {
continue;
}
$regels[] = [
'omschrijving' => (string) ($regel['description'] ?? ''),
'soort' => (string) ($regel['lineType'] ?? ''),
'aantal' => isset($regel['quantity']) ? (float) $regel['quantity'] : null,
'eenheid' => (string) ($regel['unit'] ?? ''),
'bedrag' => (float) ($regel['signedAmountExclVat'] ?? 0),
];
}
wp_send_json_success([
'regels' => $regels,
'exclBtw' => (float) ($uitkomst['totalExclVat'] ?? 0),
'btw' => (float) ($uitkomst['vatAmount'] ?? 0),
'inclBtw' => (float) ($uitkomst['totalInclVat'] ?? 0),
]);
}
/**
* Welke vragen horen in het voorbeeld: alleen de velden die een koppeling of
* regel gebruikt, met een redelijke beginwaarde. Keuzevelden krijgen hun
* keuzes, aantallen een getal, de rest (bijv. het naamveld van een
* "altijd"-koppeling) een tekstje.
*/
private function get_preview_fields($form, array $mappings, array $rules) {
$meta = [];
foreach ((array) $this->admin_field_choices($form->source_type, $form->external_form_id) as $veld) {
if (is_array($veld) && ! empty($veld['name'])) {
$meta[(string) $veld['name']] = $veld;
}
}
$gebruikt = []; // naam => voorkeurswaarde
$aantallen = [];
foreach ($mappings as $m) {
if (! empty($m->field_name) && ! isset($gebruikt[$m->field_name])) {
$gebruikt[(string) $m->field_name] = (string) $m->compare_value;
}
if (! empty($m->quantity_field)) {
$aantallen[(string) $m->quantity_field] = true;
}
}
foreach ($rules as $r) {
if (! empty($r->field_name) && ! isset($gebruikt[$r->field_name]) && in_array((string) $r->rule_operator, ['=', 'contains', 'is_not_empty'], true)) {
$gebruikt[(string) $r->field_name] = (string) $r->compare_value;
} elseif (! empty($r->field_name) && ! isset($gebruikt[$r->field_name])) {
$gebruikt[(string) $r->field_name] = '';
}
if (! empty($r->quantity_field)) {
$aantallen[(string) $r->quantity_field] = true;
}
}
foreach (array_keys($aantallen) as $naam) {
if (! isset($gebruikt[$naam])) {
$gebruikt[$naam] = '';
}
}
$velden = [];
foreach ($gebruikt as $naam => $voorkeur) {
$info = isset($meta[$naam]) ? $meta[$naam] : ['name' => $naam, 'label' => $naam];
$opties = [];
foreach ((array) ($info['choices'] ?? []) as $keuze) {
$waarde = is_array($keuze) ? (string) ($keuze['value'] ?? ($keuze['label'] ?? '')) : (string) $keuze;
$label = is_array($keuze) ? (string) ($keuze['label'] ?? $waarde) : (string) $keuze;
if ('' !== $waarde) {
$opties[$waarde] = $label;
}
}
$type = strtolower((string) ($info['type'] ?? ''));
if (! empty($opties)) {
$soort = 'keuze';
$standaard = isset($opties[$voorkeur]) ? $voorkeur : (string) array_key_first($opties);
} elseif (isset($aantallen[$naam]) || in_array($type, ['number', 'quantity', 'range', 'slider'], true)) {
$soort = 'getal';
$standaard = is_numeric($voorkeur) ? $voorkeur : '1';
} else {
$soort = 'tekst';
$standaard = '' !== $voorkeur ? $voorkeur : __('Preview', 'gresquote');
}
$velden[] = [
'naam' => $naam,
'label' => ! empty($info['label']) ? (string) $info['label'] : $naam,
'soort' => $soort,
'opties' => $opties,
'standaard' => $standaard,
];
}
return $velden;
}
/** Bewerkpagina van het formulier in de formulierplugin zelf, of ''. */
private function external_form_edit_url($form) {
if (! $form || empty($form->external_form_id)) {
return '';
}
$id = rawurlencode((string) $form->external_form_id);
switch ((string) $form->source_type) {
case 'wpforms':
return admin_url('admin.php?page=wpforms-builder&view=fields&form_id=' . $id);
case 'contact-form-7':
return admin_url('admin.php?page=wpcf7&post=' . $id . '&action=edit');
case 'gravity-forms':
return admin_url('admin.php?page=gf_edit_forms&id=' . $id);
case 'ninja-forms':
return admin_url('admin.php?page=ninja-forms&form_id=' . $id);
}
return '';
}
private function get_latest_submission_logs($form_id, $limit = 5) {
global $wpdb;
if (! $form_id) {
return [];
}
return $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM %i WHERE form_id = %d ORDER BY created_at DESC, id DESC LIMIT %d", $this->logs_table,
(int) $form_id,
(int) $limit
)
);
}
private function create_test_flow_token($form) {
$token = wp_generate_password(32, false, false);
set_transient('wpquotepro_test_form_' . get_current_user_id(), [
'form_id' => (int) $form->id,
'source_type' => (string) $form->source_type,
'external_form_id' => (string) $form->external_form_id,
'token' => $token,
'created_at' => time(),
], HOUR_IN_SECONDS * 2);
return $token;
}
private function get_test_flow_url($form_id, $token = '') {
$args = [
'page' => 'wpquotepro-test-flow',
'form_id' => (int) $form_id,
];
if (! empty($token)) {
$args['test_token'] = $token;
}
return add_query_arg($args, admin_url('admin.php'));
}
private function render_external_form_shortcode($form) {
if (! $form || empty($form->external_form_id)) {
echo '
' . esc_html__('No external form has been connected yet.', 'gresquote') . '
';
return;
}
$external_id = (int) $form->external_form_id;
$source_type = (string) $form->source_type;
// Ninja Forms bouwt zijn formulier met JavaScript dat alleen op de
// frontend geladen wordt. Hier zou er dus een eeuwig draaiend rondje
// blijven staan; dat is erger dan geen weergave, want het lijkt kapot.
// Voor Ninja sturen we daarom meteen door naar de voorbeeldweergave op
// de site zelf.
if ('ninja-forms' === $source_type) {
echo '
' . esc_html__('Ninja Forms builds its form with scripts that only run on your website, not here in the admin. So open the test form on the site itself — there you get it exactly as a visitor sees it, and your submission still counts as a test.', 'gresquote') . '
' . esc_html__('No test view is available for this form source yet. Open the form on the site itself with the button above.', 'gresquote') . '
';
}
/**
* De shortcode waarmee het gekoppelde formulier op een pagina komt, per
* formulierplugin. Leeg als er (nog) geen formulier is.
*/
private function external_form_shortcode($form) {
if (! $form || empty($form->external_form_id)) {
return '';
}
$id = (int) $form->external_form_id;
switch ((string) $form->source_type) {
case 'wpforms':
return '[wpforms id="' . $id . '" title="false" description="false"]';
case 'contact-form-7':
return '[contact-form-7 id="' . $id . '"]';
case 'gravity-forms':
return '[gravityform id="' . $id . '" title="false" description="false" ajax="true"]';
case 'ninja-forms':
return '[ninja_form id=' . $id . ']';
}
return '';
}
/**
* 1.9.4: na "Flow activeren" stond het formulier nog nergens op de site, en
* de plugin zei niet hoe het daar kwam. Voor wie niet thuis is in WordPress
* was dat de plek waar het vastliep. Nu met één klik een pagina met het
* formulier, of de shortcode om zelf te plakken.
*/
/**
* 1.9.27: de pagina heet naar het formulier ("Offerte kozijnen leveren en
* plaatsen"), niet voor elk formulier "Offerte aanvragen". Met twee
* formulieren had je twee pagina's met dezelfde naam en adressen als
* /offerte-aanvragen-2/. Bestaat de titel al, dan " v2", " v3", enzovoort.
*/
private function unieke_paginatitel($form) {
global $wpdb;
$basis = $form && '' !== trim((string) $form->name) ? trim((string) $form->name) : __('Request a quote', 'gresquote');
$bestaand = $wpdb->get_col($wpdb->prepare(
"SELECT post_title FROM {$wpdb->posts} WHERE post_type = 'page' AND post_status NOT IN ('trash', 'auto-draft') AND (post_title = %s OR post_title LIKE %s)",
$basis,
$wpdb->esc_like($basis . ' v') . '%'
));
return self::volgende_titel($basis, (array) $bestaand);
}
/** "Naam", of "Naam v2", "Naam v3" … als die al bestaan. */
public static function volgende_titel($basis, array $bestaand) {
$bestaand = array_map('strval', $bestaand);
if (! in_array($basis, $bestaand, true)) {
return $basis;
}
$n = 2;
while (in_array($basis . ' v' . $n, $bestaand, true)) {
$n++;
}
return $basis . ' v' . $n;
}
public function handle_form_create_page() {
if (! current_user_can('manage_options')) {
wp_die(esc_html__('No access.', 'gresquote'));
}
$form_id = isset($_POST['form_id']) ? absint($_POST['form_id']) : 0;
check_admin_referer('wpquotepro_form_create_page_' . $form_id);
global $wpdb;
$form = $form_id ? $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->forms_table, $form_id)) : null;
$shortcode = $this->external_form_shortcode($form);
if ('' === $shortcode) {
$this->redirect_wizard(3, $form_id, 'saved_error');
}
$bestaand = (int) get_option('wpquotepro_form_page_' . $form_id, 0);
if ($bestaand && get_post($bestaand) && 'trash' !== get_post_status($bestaand)) {
$this->redirect_wizard(3, $form_id, 'form_page_created');
}
$page_id = wp_insert_post([
'post_type' => 'page',
'post_status' => 'publish',
'post_title' => $this->unieke_paginatitel($form),
'post_content' => "\n
" . esc_html__('Fill in the form and you will receive your quote by email within a few minutes.', 'gresquote') . "
\n\n\n\n" . $shortcode . "\n",
], true);
if (is_wp_error($page_id) || ! $page_id) {
$this->redirect_wizard(3, $form_id, 'saved_error');
}
update_option('wpquotepro_form_page_' . $form_id, (int) $page_id, false);
$this->redirect_wizard(3, $form_id, 'form_page_created');
}
public function render_test_flow_page() {
global $wpdb;
$form_id = isset($_GET['form_id']) ? absint($_GET['form_id']) : 0;
$test_token = isset($_GET['test_token']) ? sanitize_text_field(wp_unslash($_GET['test_token'])) : '';
$test_context = get_transient('wpquotepro_test_form_' . get_current_user_id());
$legacy_nonce_ok = $form_id && wp_verify_nonce(isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '', 'wpquotepro_test_flow_' . $form_id);
$token_ok = is_array($test_context)
&& ! empty($test_context['token'])
&& hash_equals((string) $test_context['token'], (string) $test_token)
&& (int) $test_context['form_id'] === (int) $form_id;
if (! $form_id || (! $token_ok && ! $legacy_nonce_ok)) {
wp_die(esc_html__('Invalid test link. Open the test form again from the Test & go live step in the wizard.', 'gresquote'));
}
$form = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->forms_table, $form_id));
if (! $form) {
wp_die(esc_html__('Quote flow not found.', 'gresquote'));
}
if (! $token_ok) {
$test_token = $this->create_test_flow_token($form);
}
$latest_logs = $this->get_latest_submission_logs($form_id, 5);
?>
GresQuote ›
: name); ?>
render_notice(); ?>
→
registry->get_source_label($form->source_type)); ?> · ID external_form_id); ?>
is_gekoppeld()) {
$this->redirect_wizard(3, $form_id, 'koppel_eerst');
}
global $wpdb;
$form = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->forms_table, $form_id));
if (! $form) {
$this->redirect_wizard(3, $form_id, 'saved_error');
}
$test_token = $this->create_test_flow_token($form);
wp_safe_redirect($this->get_test_flow_url($form_id, $test_token));
exit;
}
public function handle_manual_test_submit() {
if (! current_user_can('manage_options')) {
wp_die(esc_html__('No access.', 'gresquote'));
}
$form_id = isset($_POST['form_id']) ? absint($_POST['form_id']) : 0;
check_admin_referer('wpquotepro_manual_test_submit_' . $form_id);
$test_context = get_transient('wpquotepro_test_form_' . get_current_user_id());
$test_token = isset($_POST['test_token']) ? sanitize_text_field(wp_unslash($_POST['test_token'])) : '';
$token_ok = is_array($test_context)
&& ! empty($test_context['token'])
&& hash_equals((string) $test_context['token'], (string) $test_token)
&& (int) $test_context['form_id'] === (int) $form_id;
if (! $form_id || ! $token_ok) {
wp_die(esc_html__('Invalid test link. Open the test form again from the Test & go live step in the wizard.', 'gresquote'));
}
// JSON kan pas na json_decode per sleutel en waarde opgeschoond worden;
// dat gebeurt direct hieronder in sanitize_test_fields(), vóór gebruik.
$fields_json = isset($_POST['wpquotepro_test_fields_json']) ? wp_unslash($_POST['wpquotepro_test_fields_json']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, direct na json_decode opgeschoond in sanitize_test_fields()
$fields = $this->sanitize_test_fields(is_string($fields_json) ? json_decode($fields_json, true) : null);
$bridge = new WPQUOTEPRO_Submission_Bridge($this->registry);
$result = $bridge->process_manual_test_submission($form_id, $fields);
if (is_wp_error($result)) {
$this->redirect_test_flow($form_id, 'saved_error');
}
$token = ! empty($test_context['token']) ? (string) $test_context['token'] : '';
wp_safe_redirect(add_query_arg(['page' => 'wpquotepro-test-flow', 'form_id' => $form_id, 'test_token' => $token, 'message' => 'test_sent'], admin_url('admin.php')));
exit;
}
/**
* Schoont de velden van het handmatige testformulier op: sleutels én
* waarden. Een sleutel die niet aan een formulierveld gekoppeld is wordt
* later als label gebruikt, dus ook die gaat door sanitize_text_field.
* Alleen tekst of een lijst tekst is geldig; al het andere valt weg.
*
* @param mixed $decoded Uitkomst van json_decode.
* @return array
*/
private function sanitize_test_fields($decoded) {
if (! is_array($decoded)) {
return [];
}
$schoon = [];
foreach (array_slice($decoded, 0, 200, true) as $naam => $waarde) {
$naam = sanitize_text_field((string) $naam);
if ('' === $naam) {
continue;
}
if (is_array($waarde)) {
$lijst = [];
foreach ($waarde as $item) {
if (is_scalar($item)) {
$lijst[] = sanitize_text_field((string) $item);
}
}
$schoon[$naam] = $lijst;
} elseif (is_scalar($waarde) || null === $waarde) {
$schoon[$naam] = sanitize_text_field((string) $waarde);
}
}
return $schoon;
}
/**
* Uitkomst van de laatste testinzending in gewone taal. Hier stonden tot
* 1.9.5 alleen de verstuurde JSON en het antwoord van de backend; die staan
* nu ingeklapt onder "Technische details".
*/
private function render_test_result_summary($log) {
$response = json_decode((string) $log->response_body, true);
$payload = json_decode((string) $log->payload_json, true);
$customer = is_array($payload) && isset($payload['customer']) && is_array($payload['customer']) ? $payload['customer'] : [];
$code = (int) $log->response_code;
$euro = function ($value) {
return WPQUOTEPRO_Regio::bedrag($value, null, ' ');
};
echo '
' . esc_html__('Result of the last test', 'gresquote') . '
' . sprintf(esc_html__('Quote %s has been created', 'gresquote'), esc_html((string) $response['quotationNumber'])) . '';
if (isset($response['totalInclVat'])) {
/* translators: %s: amount including VAT */
echo ' ' . sprintf(esc_html__('for %s incl. VAT', 'gresquote'), esc_html($euro($response['totalInclVat'])));
}
echo '.
';
$name = isset($customer['full_name']) ? trim((string) $customer['full_name']) : '';
/* translators: %s: name of the customer */
echo '
' . ('' !== $name ? sprintf(esc_html__('Customer: %s.', 'gresquote'), esc_html($name)) . ' ' : '')
/* translators: %s: link to the Quotes page */
. sprintf(esc_html__('The PDF is being created now; in a minute it will be under %s.', 'gresquote'), '' . esc_html__('Quotes', 'gresquote') . '') . '
';
}
}
private function redirect_test_flow($form_id, $notice = '') {
$test_context = get_transient('wpquotepro_test_form_' . get_current_user_id());
$token = is_array($test_context) && ! empty($test_context['token']) ? (string) $test_context['token'] : '';
$args = ['page' => 'wpquotepro-test-flow', 'form_id' => (int) $form_id];
if ('' !== $token) {
$args['test_token'] = $token;
}
if ('' !== $notice) {
$args['wpquotepro_notice'] = $notice;
}
wp_safe_redirect(add_query_arg($args, admin_url('admin.php')));
exit;
}
/**
* 1.9.22: Formulieren zegt per formulier wat telt: staat het live, en levert
* het iets op? Tot 1.9.21 stonden er een slug, een extern ID en vier tegels
* per formulierplugin, maar niet of het formulier op je site stond.
*/
public function render_forms_page() {
global $wpdb;
$forms = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE source_type <> 'internal' ORDER BY created_at DESC, id DESC", $this->forms_table));
$aanvragen = $this->aanvragen_per_formulier();
$mislukt = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM %i WHERE request_status = 'error' AND created_at >= %s", $this->logs_table,
gmdate('Y-m-d H:i:s', current_time('timestamp') - 7 * DAY_IN_SECONDS)
));
$new_url = admin_url('admin.php?page=wpquotepro-wizard&step=0');
?>
*/
private function aanvragen_per_formulier() {
global $wpdb;
$uit = [];
$rijen = $wpdb->get_results($wpdb->prepare("SELECT form_id, COUNT(*) AS aantal, MAX(created_at) AS laatste FROM %i WHERE request_status = 'success' GROUP BY form_id", $this->logs_table));
foreach ((array) $rijen as $rij) {
$uit[(int) $rij->form_id] = ['aantal' => (int) $rij->aantal, 'laatste' => (string) $rij->laatste];
}
return $uit;
}
/**
* Op welke gepubliceerde pagina staat dit formulier? Via de knop "Pagina
* maken" (optie) of zelf geplakt (shortcode). 0 als nergens.
*/
private function formulier_pagina($form) {
global $wpdb;
$page_id = (int) get_option('wpquotepro_form_page_' . (int) $form->id, 0);
if ($page_id && 'publish' === get_post_status($page_id)) {
return $page_id;
}
$code = $this->external_form_shortcode($form);
if ('' === $code) {
return 0;
}
// Begin van de shortcode ("[wpforms") plus het formuliernummer.
$begin = '%' . $wpdb->esc_like(strtok($code, ' ')) . '%';
$id = (int) $form->external_form_id;
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ('page','post') AND post_content LIKE %s AND (post_content LIKE %s OR post_content LIKE %s) LIMIT 1",
$begin,
'%' . $wpdb->esc_like('id="' . $id . '"') . '%',
'%' . $wpdb->esc_like('id=' . $id . ']') . '%'
));
}
/**
* 1.9.27: bestaat het formulier in de formulierplugin nog?
*
* 'ok', 'plugin_uit' (de formulierplugin staat uit of is weg) of 'weg' (het
* formulier zelf is verwijderd, in de prullenbak of bij Gravity uitgezet).
* Eén keer per paginaweergave per bron: get_forms() is een query.
*/
private function bron_staat($form) {
static $per_bron = [];
$bron = isset($form->source_type) ? (string) $form->source_type : '';
$extern = isset($form->external_form_id) ? (string) $form->external_form_id : '';
if ('' === $bron || 'internal' === $bron || '' === $extern || ! $this->registry) {
return 'ok';
}
if (! isset($per_bron[$bron])) {
$adapter = $this->registry->get_adapter($bron);
if (! $adapter || ! $adapter->is_available()) {
$per_bron[$bron] = false;
} else {
$per_bron[$bron] = array_map(function ($f) { return (string) $f['id']; }, (array) $adapter->get_forms());
}
}
if (false === $per_bron[$bron]) {
return 'plugin_uit';
}
return in_array($extern, $per_bron[$bron], true) ? 'ok' : 'weg';
}
public function vergeet_bron_weg() {
delete_transient('wpquotepro_bron_weg');
}
/** Kan "Opnieuw aanmaken"? Alleen bij een formulier uit een template: dan weten we welke vragen erin hoorden. */
private function kan_bron_herstellen($form) {
$template_key = $this->get_form_template_key((int) $form->id);
if ('' === (string) $template_key || 'blank' === $template_key || ! $this->get_wizard_template($template_key)) {
return false;
}
$adapter = $this->registry->get_adapter((string) $form->source_type);
return $adapter && method_exists($adapter, 'can_create_form') && $adapter->can_create_form();
}
private function herstel_url($form_id) {
return wp_nonce_url(admin_url('admin-post.php?action=wpquotepro_form_recreate&form_id=' . (int) $form_id), 'wpquotepro_form_recreate_' . (int) $form_id);
}
/**
* Opnieuw aanmaken: hetzelfde formulier uit de template in dezelfde
* formulierplugin, en ons offerteformulier wijst er daarna naar. Koppelingen,
* regels, klantvelden en toelichtingen hangen aan de veldnamen en blijven dus
* werken. Staat de oude shortcode op een pagina, dan zetten we die om.
*/
public function handle_form_recreate() {
if (! current_user_can('manage_options')) {
wp_die(esc_html__('No access.', 'gresquote'));
}
$form_id = isset($_GET['form_id']) ? absint($_GET['form_id']) : 0;
check_admin_referer('wpquotepro_form_recreate_' . $form_id);
$terug = admin_url('admin.php?page=wpquotepro-forms');
global $wpdb;
$form = $form_id ? $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->forms_table, $form_id)) : null;
if (! $form || 'weg' !== $this->bron_staat($form) || ! $this->kan_bron_herstellen($form)) {
wp_safe_redirect(add_query_arg('message', 'form_recreate_failed', $terug));
exit;
}
$template_key = $this->get_form_template_key($form_id);
$template = $this->get_wizard_template($template_key);
$template['default_name'] = (string) $form->name;
$oude_code = $this->external_form_shortcode($form);
$oude_pagina = $this->formulier_pagina($form);
$nieuw = $this->registry->get_adapter((string) $form->source_type)->create_form_from_template($template);
if (is_wp_error($nieuw) || empty($nieuw['id'])) {
wp_safe_redirect(add_query_arg('message', 'form_recreate_failed', $terug));
exit;
}
$wpdb->update($this->forms_table, [
'external_form_id' => sanitize_text_field((string) $nieuw['id']),
'external_form_label' => sanitize_text_field((string) $nieuw['label']),
'updated_at' => current_time('mysql'),
], ['id' => $form_id]);
$form->external_form_id = (string) $nieuw['id'];
$nieuwe_code = $this->external_form_shortcode($form);
if ($oude_pagina && '' !== $oude_code && '' !== $nieuwe_code) {
$post = get_post($oude_pagina);
if ($post && false !== strpos($post->post_content, $oude_code)) {
wp_update_post(['ID' => $oude_pagina, 'post_content' => str_replace($oude_code, $nieuwe_code, $post->post_content)]);
}
}
delete_transient('wpquotepro_bron_weg');
wp_safe_redirect(add_query_arg('message', 'form_recreated', $terug));
exit;
}
/**
* Bovenaan wp-admin zolang een actief offerteformulier zijn bron mist: niemand
* kijkt elke dag op Formulieren, en een dood formulier levert stil niets op.
* Vijf minuten bewaard; opnieuw aanmaken wist het meteen.
*/
public function render_bron_weg_melding() {
if (! current_user_can('manage_options')) {
return;
}
$scherm = function_exists('get_current_screen') ? get_current_screen() : null;
if ($scherm && false !== strpos((string) $scherm->id, 'wpquotepro-forms')) {
return; // daar staat het al bij het formulier zelf
}
$namen = get_transient('wpquotepro_bron_weg');
if (false === $namen) {
global $wpdb;
$namen = [];
$actief = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE status = 'active' AND source_type <> 'internal' AND external_form_id <> ''", $this->forms_table));
foreach ((array) $actief as $form) {
if ('ok' !== $this->bron_staat($form)) {
$namen[] = (string) $form->name;
}
}
set_transient('wpquotepro_bron_weg', $namen, 5 * MINUTE_IN_SECONDS);
}
if (empty($namen)) {
return;
}
?>
GresQuote:
id;
$stap3 = admin_url('admin.php?page=wpquotepro-wizard&step=3&form_id=' . $id);
$status = $this->sanitize_form_status($form->status);
// 1.9.27: zonder bronformulier werkt niets, ook al staat het hier op
// actief. Tot nu toe stond er dan gewoon "Live" terwijl de pagina een
// foutcode van de formulierplugin toonde.
if (! in_array($status, ['inactive', 'archived'], true)) {
$bron = $this->bron_staat($form);
if ('plugin_uit' === $bron) {
return ['soort' => 'is-fout', 'label' => sprintf(/* translators: %s: name of the form plugin */ __('%s is switched off', 'gresquote'), $this->registry->get_source_label($form->source_type)), 'link' => [__('Go to Plugins', 'gresquote'), admin_url('plugins.php')]];
}
if ('weg' === $bron) {
$kiezen = [__('Choose another form', 'gresquote'), admin_url('admin.php?page=wpquotepro-wizard&step=1&form_id=' . $id)];
if ($this->kan_bron_herstellen($form)) {
return ['soort' => 'is-fout', 'label' => __('Source form missing', 'gresquote'), 'link' => [__('Create again', 'gresquote'), $this->herstel_url($id)], 'link2' => $kiezen];
}
return ['soort' => 'is-fout', 'label' => __('Source form missing', 'gresquote'), 'link' => $kiezen];
}
}
if ('active' === $status) {
$pagina = $this->formulier_pagina($form);
if ($pagina) {
return ['soort' => 'is-live', 'label' => __('Live', 'gresquote'), 'link' => [sprintf(/* translators: %s: title of the page */ __('on "%s"', 'gresquote'), get_the_title($pagina)), get_permalink($pagina), true]];
}
return ['soort' => 'is-let-op', 'label' => __('Not on your site', 'gresquote'), 'link' => [__('Create page', 'gresquote'), $stap3]];
}
if (in_array($status, ['inactive', 'archived'], true)) {
return ['soort' => 'is-uit', 'label' => __('Off', 'gresquote'), 'link' => [__('Switch on again', 'gresquote'), $stap3]];
}
return ['soort' => 'is-concept', 'label' => __('Draft', 'gresquote'), 'link' => [__('Finish in the wizard', 'gresquote'), $stap3]];
}
/** "vandaag 09:14", "gisteren" of "3 okt". $nu alleen voor de tests. */
private function wanneer($mysql_datum, $nu = null) {
$tijd = strtotime((string) $mysql_datum);
if (! $tijd) {
return '';
}
$nu = null === $nu ? (int) current_time('timestamp') : (int) $nu;
$dag = gmdate('Y-m-d', $tijd);
if (gmdate('Y-m-d', $nu) === $dag) {
/* translators: %s: time, for example 09:14 */
return sprintf(__('today %s', 'gresquote'), gmdate('H:i', $tijd));
}
if (gmdate('Y-m-d', $nu - DAY_IN_SECONDS) === $dag) {
return __('yesterday', 'gresquote');
}
$maanden = [__('Jan', 'gresquote'), __('Feb', 'gresquote'), __('Mar', 'gresquote'), __('Apr', 'gresquote'), __('May', 'gresquote'), __('Jun', 'gresquote'), __('Jul', 'gresquote'), __('Aug', 'gresquote'), __('Sep', 'gresquote'), __('Oct', 'gresquote'), __('Nov', 'gresquote'), __('Dec', 'gresquote')];
return (int) gmdate('j', $tijd) . ' ' . $maanden[(int) gmdate('n', $tijd) - 1];
}
/**
* 1.9.18: Instellingen in vier tabbladen, in de huisstijl van de wizard.
* Tot 1.9.17 deed het tabblad Algemeen vijf dingen tegelijk (koppelen,
* gebruik, bedrijfsgegevens, PDF en mail) met één knop Opslaan onderaan, en
* stond het abonnement er twee keer. Nu gaat elk tabblad over één ding en
* slaat het alleen zijn eigen velden op.
*/
public function render_settings_page() {
$license_status = $this->get_license_status();
$this->maybe_cache_api_key_for_account_page($this->get_configured_api_key());
$actief = $this->huidig_instellingen_tabblad();
if ('abonnement' === $actief && function_exists('wpquotepro_fetch_and_update_usage_limits')) {
// Verse telling en stand bij de backend voordat we ze tonen.
wpquotepro_fetch_and_update_usage_limits();
$license_status = $this->get_license_status();
}
$ondertitels = [
'bedrijf' => __('Your company details, for the quote.', 'gresquote'),
'offerte' => __('What your quote looks like and what happens after sending.', 'gresquote'),
'abonnement' => __('Your subscription, your usage and the connection of this website.', 'gresquote'),
'boekhouding' => __('Accepted quotes go into your accounting automatically.', 'gresquote'),
];
?>
get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->forms_table, $form_id));
$labels = $form ? $this->get_enrichable_fields($form) : [];
WPQUOTEPRO_Enrichment::save_for_field($form_id, $field_name, [
'label' => isset($labels[$field_name]) ? $labels[$field_name] : $field_name,
// Bewust NIET sanitize_text_field: dit is opmaak. wp_kses in de
// enrichment-klasse bepaalt wat er mag blijven staan.
'text_html' => isset($_POST['text_html']) ? wp_kses_post(wp_unslash($_POST['text_html'])) : '',
'images' => $this->posted_enrichment_images($existing),
'show_on' => isset($_POST['show_on']) ? sanitize_key(wp_unslash($_POST['show_on'])) : WPQUOTEPRO_Enrichment::DEFAULT_PLACEMENT,
]);
wp_safe_redirect(add_query_arg('message', 'enrichment_saved', $back));
exit;
}
/**
* De afbeeldingen zoals ze in het formulier stonden bij het opslaan. Het
* formulier stuurt de hele lijst mee, dus verwijderen is simpelweg: niet
* meer meegestuurd.
*
* Ontbreekt het veld helemaal — een oudere pagina in een openstaand tabblad,
* of een POST van elders — dan houden we wat er stond. Anders gooit zo'n
* verzoek stilletjes de afbeeldingen van de ondernemer weg.
*/
private function posted_enrichment_images($existing) {
// Alleen aangeroepen uit handle_save_enrichment(), na check_admin_referer().
// Per afbeelding opgeschoond hieronder (esc_url_raw, absint,
// sanitize_text_field); sanitize_text_field op de hele rij zou %20 uit de
// URL's halen.
// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if (! isset($_POST['images'])) {
return $existing ? $existing['images'] : [];
}
$images = [];
$rijen = (array) wp_unslash($_POST['images']);
// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
foreach ($rijen as $image) {
if (! is_array($image)) {
continue;
}
$url = isset($image['url']) ? esc_url_raw(trim((string) $image['url'])) : '';
if ('' === $url) {
continue;
}
$images[] = [
'url' => $url,
'id' => isset($image['id']) ? absint($image['id']) : 0,
'alt' => isset($image['alt']) ? sanitize_text_field((string) $image['alt']) : '',
];
}
return $images;
}
/**
* Een afgewezen offerte weer openzetten.
*
* De klant belt terug dat het toch doorgaat — dagelijkse praktijk. Zonder
* deze knop zit de ondernemer vast en moet hij een nieuwe offerte maken met
* een nieuw nummer, terwijl het om dezelfde afspraak gaat.
*
* De API-sleutel blijft aan deze kant: de browser krijgt alleen een link met
* nonce, de server praat met de backend.
*/
public function handle_reopen_quotation() {
if (! current_user_can('manage_options')) {
wp_die(esc_html__('No access.', 'gresquote'));
}
$quotation_id = isset($_GET['quotation_id']) ? sanitize_text_field(wp_unslash($_GET['quotation_id'])) : '';
check_admin_referer('wpquotepro_reopen_' . $quotation_id);
$back = admin_url('admin.php?page=wpquotepro-my-quotes');
$api_key = $this->get_configured_api_key();
if ('' === $quotation_id || ! $api_key) {
wp_safe_redirect(add_query_arg('message', 'reopen_failed', $back));
exit;
}
$backend_url = defined('WPQUOTEPRO_BACKEND_BASE') ? WPQUOTEPRO_BACKEND_BASE : 'https://app.wpquotepro.nl';
$response = wp_remote_post(
rtrim($backend_url, '/') . '/api/v1/tenant/quotations/' . rawurlencode($quotation_id) . '/reopen',
[
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/json',
'Apikey' => $api_key,
'Authorization' => 'Bearer ' . $api_key,
],
'body' => '{}',
]
);
$ok = ! is_wp_error($response) && 200 === (int) wp_remote_retrieve_response_code($response);
if (! $ok) {
$reden = is_wp_error($response)
? $response->get_error_message()
: wp_remote_retrieve_body($response);
wpquotepro_log('WPQUOTEPRO: heropenen van offerte ' . $quotation_id . ' mislukt — ' . $reden);
}
wp_safe_redirect(add_query_arg('message', $ok ? 'reopened' : 'reopen_failed', $back));
exit;
}
/**
* 1.9.23: Producten met "Gebruikt in". Een product verwijderen raakt elk
* formulier waar het in een koppeling of regel zit; nu zie je vooraf welke
* formulieren dat zijn, en noemt de bevestiging ze bij naam.
*
* Sinds 1.9.25 is "Uit" echt uit: de brug slaat koppelingen en regels met
* een uitgezet product over, ook in herhaalgroepen en de voorbeeldofferte.
* Daarom vraagt Uitzetten om bevestiging als het product ergens in zit.
*/
public function render_products_page() {
global $wpdb;
$products = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i ORDER BY is_active DESC, name ASC", $this->products_table));
$gebruik = $this->product_gebruik();
$new_url = admin_url('admin.php?page=wpquotepro-product-editor');
?>
>
*/
private function product_gebruik() {
global $wpdb;
$rijen = $wpdb->get_results(
$wpdb->prepare("SELECT x.product_id, f.id AS form_id, f.name FROM (
SELECT product_id, form_id FROM %i WHERE product_id > 0
UNION
SELECT product_id, form_id FROM %i WHERE product_id > 0
) x JOIN %i f ON f.id = x.form_id
WHERE f.source_type <> 'internal'
ORDER BY f.name ASC", $this->form_product_mappings_table, $this->rules_table, $this->forms_table));
$uit = [];
foreach ((array) $rijen as $rij) {
$uit[(int) $rij->product_id][(int) $rij->form_id] = ['id' => (int) $rij->form_id, 'naam' => (string) $rij->name];
}
return array_map('array_values', $uit);
}
/** De zin bij verwijderen: welke formulieren het merken. */
private function product_waarschuwing(array $formulieren) {
if (empty($formulieren)) {
return '';
}
$namen = array_map(function ($f) { return $f['naam']; }, $formulieren);
/* translators: %s: name or names of the forms */
$waar = sprintf(1 === count($namen) ? __('the form %s', 'gresquote') : __('the forms %s', 'gresquote'), implode(', ', $namen));
/* translators: %s: the form or forms, for example: the form Windows */
return sprintf(__('It is in %s; the connections and rules with this product will then no longer work there.', 'gresquote'), $waar);
}
/** De bevestiging bij Uitzetten: een uitgezet product komt nergens meer op de offerte (1.9.25). */
private function product_uit_waarschuwing(array $formulieren) {
$namen = array_map(function ($f) { return $f['naam']; }, $formulieren);
/* translators: %s: name or names of the forms */
$waar = sprintf(1 === count($namen) ? __('the form %s', 'gresquote') : __('the forms %s', 'gresquote'), implode(', ', $namen));
/* translators: %s: the form or forms, for example: the form Windows */
return sprintf(__('Switch off this product? It is in %s and will then no longer appear on the quote there, not even in the sample quote. You can always switch it on again.', 'gresquote'), $waar);
}
/**
* 1.9.23: de producteditor in kaarten, zoals Instellingen: het product, de
* prijs, eventueel de webshopkoppeling, en waar het product gebruikt wordt.
*/
public function render_product_editor_page() {
global $wpdb;
$product_id = isset($_GET['product_id']) ? absint($_GET['product_id']) : 0;
$row = $product_id ? $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->products_table, $product_id)) : null;
$product = $row ? $row : (object) [
'id' => 0,
'name' => '',
'code' => '',
'description_text' => '',
'unit_label' => __('piece', 'gresquote'),
'unit_price' => '',
'vat_rate' => number_format(WPQUOTEPRO_Regio::standaard_tarief(), 2, '.', ''),
'wc_product_id' => 0,
'is_active' => 1,
];
$wc_actief = class_exists('WPQUOTEPRO_WooCommerce') && WPQUOTEPRO_WooCommerce::is_active();
$wc_keuzes = $wc_actief ? WPQUOTEPRO_WooCommerce::selectable_products() : [];
$wc_gekoppeld = isset($product->wc_product_id) ? (int) $product->wc_product_id : 0;
$gebruik = $product_id ? $this->product_gebruik() : [];
$formulieren = isset($gebruik[$product_id]) ? $gebruik[$product_id] : [];
?>
__('Form saved.', 'gresquote'),
'form_deleted' => __('Form deleted.', 'gresquote'),
'product_saved' => __('Product saved.', 'gresquote'),
'product_deleted' => __('Product deleted.', 'gresquote'),
'product_toggled' => __('Product status updated.', 'gresquote'),
'rule_saved' => __('Rule saved.', 'gresquote'),
'rule_deleted' => __('Rule deleted.', 'gresquote'),
'rule_toggled' => __('Rule status updated.', 'gresquote'),
'saved_error' => __('Not everything was saved. Check that the required fields are filled in.', 'gresquote'),
'settings_saved' => __('Settings saved.', 'gresquote'),
'settings_saved_synced' => __('Settings saved. Your subscription details have been updated.', 'gresquote'),
'settings_saved_no_sub' => __('Saved. We found no active subscription for this API key. Check the key on the Subscription tab, or choose a subscription on the GresQuote website.', 'gresquote'),
'settings_saved_no_key' => __('Saved on this website. It is not yet connected to GresQuote: start a trial or connect your account on the Subscription tab.', 'gresquote'),
'templates_synced' => __('Templates fetched again. Your subscription details have also been updated.', 'gresquote'),
'koppel_eerst' => __('Connect this website first: start a free trial or connect your account under Settings, Subscription tab. Then you can test and go live.', 'gresquote'),
'verwijderkeuze_wissen' => __('Saved. If you delete the plugin, all data on this website will be erased as well.', 'gresquote'),
'verwijderkeuze_bewaren' => __('Saved. If you delete the plugin, your data stays on this website.', 'gresquote'),
'email_domain_invalid' => __('The sender email address must use the same domain as your website.', 'gresquote'),
'form_recreated' => __('The form has been created again. If it was on a page, the new form is there now. Give it a quick try.', 'gresquote'),
'form_recreate_failed' => __('Creating it again did not work. Choose another form in the wizard.', 'gresquote'),
'form_page_created' => __('The page with your form is on your website. It has the same name as your form.', 'gresquote'),
'trial_created' => __('Your free trial has started: 14 days and 25 quotes. The API key was filled in automatically; now connect a form.', 'gresquote'),
'trial_created_local' => __('Subscription not connected yet. Use the Subscribe button to activate GresQuote.', 'gresquote'),
'register_failed' => __('Starting the trial did not work: GresQuote could not be reached or returned an error. Try again in a few minutes, or contact support.', 'gresquote'),
'register_terms_required' => __('Tick the box to agree to the terms and conditions and the privacy statement to start the trial.', 'gresquote'),
'site_proof_failed' => __('GresQuote could not confirm that this request comes from this website. To check, our server fetches a verification code through the REST API of your site (/wp-json/wpquotepro/v1/connect-proof). If a security plugin, firewall or site-wide password blocks it, open it up and try again.', 'gresquote'),
'register_site_taken' => __('This website already has a GresQuote account. Connect it with the email address it was registered with, using the "Connect" button in the subscription block above. Forgotten that address? Contact support.', 'gresquote'),
'register_email_taken' => __('This email address already belongs to a GresQuote account. Connect this website with that address using the "Connect" button in the subscription block above.', 'gresquote'),
'trial_required' => __('Activate your GresQuote subscription first.', 'gresquote'),
'templates_synced' => __('Templates synchronised.', 'gresquote'),
'templates_synced_local' => __('Templates fetched again.', 'gresquote'),
'wizard_step_locked' => __('This step is still locked. Finish the previous step first.', 'gresquote'),
'setup_incomplete' => __('The general settings are not complete yet, but you can still test and activate.', 'gresquote'),
'test_mode_enabled' => __('The form is back in draft. You can test again and then activate it.', 'gresquote'),
'blank_form_created' => __('Form created. Now add your own questions in your form plugin.', 'gresquote'),
'wizard_customer_missing' => __('Saved, but first link the name and email address of your customer.', 'gresquote'),
'wizard_product_missing' => __('Saved, but link at least one choice to a product.', 'gresquote'),
'template_installed' => __('Template installed: form, flow, customer mapping, products, product links and rules have been created.', 'gresquote'),
'template_install_failed' => __('Installing the template did not work. Check that the form plugin is active.', 'gresquote'),
'template_source_missing' => __('First choose an active form source for this template.', 'gresquote'),
'reopened' => __('The quote is open again. The customer can still accept it.', 'gresquote'),
'reopen_failed' => __('Reopening did not work. Check your API key; the reason is in the error log.', 'gresquote'),
'enrichment_saved' => __('Explanation saved.', 'gresquote'),
'enrichment_no_field' => __('First choose a question to write an explanation for.', 'gresquote'),
'template_unavailable' => __('This template is not available on this website.', 'gresquote'),
'test_sent' => __('The test payload was processed and sent to the backend. See the result below.', 'gresquote'),
'account_connected' => __('Account connected. The API key was filled in automatically and your subscription is active.', 'gresquote'),
'account_connect_invalid_email' => __('Enter a valid email address — the address of your GresQuote account.', 'gresquote'),
'account_connect_not_found' => __('Connecting did not work: there is no account with this email address for this website. No account yet? Start a free trial above. If you bought a subscription, use the address from your order confirmation email. Still stuck? Contact support.', 'gresquote'),
'account_connect_failed' => __('Connecting did not work. Check that this email address and this website match your purchase, or contact support.', 'gresquote'),
'account_connect_rate_limited' => __('Too many attempts. Wait an hour and try again, or contact support.', 'gresquote'),
];
return isset($map[$message]) ? $map[$message] : '';
}
/** " · 9% btw" als een regel een eigen tarief heeft; niets als hij de offerte volgt. */
private function btw_achtervoegsel($rule) {
if (! isset($rule->vat_rate) || null === $rule->vat_rate || '' === (string) $rule->vat_rate) {
return '';
}
/* translators: %s: VAT rate with percent sign, e.g. 9% */
return ' · ' . sprintf(__('%s VAT', 'gresquote'), WPQUOTEPRO_Regio::procent($rule->vat_rate) . '%');
}
private function format_action_summary($rule) {
switch ($rule->action_type) {
case 'add_product':
case 'append_product':
/* translators: %s: product name */
return sprintf(__('Product %s', 'gresquote'), $rule->product_name ? $rule->product_name : __('(choose)', 'gresquote'))
/* translators: %s: name of the quantity field */
. ($rule->quantity_field ? ' × ' . sprintf(__('field %s', 'gresquote'), $rule->quantity_field) : '')
. (isset($rule->product_is_active) && null !== $rule->product_is_active && ! (int) $rule->product_is_active ? ' · ' . __('switched off, not added to the quote', 'gresquote') : '');
case 'add_fixed_amount':
/* translators: %s: amount */
return sprintf(__('Add %s', 'gresquote'), WPQUOTEPRO_Regio::bedrag($rule->amount, null, '')) . $this->btw_achtervoegsel($rule);
case 'add_amount_per_quantity':
/* translators: 1: amount, 2: name of the quantity field */
return sprintf(__('Add %1$s per %2$s', 'gresquote'), WPQUOTEPRO_Regio::bedrag($rule->amount, null, ''), $rule->quantity_field ? $rule->quantity_field : __('quantity', 'gresquote')) . $this->btw_achtervoegsel($rule);
case 'add_percentage_surcharge':
/* translators: %s: percentage or amount */
return sprintf(__('Surcharge %s', 'gresquote'), number_format((float) $rule->percentage, 2, ',', '.') . '%');
case 'add_percentage_discount':
/* translators: %s: percentage or amount */
return sprintf(__('Discount %s', 'gresquote'), number_format((float) $rule->percentage, 2, ',', '.') . '%');
case 'add_discount_amount':
/* translators: %s: percentage or amount */
return sprintf(__('Discount %s', 'gresquote'), WPQUOTEPRO_Regio::bedrag($rule->amount, null, ''));
case 'add_fixed_surcharge':
/* translators: %s: percentage or amount */
return sprintf(__('Surcharge %s', 'gresquote'), WPQUOTEPRO_Regio::bedrag($rule->amount, null, '')) . $this->btw_achtervoegsel($rule);
case 'info_only':
return __('Text line on the quote', 'gresquote') . (! empty($rule->description_text) ? '' : ' ' . __('(no text yet)', 'gresquote'));
default:
return $rule->action_type;
}
}
/**
* Waar een regelactie (opslaan, aan/uit, verwijderen) naartoe terugkeert:
* de stap Prijs van de wizard. Sinds 1.9.21 is er geen losse pagina Regels
* meer; zonder formulier naar Formulieren.
*/
private function rule_return_url($from_wizard, $form_id, $message) {
if ($form_id) {
return $this->admin_url_with_message('wpquotepro-wizard', $message, ['step' => 2, 'form_id' => (int) $form_id]) . '#wpqp-regels';
}
return $this->admin_url_with_message('wpquotepro-forms', $message);
}
private function admin_url_with_message($page, $message, $args = []) {
$args = wp_parse_args(
$args,
[
'page' => $page,
'message' => $message,
]
);
return add_query_arg($args, admin_url('admin.php'));
}
private function get_external_forms_by_source() {
$result = [];
foreach ($this->registry->get_source_options() as $source => $label) {
$result[$source] = $this->registry->get_external_form_choices($source);
}
return $result;
}
private function get_field_suggestions_for_form_editor($form) {
$suggestions = [];
if (! empty($form->id)) {
global $wpdb;
$rule_fields = $wpdb->get_col($wpdb->prepare("SELECT DISTINCT field_name FROM %i WHERE form_id = %d AND field_name != '' ORDER BY field_name ASC", $this->rules_table, (int) $form->id));
$mapping_fields = $wpdb->get_col($wpdb->prepare("SELECT DISTINCT field_name FROM %i WHERE form_id = %d AND field_name != '' ORDER BY field_name ASC", $this->form_product_mappings_table, (int) $form->id));
$mapping_quantity_fields = $wpdb->get_col($wpdb->prepare("SELECT DISTINCT quantity_field FROM %i WHERE form_id = %d AND quantity_field != '' ORDER BY quantity_field ASC", $this->form_product_mappings_table, (int) $form->id));
$suggestions = array_merge($suggestions, (array) $rule_fields, (array) $mapping_fields, (array) $mapping_quantity_fields);
}
$common = ['name', 'email', 'phone', 'aantal', 'quantity', 'qty'];
$suggestions = array_merge($suggestions, $common);
$suggestions = array_values(array_unique(array_filter(array_map('sanitize_key', $suggestions))));
sort($suggestions);
return $suggestions;
}
/**
* Velden van een formulier voor de beheerschermen (koppelen, regels,
* voorbeeldofferte). 1.9.15: een veld zonder vraagtekst, zoals een CF7-tag
* `[select select-820 "plastic" "hout"]` zonder tekst eromheen, heette
* overal "select-820". Hier krijgt zo'n veld een leesbare naam; op de
* offerte zelf (de brug leest de adapter rechtstreeks) verandert niets.
*/
private function admin_field_choices($source_type, $external_form_id) {
$velden = (array) $this->registry->get_field_choices($source_type, $external_form_id);
$typen = [
'text' => __('Text field', 'gresquote'), 'email' => __('Email field', 'gresquote'), 'tel' => __('Phone field', 'gresquote'), 'number' => __('Number', 'gresquote'),
'textarea' => __('Text area', 'gresquote'), 'date' => __('Date', 'gresquote'), 'url' => __('Web address', 'gresquote'), 'select' => __('Dropdown', 'gresquote'),
'radio' => __('Radio buttons', 'gresquote'), 'checkbox' => __('Checkboxes', 'gresquote'), 'file' => __('File', 'gresquote'),
];
foreach ($velden as $i => $veld) {
if (! is_array($veld) || empty($veld['name'])) {
continue;
}
$naam = (string) $veld['name'];
$label = isset($veld['label']) ? trim((string) $veld['label']) : '';
// Alleen technische namen ("select-820", "text_3"); "naam" als label is prima.
if (('' !== $label && $label !== $naam) || ! preg_match('/[0-9_-]/', $naam)) {
continue;
}
$keuzes = [];
foreach ((array) ($veld['choices'] ?? []) as $keuze) {
$tekst = is_array($keuze) ? (string) ($keuze['label'] ?? ($keuze['value'] ?? '')) : (string) $keuze;
if ('' !== trim($tekst)) {
$keuzes[] = trim($tekst);
}
}
if (! empty($keuzes)) {
$velden[$i]['label'] = __('Choice:', 'gresquote') . ' ' . implode(' / ', array_slice($keuzes, 0, 3)) . (count($keuzes) > 3 ? ' / …' : '') . ' (' . $naam . ')';
} else {
$type = strtolower((string) ($veld['type'] ?? ''));
$velden[$i]['label'] = (isset($typen[$type]) ? $typen[$type] : __('Field', 'gresquote')) . ' (' . $naam . ')';
}
}
return $velden;
}
private function get_external_field_choices_by_source_form() {
$result = [];
foreach ($this->registry->get_source_options() as $source => $label) {
$result[$source] = [];
$forms = $this->registry->get_external_form_choices($source);
if (empty($forms) || ! is_array($forms)) {
continue;
}
foreach ($forms as $form_choice) {
if (empty($form_choice['id'])) {
continue;
}
$result[$source][(string) $form_choice['id']] = $this->admin_field_choices($source, $form_choice['id']);
}
}
return $result;
}
private function get_field_suggestions_by_internal_form() {
global $wpdb;
$result = [];
$forms = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i ORDER BY id ASC", $this->forms_table));
foreach ($forms as $form) {
$fields = [];
if (! empty($form->external_form_id)) {
$fields = $this->admin_field_choices($form->source_type, $form->external_form_id);
}
if (empty($fields)) {
$stored_fields = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT field_name FROM %i WHERE form_id = %d AND field_name != '' ORDER BY field_name ASC", $this->rules_table,
$form->id
)
);
foreach ($stored_fields as $name) {
$fields[] = [
'name' => $name,
'label' => $name,
'choices' => [],
];
}
}
$result[(string) $form->id] = $fields;
}
return $result;
}
/**
* De pagina Templates (1.9.23): dezelfde kiezer als de wizard, met filters op
* soort, branche en zoeken. Bij je eigen templates staat in welke formulieren
* ze gebruikt worden.
*/
public function render_templates_library_page() {
// Altijd de nieuwste catalogus ophalen bij het openen van deze pagina: geen
// aparte "synchroniseren"-actie nodig om nieuwe of gewijzigde templates te
// zien. De 5-minuten cache blijft gelden voor de wizard.
$catalogus = $this->template_catalogus(true);
$source_options = $this->get_creatable_source_options();
// Per template: welke eigen formulieren zijn ermee gemaakt (omgekeerde van
// get_form_template_key()).
global $wpdb;
$gebruik = [];
foreach ((array) $wpdb->get_results($wpdb->prepare("SELECT id, name, status FROM %i WHERE source_type <> 'internal' ORDER BY updated_at DESC, id DESC", $this->forms_table)) as $form_row) {
$gebruik[$this->get_form_template_key($form_row->id)][] = $form_row;
}
?>