__('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 '

' . esc_html__('Rules are in the wizard, in the Price step of your form.', 'gresquote') . ' ' . esc_html__('Go to the rules', 'gresquote') . '

'; } 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); ?>
$item) { if (empty($item['ok'])) { $volgende = $i; break; } } ?>

→
    $item) : $ok = ! empty($item['ok']); ?>
  1. (' . esc_html__('done', 'gresquote') . ')' : ''; ?> →
['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; } ?>

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; } ?>
$item) : ?>
print_template_filter_attrs($template); ?>> ·
render_template_counts($template); ?>
$form) : ?>name); ?>
→

> .

template_catalogus(); $source_options = $this->get_creatable_source_options(); $bron_keys = array_keys($source_options); $standaard_bron = ! empty($bron_keys) ? (string) $bron_keys[0] : ''; ?>
›

render_notice(); ?> render_template_bron_keuze($source_options); ?> render_template_raster($catalogus, $source_options); ?>
get_results($wpdb->prepare("SELECT * FROM %i ORDER BY updated_at DESC, id DESC", $this->forms_table)); $selected_form = null; if ($selected_form_id) { foreach ($forms as $candidate_form) { if ((int) $candidate_form->id === (int) $selected_form_id) { $selected_form = $candidate_form; break; } } } if ($auto_select_latest && ! $selected_form && ! empty($forms)) { $selected_form = $forms[0]; $selected_form_id = (int) $selected_form->id; } $customer_rows = []; $customer_count = 0; $product_mapping_count = 0; $rule_count = 0; if ($selected_form_id) { $customer_rows = $wpdb->get_results($wpdb->prepare("SELECT customer_key, field_name FROM %i WHERE form_id = %d", $this->form_customer_mappings_table, $selected_form_id)); $customer_count = count($customer_rows); $product_mapping_count = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE form_id = %d AND is_active = 1", $this->form_product_mappings_table, $selected_form_id)); $rule_count = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i WHERE form_id = %d AND is_active = 1", $this->rules_table, $selected_form_id)); } $mapped_customer_keys = []; foreach ($customer_rows as $customer_row) { if (! empty($customer_row->field_name)) { $mapped_customer_keys[(string) $customer_row->customer_key] = true; } } $has_name = ! empty($mapped_customer_keys['full_name']) || (! empty($mapped_customer_keys['first_name']) && ! empty($mapped_customer_keys['last_name'])); $has_email = ! empty($mapped_customer_keys['email']); $formulier_geldig = (bool) ($selected_form && ! empty($selected_form->name) && 'internal' !== $selected_form->source_type && ! empty($selected_form->external_form_id)); $step_valid = [ 1 => $formulier_geldig && $has_name && $has_email, 2 => (bool) ($selected_form_id && $product_mapping_count > 0), // kortingen en toeslagen zijn optioneel 3 => (bool) ($selected_form && 'active' === $selected_form->status), ]; $can_access = [1 => true]; for ($i = 2; $i <= 3; $i++) { $can_access[$i] = ! empty($step_valid[$i - 1]) && ! empty($can_access[$i - 1]); } return [ 'forms' => $forms, 'selected_form' => $selected_form, 'selected_form_id' => (int) $selected_form_id, 'customer_count' => (int) $customer_count, 'product_mapping_count' => (int) $product_mapping_count, 'rule_count' => (int) $rule_count, 'has_name' => $has_name, 'has_email' => $has_email, 'form_valid' => $formulier_geldig, 'step_valid' => $step_valid, 'can_access' => $can_access, ]; } private function get_first_accessible_wizard_step($requested_step, $can_access) { $requested_step = max(1, min(3, (int) $requested_step)); if (! empty($can_access[$requested_step])) { return $requested_step; } for ($step = 3; $step >= 1; $step--) { if (! empty($can_access[$step])) { return $step; } } return 1; } private function render_wizard_progress($current_step, $state) { $steps = $this->get_wizard_step_definitions(); $selected_form_id = (int) $state['selected_form_id']; ?>
✓ $step) : ?>
__('connection to GresQuote', 'gresquote'), 'company_name' => __('company name', 'gresquote'), 'company_email' => __('company email address', 'gresquote'), 'quotation_validity_days' => __('quote validity period', 'gresquote'), ]; $open = []; $eerste_veld = ''; foreach ($setup_status['items'] as $sleutel => $item) { if (! empty($item['ok'])) { continue; } $open[] = isset($ontbreekt[$sleutel]) ? $ontbreekt[$sleutel] : $item['label']; if ('' === $eerste_veld) { $eerste_veld = (string) $item['field']; } } $aantal = count($open); ?>
.
registry->get_source_label($form->source_type); $formulier = ! empty($form->external_form_label) ? $form->external_form_label : $form->name; ?>

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 '
' . esc_html($this->get_notice_text('wizard_step_locked')) . '
'; } $steps = $this->get_wizard_step_definitions(); $selected_form = $state['selected_form']; $selected_form_id = (int) $state['selected_form_id']; $huidige_bron = $selected_form && ! empty($selected_form->source_type) ? (string) $selected_form->source_type : ''; $sources = $this->get_wizard_source_options($huidige_bron); $default_wizard_source_type = $this->get_default_wizard_source_type($huidige_bron); $form_for_step = $selected_form ? $selected_form : (object) [ 'id' => 0, 'name' => '', 'slug' => '', 'source_type' => $default_wizard_source_type, 'external_form_id' => '', 'external_form_label' => '', 'description_text' => '', 'status' => 'draft', ]; if ('internal' === $form_for_step->source_type) { $form_for_step->source_type = $default_wizard_source_type; $form_for_step->external_form_id = ''; $form_for_step->external_form_label = ''; } if (! $selected_form && 'blank' !== $template_key) { $template = $this->get_wizard_template($template_key); if (empty($form_for_step->name)) { $form_for_step->name = $template['default_name']; } if (empty($form_for_step->description_text)) { $form_for_step->description_text = $template['default_description']; } } if ($selected_form_id) { $template_key = $this->get_form_template_key($selected_form_id); } $active_template = $this->get_wizard_template($template_key); $external_choices = $this->registry->get_external_form_choices($form_for_step->source_type); // Ook uitgezette producten: een koppeling met zo'n product moet het blijven // tonen, anders staat er "Selecteer product" en gooit opslaan de koppeling // weg. In de keuzelijsten staan ze alleen waar ze al gekozen zijn (1.9.25). $products = $wpdb->get_results($wpdb->prepare("SELECT id, name, code, unit_price, is_active FROM %i ORDER BY name ASC", $this->products_table)); $template_products_for_form = $selected_form_id ? $this->get_template_products_for_form($selected_form_id) : []; $mappings = []; if ($selected_form_id) { $mappings = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE form_id = %d ORDER BY sort_order ASC, id ASC", $this->form_product_mappings_table, $selected_form_id)); } if (empty($mappings)) { $mappings = [(object) [ 'id' => 0, 'field_name' => '', 'compare_value' => '', 'product_id' => 0, 'quantity_field' => '', 'item_key' => '', 'sort_order' => 10, 'is_active' => 1, ]]; } $customer_mapping_values = []; if ($selected_form_id) { $customer_rows = $wpdb->get_results($wpdb->prepare("SELECT customer_key, field_name FROM %i WHERE form_id = %d", $this->form_customer_mappings_table, $selected_form_id)); foreach ($customer_rows as $customer_row) { $customer_mapping_values[(string) $customer_row->customer_key] = (string) $customer_row->field_name; } } $customer_fields = $this->get_customer_mapping_fields(); $submissions_url = admin_url('admin.php?page=wpquotepro-submissions'); ?>
› name) : esc_html__('New quote form', 'gresquote'); ?>

render_template_install_summary($selected_form, $active_template, $state, count($template_products_for_form)); } elseif ('blank_form_created' !== $wizard_message) { // Na "leeg formulier" staat de uitleg in stap 1 zelf; geen tweede melding. $this->render_notice(); } ?> render_setup_notice($setup_status); ?> render_wizard_progress($current_step, $state); ?>
external_form_edit_url($selected_form) : ''; $bron_naam = $selected_form ? $this->registry->get_source_label($selected_form->source_type) : ''; ?>
external_form_label ? $selected_form->external_form_label : $selected_form->name, $bron_naam)); ?>

↗

render_wizard_hidden_context(1, $selected_form_id); ?> render_template_summary($template_key); ?>

↗

', '')); ?>

description_text) ? ' open' : ''; ?>>

render_template_hint_list(__('Tips from the template', 'gresquote'), $active_template['customer_hints']); ?> $customer_meta) { if (! in_array($customer_key, $hoofdvelden, true) && ! empty($customer_mapping_values[$customer_key])) { $overige_gekoppeld = true; } } $render_klantveld = function ($customer_key, $customer_meta) use ($customer_mapping_values) { $selected_customer_field = isset($customer_mapping_values[$customer_key]) ? $customer_mapping_values[$customer_key] : ''; ?>
>
$customer_meta) : ?>
admin_field_choices($form_for_step->source_type, $form_for_step->external_form_id) as $veld) { if (is_array($veld) && ! empty($veld['name'])) { $veldlabels[(string) $veld['name']] = ! empty($veld['label']) ? (string) $veld['label'] : (string) $veld['name']; } } $prijs_url = admin_url('admin.php?page=wpquotepro-wizard&step=2&form_id=' . $selected_form_id); $nieuwe_regel_url = add_query_arg('regel', 'nieuw', $prijs_url) . '#wpqp-regel-paneel'; $regel_param = isset($_GET['regel']) ? sanitize_key(wp_unslash($_GET['regel'])) : ''; $regel_context = null; if ('nieuw' === $regel_param || ctype_digit($regel_param)) { $regel_context = $this->get_rule_editor_context('nieuw' === $regel_param ? 0 : (int) $regel_param, $selected_form_id); if ((int) $regel_context[0]->form_id !== $selected_form_id) { $regel_context = null; // regel van een ander formulier: niet tonen } } $product_label = function ($product) { return $product->name . ($product->code ? ' (' . $product->code . ')' : '') . (isset($product->is_active) && ! (int) $product->is_active ? ' (' . __('switched off', 'gresquote') . ')' : ''); }; // Eén koppeling: ALS keuze DAN product × aantal. Een koppeling met een // lege waarde geldt altijd zodra dat veld is ingevuld (templates zetten zo // bijvoorbeeld voorrijkosten vast op de offerte); die tonen we als // "Altijd" en schrijven we ongewijzigd terug. Tot 1.9.13 viel zo'n // koppeling bij opslaan stil weg, omdat het veld geen keuzeveld is. $render_koppeling = function ($mapping, $index) use ($products, $veldlabels, $product_label) { $altijd = '' !== (string) $mapping->field_name && '' === trim((string) $mapping->compare_value); ?>
field_name]) ? $veldlabels[$mapping->field_name] : $mapping->field_name)); ?>
id); })); $voorbeeld_velden = $this->get_preview_fields($form_for_step, $opgeslagen_koppelingen, $this->get_rules_for_form($selected_form_id)); ?>
render_wizard_hidden_context(2, $selected_form_id); ?> render_wizard_field_context($form_for_step); ?>

render_template_hint_list(__('Tips from the template', 'gresquote'), $active_template['product_hints']); ?>

name); ?> code); ?> · unit_label); ?>
$mapping) { $render_koppeling($mapping, $index); } ?>
  • .

render_template_hint_list(__('Tips from the template', 'gresquote'), $active_template['rule_hints']); ?>

id ? __('Edit rule', 'gresquote') : __('New rule', 'gresquote')); ?>

render_rule_form($p_rule, $p_forms, $p_products, $p_operators, $p_actions, true, $prijs_url . '#wpqp-regels'); ?>
get_rules_for_form($selected_form_id); ?> render_rules_table($wizard_rules, $nieuwe_regel_url, true, $selected_form_id, $veldlabels); ?>
status; $laatste_logs = $this->get_latest_submission_logs($selected_form_id, 1); $laatste_log = ! empty($laatste_logs) ? $laatste_logs[0] : null; $laatste_antwoord = $laatste_log ? json_decode((string) $laatste_log->response_body, true) : null; $offerte_gelukt = $laatste_log && (int) $laatste_log->response_code >= 200 && (int) $laatste_log->response_code < 300 && is_array($laatste_antwoord) && ! empty($laatste_antwoord['quotationNumber']); $op_site_pagina = $selected_form ? $this->get_form_site_page($selected_form) : null; $site_code = $selected_form ? $this->external_form_shortcode($selected_form) : ''; $huidig_punt = ! $laatste_log ? 1 : (! $offerte_gelukt ? 2 : (! $op_site_pagina ? 3 : (! $is_actief ? 4 : 0))); // 1.9.31: bouwen mag zonder koppeling, testen en live gaan niet. Zonder // sleutel maakte de backend stil een proef aan bij de eerste aanvraag, // zonder akkoord op de voorwaarden en zonder dat de ondernemer het wist. $gekoppeld = $this->is_gekoppeld(); $punt_klasse = function ($nummer, $klaar) use ($huidig_punt) { return 'wpqp-check' . ($klaar ? ' is-done' : '') . ($nummer === $huidig_punt ? ' is-now' : ''); }; $euro = function ($waarde) { // Harde spatie: € en bedrag niet over twee regels. return WPQUOTEPRO_Regio::bedrag($waarde); }; ?>

created_at))); ?>

Shortcode')); ?>

get_form_status_label($selected_form->status) : __('not set', 'gresquote'))); ?>
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') . '

'; return; } $shortcode = $this->external_form_shortcode($form); if ('' !== $shortcode) { echo do_shortcode($shortcode); return; } echo '

' . 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); ?>
render_external_form_shortcode($form); ?>
id); ?>

created_at); ?> request_status); ?> response_code ? (int) $log->response_code : '—'; ?>
render_test_result_summary($latest_log); ?>

payload_json, true), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ?>

response_body); ?>

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') . '

'; if ($code >= 200 && $code < 300 && is_array($response) && ! empty($response['quotationNumber'])) { /* translators: %s: quote number */ echo '

' . 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') . '') . '

'; $lines = isset($response['lines']) && is_array($response['lines']) ? $response['lines'] : []; if (! empty($lines)) { echo ''; foreach ($lines as $line) { $quantity = isset($line['quantity']) && '' !== (string) $line['quantity'] ? rtrim(rtrim(number_format((float) $line['quantity'], 4, ',', '.'), '0'), ',') . (! empty($line['unit']) ? ' ' . (string) $line['unit'] : '') : '—'; $amount = isset($line['signedAmountInclVat']) ? $line['signedAmountInclVat'] : ($line['amountInclVat'] ?? 0); echo ''; } echo '
' . esc_html__('Line', 'gresquote') . '' . esc_html__('Quantity', 'gresquote') . '' . esc_html__('Amount incl. VAT', 'gresquote') . '
' . esc_html((string) ($line['description'] ?? '')) . '' . esc_html($quantity) . '' . esc_html($euro($amount)) . '
'; } return; } if ($code >= 200 && $code < 300) { echo '

' . esc_html__('The submission has been received.', 'gresquote') . '

'; return; } $message = is_array($response) && isset($response['error']['message']) ? (string) $response['error']['message'] : ''; if ($code > 0) { /* translators: %d: status code of the response */ echo '

' . esc_html__('No quote was created', 'gresquote') . ' (' . sprintf(esc_html__('error code %d', 'gresquote'), (int) $code) . ').'; } else { echo '

' . esc_html__('GresQuote could not be reached', 'gresquote') . ', ' . esc_html__('no quote was created.', 'gresquote'); } echo '

'; if ('' !== $message) { /* translators: %s: message from GresQuote */ echo '

' . sprintf(esc_html__('Message: %s', 'gresquote'), esc_html($message)) . '

'; } } 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'); ?>
GresQuote ›

+
render_notice(); ?> render_formulieren_tabs('formulieren'); ?> 0) : ?>
get_creatable_source_options())) : ?>
id; $stand = $this->formulier_stand($form); $telling = isset($aanvragen[$id]) ? $aanvragen[$id] : ['aantal' => 0, 'laatste' => '']; $wizard = admin_url('admin.php?page=wpquotepro-wizard&form_id=' . $id); ?>
name); ?> registry->get_source_label($form->source_type)); ?> external_form_label)) : ?> · external_form_label)); ?>
>
0) : ?> , wanneer($telling['laatste']))); ?> 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'), ]; ?>
GresQuote ›

render_notice(); ?> render_go_live_checklist(); ?> render_settings_tabs($actief); ?>
render_accounting_settings_tab(); } elseif ('abonnement' === $actief) { include WPQUOTEPRO_PLUGIN_DIR . 'admin/instellingen-abonnement.php'; } elseif ('offerte' === $actief) { include WPQUOTEPRO_PLUGIN_DIR . 'admin/instellingen-offerte.php'; } else { include WPQUOTEPRO_PLUGIN_DIR . 'admin/instellingen-bedrijf.php'; } ?>
['error'], 'dubbel' => ['dubbel_genegeerd']]; // 1.0.6: met prepare() (Plugin Check). Elke filter is precies één status. $status = isset($statussen[$filter]) ? (string) $statussen[$filter][0] : ''; if ('' !== $status && $alleen_form) { $rows = $wpdb->get_results($wpdb->prepare('SELECT * FROM %i WHERE request_status = %s AND form_id = %d ORDER BY created_at DESC, id DESC LIMIT 100', $this->logs_table, $status, $alleen_form)); } elseif ('' !== $status) { $rows = $wpdb->get_results($wpdb->prepare('SELECT * FROM %i WHERE request_status = %s ORDER BY created_at DESC, id DESC LIMIT 100', $this->logs_table, $status)); } elseif ($alleen_form) { $rows = $wpdb->get_results($wpdb->prepare('SELECT * FROM %i WHERE form_id = %d ORDER BY created_at DESC, id DESC LIMIT 100', $this->logs_table, $alleen_form)); } else { $rows = $wpdb->get_results($wpdb->prepare('SELECT * FROM %i ORDER BY created_at DESC, id DESC LIMIT 100', $this->logs_table)); } $tellingen = []; $telling_rijen = $alleen_form ? $wpdb->get_results($wpdb->prepare('SELECT request_status, COUNT(*) AS aantal FROM %i WHERE form_id = %d GROUP BY request_status', $this->logs_table, $alleen_form)) : $wpdb->get_results($wpdb->prepare('SELECT request_status, COUNT(*) AS aantal FROM %i GROUP BY request_status', $this->logs_table)); foreach ((array) $telling_rijen as $t) { $tellingen[(string) $t->request_status] = (int) $t->aantal; } $formnamen = []; foreach ((array) $wpdb->get_results($wpdb->prepare("SELECT id, name FROM %i", $this->forms_table)) as $f) { $formnamen[(int) $f->id] = (string) $f->name; } $basis = admin_url('admin.php?page=wpquotepro-submissions' . ($alleen_form ? '&form_id=' . $alleen_form : '')); $chips = [ '' => [__('Everything', 'gresquote'), array_sum($tellingen)], 'mislukt' => [__('Failed', 'gresquote'), isset($tellingen['error']) ? $tellingen['error'] : 0], 'dubbel' => [__('Duplicate', 'gresquote'), isset($tellingen['dubbel_genegeerd']) ? $tellingen['dubbel_genegeerd'] : 0], ]; ?>
GresQuote › ›

render_formulieren_tabs('verzendingen'); ?>
$chip) : ?> > 0) : ?> ✕
verzending_klant($row); $uitkomst = $this->verzending_uitkomst($row); $formnaam = isset($formnamen[(int) $row->form_id]) ? $formnamen[(int) $row->form_id] : '—'; $zoek = strtolower(implode(' ', [$klant['naam'], $klant['email'], $formnaam, $uitkomst['label']])); ?>
wanneer($row->created_at)); ?> — → id, WPQUOTEPRO_Bijlagen::uit_log($row))); ?>
created_at); ?>
registry->get_source_label($row->source_type)); ?>
entry_reference ?: '—'); ?>
response_code) ? (int) $row->response_code : '—'; ?>
response_body) : ?>
response_body, 0, 1500)); ?>

payload_json, true); $klant = is_array($payload) && isset($payload['customer']) && is_array($payload['customer']) ? $payload['customer'] : []; $naam = ''; foreach (['full_name', 'name', 'company_name'] as $sleutel) { if (! empty($klant[$sleutel]) && is_scalar($klant[$sleutel])) { $naam = trim((string) $klant[$sleutel]); break; } } $email = ! empty($klant['email']) && is_scalar($klant['email']) ? trim((string) $klant['email']) : ''; return ['naam' => $naam, 'email' => $email]; } /** * Wat een verzending werd, in woorden: een offertenummer, of waarom niet en * de knop die het oplost. De uitleg per statuscode komt uit * WPQUOTEPRO_Verzendfout::soort(), dezelfde als in de melding en de mail. * * @return array{soort:string, label:string, reden:string, actie:array|null} */ private function verzending_uitkomst($row) { $status = (string) $row->request_status; $body = (string) $row->response_body; $json = json_decode($body, true); if ('success' === $status) { $nummer = is_array($json) ? (string) ($json['quotationNumber'] ?? ($json['quotation_number'] ?? '')) : ''; return ['soort' => 'is-live', 'label' => '' !== $nummer ? sprintf(/* translators: %s: quote number */ __('Quote %s', 'gresquote'), $nummer) : __('Sent', 'gresquote'), 'reden' => '', 'actie' => null]; } if ('dubbel_genegeerd' === $status) { return ['soort' => 'is-uit', 'label' => __('Duplicate, skipped', 'gresquote'), 'reden' => $body, 'actie' => null]; } if ('error' === $status) { $code = (int) $row->response_code; list($soort, $uitleg) = WPQUOTEPRO_Verzendfout::soort($code, 0 === $code); // Bij geweigerde gegevens staat de echte reden in het antwoord. if ('gegevens' === $soort && is_array($json) && ! empty($json['error']['message'])) { /* translators: %s: reason from GresQuote */ $uitleg = sprintf(__('The request was refused: %s', 'gresquote'), (string) $json['error']['message']); } $acties = [ 'limiet' => [__('View subscription', 'gresquote'), $this->instellingen_url('abonnement')], 'sleutel' => [__('Check connection', 'gresquote'), $this->instellingen_url('abonnement')], 'gegevens' => [__('Check customer fields', 'gresquote'), admin_url('admin.php?page=wpquotepro-wizard&step=1&form_id=' . (int) $row->form_id)], ]; return ['soort' => 'is-fout', 'label' => __('Not sent', 'gresquote'), 'reden' => $uitleg, 'actie' => isset($acties[$soort]) ? $acties[$soort] : null]; } return ['soort' => 'is-concept', 'label' => __('In progress', 'gresquote'), 'reden' => '', 'actie' => null]; } /** De vragen van het gekoppelde bronformulier, met hun label. */ private function get_enrichable_fields($form) { $adapter = $this->registry->get_adapter(isset($form->source_type) ? $form->source_type : ''); if (! $adapter) { return []; } $fields = $adapter->get_fields(isset($form->external_form_id) ? $form->external_form_id : ''); $items = []; foreach ((array) $fields as $field) { $name = isset($field['name']) ? sanitize_key((string) $field['name']) : ''; if ('' === $name) { continue; } $items[$name] = isset($field['label']) && '' !== $field['label'] ? (string) $field['label'] : $name; } return $items; } public function render_enrichment_page() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } global $wpdb; $form_id = isset($_GET['form_id']) ? absint($_GET['form_id']) : 0; $form = $form_id ? $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->forms_table, $form_id)) : null; if (! $form) { wp_die(esc_html__('Form not found.', 'gresquote')); } $field_name = isset($_GET['field']) ? sanitize_key(wp_unslash($_GET['field'])) : ''; $fields = $this->get_enrichable_fields($form); $enrichment = WPQUOTEPRO_Enrichment::get_for_form($form_id); $back = admin_url('admin.php?page=wpquotepro-enrichment&form_id=' . $form_id); ?>
GresQuote › ›

render_notice(); ?>

registry->get_source_label($form->source_type))); ?>

'', 'images' => [], 'show_on' => WPQUOTEPRO_Enrichment::DEFAULT_PLACEMENT]; ?>

'text_html', 'textarea_rows' => 8, 'media_buttons' => false, 'teeny' => true, 'quicktags' => true, ] ); ?>
$image) : ?>

$label) : ?> __('Form and quote', 'gresquote'), 'form' => __('Form only', 'gresquote'), 'quote' => __('Quote only', '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'); ?>
GresQuote ›

+
render_notice(); ?>

    $melding) : ?>
  • — ()

id; $actief = 1 === (int) $product->is_active; $formulieren = isset($gebruik[$id]) ? $gebruik[$id] : []; // De prijs in onze tabel is bij een gekoppeld product niet wat // de klant krijgt: die wordt bij het rekenen opgehaald. Hem hier // tonen alsof hij geldt, zou misleiden. $wc_nu = (! empty($product->wc_product_id) && class_exists('WPQUOTEPRO_WooCommerce')) ? WPQUOTEPRO_WooCommerce::resolve((int) $product->wc_product_id) : null; $prijs = null !== $wc_nu ? (float) $wc_nu['unit_price'] : (float) $product->unit_price; $btw = null !== $wc_nu ? (float) $wc_nu['vat_rate'] : (float) $product->vat_rate; $waarschuwing = $this->product_waarschuwing($formulieren); ?>
name); ?> code) : ?> code); ?> unit_label) : ?>unit_label)); ?> wc_product_id)) : ?> $form) : ?>
> */ 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] : []; ?>
GresQuote › ›

name) : esc_html__('New product', 'gresquote'); ?>

  • ·
get_results( $wpdb->prepare( "SELECT r.*, p.name AS product_name, p.is_active AS product_is_active FROM %i r LEFT JOIN %i p ON p.id = r.product_id WHERE r.form_id = %d ORDER BY r.sort_order ASC, r.id ASC", $this->rules_table, $this->products_table, (int) $form_id ) ); } private function render_rules_table($rules, $editor_url, $wizard = false, $wizard_form_id = 0, array $veldlabels = []) { $wizard_arg = $wizard ? '&wizard=1' : ''; // 1.9.13: vanuit de wizard opent Bewerk het regelpaneel in de stap Prijs. $bewerk_url = function ($rule) use ($wizard, $wizard_form_id, $wizard_arg) { if ($wizard && $wizard_form_id) { return admin_url('admin.php?page=wpquotepro-wizard&step=2&form_id=' . (int) $wizard_form_id . '®el=' . (int) $rule->id) . '#wpqp-regel-paneel'; } return admin_url('admin.php?page=wpquotepro-rule-editor&rule_id=' . (int) $rule->id . $wizard_arg); }; ?>
#
sort_order; ?>
field_name ? __('Always', 'gresquote') : (isset($veldlabels[$rule->field_name]) ? $veldlabels[$rule->field_name] : $rule->field_name) . ' ' . $rule->rule_operator . ' ' . $rule->compare_value); ?>
format_action_summary($rule)); ?>
description_text)) : ?>
description_text); ?>
is_active === 1 ? __('On', 'gresquote') : __('Off', 'gresquote')); ?>

get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->rules_table, $rule_id)) : null; $forms = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i ORDER BY name ASC", $this->forms_table)); // Ook uitgezette producten, zie de wizard: alleen getoond waar ze al gekozen zijn. $products = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i ORDER BY name ASC", $this->products_table)); $defaults = (object) [ 'id' => 0, 'form_id' => $form_id ? (int) $form_id : (! empty($forms) ? (int) $forms[0]->id : 0), 'field_name' => '', 'rule_operator' => '=', 'compare_value' => '', 'action_type' => 'add_fixed_amount', 'product_id' => 0, 'amount' => '', 'percentage' => '', 'vat_rate' => null, 'quantity_field' => '', 'item_key' => '', 'description_text' => '', 'is_active' => 1, 'stop_after_match' => 0, 'sort_order' => 10, ]; $rule = $row ? $row : $defaults; $operators = [ '=' => '=', '!=' => '!=', '>' => '>', '<' => '<', '>=' => '>=', '<=' => '<=', 'contains' => 'contains', 'is_empty' => 'is_empty', 'is_not_empty' => 'is_not_empty', ]; // 1.9.15: acht acties in drie groepen. Alles wat de brug en de calculator // al konden, staat nu ook in de keuzelijst. "Product toevoegen" is terug // (1.9.11 haalde hem weg): een product dat van een voorwaarde als "groter // dan" afhangt, kan niet bij Basisprijs. Eén productactie in plaats van // twee: add_product en append_product rekenen hetzelfde. $op_offerte = __('Put on the quote', 'gresquote'); $actions = [ $op_offerte => [ 'add_product' => __('Add product', 'gresquote'), 'add_fixed_amount' => __('Fixed amount', 'gresquote'), 'add_amount_per_quantity' => __('Amount per quantity', 'gresquote'), 'info_only' => __('Text line (no amount)', 'gresquote'), ], __('Discount', 'gresquote') => [ 'add_percentage_discount' => __('Percentage discount', 'gresquote'), 'add_discount_amount' => __('Fixed discount', 'gresquote'), ], __('Surcharge', 'gresquote') => [ 'add_percentage_surcharge' => __('Percentage surcharge', 'gresquote'), 'add_fixed_surcharge' => __('Fixed surcharge', 'gresquote'), ], ]; // Oudere regels (o.a. uit templates) met append_product houden hun eigen optie. if ('append_product' === $rule->action_type) { $actions[$op_offerte] = ['append_product' => __('Add product', 'gresquote')] + $actions[$op_offerte]; } return [$rule, $forms, $products, $operators, $actions]; } /** Het regelformulier zelf (ALS ... DAN ...). */ private function render_rule_form($rule, $forms, $products, $operators, $actions, $in_wizard, $back_url) { ?>
  • ' . esc_html__('when', 'gresquote') . '')); ?>
  • ' . esc_html__('what', 'gresquote') . '')); ?>
  • ' . esc_html__('Quantity from field', 'gresquote') . '')); ?>
1.
2.
vat_rate) && null !== $rule->vat_rate && '' !== (string) $rule->vat_rate) ? (float) $rule->vat_rate : null; ?>
description_text) || (int) $rule->sort_order !== 10 || (int) $rule->is_active !== 1) ? ' open' : ''; ?>>
get_var($wpdb->prepare("SELECT id FROM %i WHERE code = %s LIMIT 1", $this->products_table, $code)); $data = [ 'name' => isset($product['name']) ? sanitize_text_field($product['name']) : $code, 'code' => $code, 'description_text' => isset($product['description']) ? sanitize_textarea_field($product['description']) : '', 'unit_label' => isset($product['unit_label']) ? sanitize_text_field($product['unit_label']) : __('piece', 'gresquote'), 'unit_price' => isset($product['unit_price']) ? $this->template_price_to_euros($product['unit_price']) : 0, 'vat_rate' => isset($product['vat_rate']) ? (float) $product['vat_rate'] : WPQUOTEPRO_Regio::standaard_tarief(), 'is_active' => 1, 'updated_at' => current_time('mysql'), ]; if ($existing_id) { $wpdb->update($this->products_table, $data, ['id' => $existing_id]); return $existing_id; } $data['created_at'] = current_time('mysql'); $wpdb->insert($this->products_table, $data); return (int) $wpdb->insert_id; } /** * Debug-tracing die alleen naar het errorlog gaat als WP_DEBUG aan staat — * anders (TP-22) belandt interne formulier-/regelopbouw onvoorwaardelijk * in het productielog. */ private function debug_log($message) { if (defined('WP_DEBUG') && WP_DEBUG) { wpquotepro_log($message); } } private function install_template_for_source($template_key, $source_type, $leeg_toegestaan = false) { global $wpdb; $template_key = $this->sanitize_wizard_template_key($template_key); $this->debug_log('[WPQuotePro] === START install_template_for_source: key=' . $template_key . ' source=' . $source_type); if ('blank' === $template_key && ! $leeg_toegestaan) { return new WP_Error('wpquotepro_blank_template', __('Blank templates are created manually.', 'gresquote')); } $template = $this->get_wizard_template($template_key); if ('blank' === $template_key) { // 1.9.13: leeg formulier, alleen de klantvelden. Naam is aan te passen in stap 1. $template['default_name'] = __('Quote form', 'gresquote'); $template['default_description'] = ''; } $this->debug_log('[WPQuotePro] template loaded: products=' . count((array) ($template['products'] ?? [])) . ' mappings=' . count((array) ($template['product_mappings'] ?? [])) . ' rules=' . count((array) ($template['rules'] ?? []))); $adapter = $this->registry->get_adapter($source_type); if (! $adapter || ! method_exists($adapter, 'can_create_form') || ! $adapter->can_create_form()) { return new WP_Error('wpquotepro_source_unavailable', __('This source is not available or cannot create forms.', 'gresquote')); } // Sinds 1.0.13 komen herhaalgroepen bij elke template mee, ook een gratis // (eerder alleen bij een betaalde, ontgrendelde template; WordPress.org // staat geen vergrendelde functies in de plugin toe). $external_form = $adapter->create_form_from_template($template); if (is_wp_error($external_form)) { return $external_form; } $now = current_time('mysql'); $wpdb->insert($this->forms_table, [ 'name' => sanitize_text_field($template['default_name']), 'slug' => sanitize_title($template['default_name']), 'source_type' => sanitize_text_field($source_type), 'external_form_id' => sanitize_text_field($external_form['id']), 'external_form_label' => sanitize_text_field($external_form['label']), 'description_text' => sanitize_textarea_field($template['default_description']), 'status' => 'draft', 'created_at' => $now, 'updated_at' => $now, ]); $form_id = (int) $wpdb->insert_id; if (! $form_id) { return new WP_Error('wpquotepro_flow_create_failed', __('GresQuote could not create the quote flow.', 'gresquote')); } update_option('wpquotepro_form_template_' . $form_id, $template_key, false); // Herhaalgroepen van deze template vastleggen bij het formulier: de // frontend-repeater en de submission-bridge lezen ze hiervandaan. $saved_groups = WPQUOTEPRO_Groups::save_for_form($form_id, isset($template['groups']) ? $template['groups'] : []); $this->debug_log('[WPQuotePro] groups saved: ' . count($saved_groups)); $customer_mapping_count = 0; foreach ((array) $template['fields'] as $field) { if (empty($field['customer_key'])) { $this->debug_log('[WPQuotePro] field skipped (no customer_key): name=' . ($field['name'] ?? '(none)') . ' has_customer_key=' . (isset($field['customer_key']) ? 'yes' : 'no')); continue; } $wpdb->insert($this->form_customer_mappings_table, [ 'form_id' => $form_id, 'customer_key' => sanitize_text_field($field['customer_key']), 'field_name' => sanitize_key($field['name']), 'created_at' => $now, 'updated_at' => $now, ]); $customer_mapping_count++; } $this->debug_log('[WPQuotePro] customer_mappings created: ' . $customer_mapping_count); $product_ids_by_code = []; $template_product_ids = []; foreach ((array) $template['products'] as $product) { $product_id = $this->ensure_template_product($product); $code = $product['code'] ?? '(no code)'; $this->debug_log('[WPQuotePro] product created: code=' . $code . ' id=' . $product_id . ' has_price=' . (isset($product['unit_price']) ? 'yes' : 'no')); if ($product_id && ! empty($product['code'])) { $product_ids_by_code[(string) $product['code']] = $product_id; $template_product_ids[] = $product_id; } } $this->debug_log('[WPQuotePro] products_by_code=' . count($product_ids_by_code) . ' total_template_product_ids=' . count($template_product_ids)); update_option('wpquotepro_template_products_' . $form_id, array_values(array_unique($template_product_ids)), false); $this->debug_log('[WPQuotePro] === PRODUCT MAPPINGS ==='); foreach ((array) $template['product_mappings'] as $idx => $mapping) { $code = isset($mapping['product_code']) ? (string) $mapping['product_code'] : ''; $product_id = isset($product_ids_by_code[$code]) ? (int) $product_ids_by_code[$code] : 0; $this->debug_log('[WPQuotePro] mapping[' . $idx . ']: code=' . $code . ' product_id=' . $product_id . ' field=' . ($mapping['field_name'] ?? '(none)') . ' compare=' . ($mapping['compare_value'] ?? '(none)')); if (! $product_id) { $this->debug_log('[WPQuotePro] -> SKIP (no product_id found)'); continue; } $result = $wpdb->insert($this->form_product_mappings_table, [ 'form_id' => $form_id, 'field_name' => sanitize_key($mapping['field_name']), 'compare_value' => isset($mapping['compare_value']) ? sanitize_text_field($mapping['compare_value']) : '', 'product_id' => $product_id, 'quantity_field' => isset($mapping['quantity_field']) ? sanitize_key($mapping['quantity_field']) : '', 'item_key' => 'item_' . sanitize_key(str_replace('-', '_', wp_generate_uuid4())), 'sort_order' => isset($mapping['sort_order']) ? absint($mapping['sort_order']) : 10, 'is_active' => 1, 'created_at' => $now, 'updated_at' => $now, ]); $this->debug_log('[WPQuotePro] -> INSERT result=' . ($result ? 'ok' : 'fail: ' . $wpdb->last_error)); } $this->debug_log('[WPQuotePro] === RULES ==='); foreach ((array) $template['rules'] as $idx => $rule) { $result = $wpdb->insert($this->rules_table, [ 'form_id' => $form_id, 'field_name' => isset($rule['field_name']) ? sanitize_key($rule['field_name']) : '', 'rule_operator' => isset($rule['rule_operator']) ? sanitize_text_field($rule['rule_operator']) : '=', 'compare_value' => isset($rule['compare_value']) ? sanitize_text_field($rule['compare_value']) : '', 'action_type' => isset($rule['action_type']) ? sanitize_text_field($rule['action_type']) : 'add_fixed_amount', 'product_id' => 0, // Zelfde omrekening als bij de producten hierboven: een bedrag in // een template staat in CENTEN, de regeltabel rekent in EURO'S. // Dit stond hier NIET, terwijl het bij de producten wel al // gerepareerd was -- drie regels hoger in dezelfde lus. Gevolg: // een demontagepost van 7500 (= EUR 75) installeerde als EUR 7.500 // per stuk, en de voorbeeldberekening in de plugin liet EUR 75 zien // omdat die server-side via buildPreviewRules gaat en de centen wel // goed leest. Preview en echte offerte verschilden dus een factor 100. 'amount' => isset($rule['amount']) ? $this->template_price_to_euros($rule['amount']) : 0, 'percentage' => isset($rule['percentage']) ? (float) $rule['percentage'] : 0, // Zonder tarief in de template volgt de regel de btw van de offerte. 'vat_rate' => isset($rule['vat_rate']) && is_numeric($rule['vat_rate']) ? (float) $rule['vat_rate'] : null, 'quantity_field' => isset($rule['quantity_field']) ? sanitize_key($rule['quantity_field']) : '', 'item_key' => 'item_' . sanitize_key(str_replace('-', '_', wp_generate_uuid4())), 'description_text' => isset($rule['description_text']) ? sanitize_text_field($rule['description_text']) : '', 'is_active' => 1, 'stop_after_match' => 0, 'sort_order' => isset($rule['sort_order']) ? absint($rule['sort_order']) : 10, 'created_at' => $now, 'updated_at' => $now, ]); $this->debug_log('[WPQuotePro] rule[' . $idx . ']: field=' . ($rule['field_name'] ?? '(none)') . ' operator=' . ($rule['rule_operator'] ?? '(none)') . ' compare=' . ($rule['compare_value'] ?? '(none)') . ' result=' . ($result ? 'ok' : 'fail')); } $this->debug_log('[WPQuotePro] === END install_template_for_source ==='); return $form_id; } private function get_template_products_for_form($form_id) { global $wpdb; $ids = get_option('wpquotepro_template_products_' . absint($form_id), []); if (! is_array($ids) || empty($ids)) { return []; } $ids = array_values(array_filter(array_map('absint', $ids))); if (empty($ids)) { return []; } $placeholders = implode(',', array_fill(0, count($ids), '%d')); return $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE id IN ($placeholders) ORDER BY name ASC", array_merge([$this->products_table], $ids))); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders bestaat alleen uit %d of %s, één per waarde } private function redirect_wizard($step, $form_id, $message = 'form_saved') { wp_safe_redirect(admin_url('admin.php?page=wpquotepro-wizard&step=' . (int) $step . '&form_id=' . (int) $form_id . '&message=' . sanitize_key($message))); exit; } private function get_template_demo_defaults($template_key) { $defaults = [ 'solar' => [ 'full_name' => 'Sanne de Vries', 'email' => 'sanne@example.nl', 'phone' => '06 12345678', 'postal_code' => '1181 AB', 'city' => 'Amstelveen', 'aantal_zonnepanelen' => 12, 'type_paneel' => 'Full black paneel', 'type_omvormer' => 'Stringomvormer', 'daktype' => 'Plat dak', 'afstand_meterkast' => 'Standaard', 'opmerkingen' => 'Graag levering binnen 6 weken.', ], 'heatpump' => [ 'full_name' => 'Jeroen Bakker', 'email' => 'jeroen@example.nl', 'phone' => '06 87654321', 'postal_code' => '2011 CD', 'city' => 'Haarlem', 'type_warmtepomp' => 'Hybride warmtepomp', 'installatiepakket' => 'Standaard installatie', 'woningtype' => 'Vrijstaand', 'extra_leidingwerk' => 'Ja', 'opmerkingen' => 'Cv-ketel hangt op zolder.', ], 'service' => [ 'full_name' => 'Emma Jansen', 'email' => 'emma@example.nl', 'phone' => '06 11223344', 'bedrijfsnaam' => 'Jansen & Co', 'pakket' => 'Plus', 'aantal_uren' => 12, 'spoed' => 'Ja', 'opmerkingen' => 'Graag starten komende maand.', ], ]; return isset($defaults[$template_key]) ? $defaults[$template_key] : []; } private function get_template_demo_price($product_code, $template_key) { $prices = [ 'solar' => [ 'SOL-PANEL-STD' => 245, 'SOL-PANEL-BLACK' => 285, 'SOL-PANEL-GLASS' => 335, 'SOL-INVERTER-STRING' => 1150, 'SOL-INVERTER-MICRO' => 1550, 'SOL-MOUNTING' => 38, ], 'heatpump' => [ 'HP-HYBRID' => 4200, 'HP-ELECTRIC' => 8350, 'HP-BOILER' => 2850, 'HP-INSTALL-STD' => 1250, 'HP-INSTALL-PLUS' => 1950, ], 'service' => [ 'SRV-BASIC' => 75, 'SRV-PLUS' => 95, 'SRV-PREMIUM' => 125, ], ]; return isset($prices[$template_key][$product_code]) ? (float) $prices[$template_key][$product_code] : 100; } private function get_template_demo_payload($template_key) { $template_key = $this->sanitize_wizard_template_key($template_key); $template = $this->get_wizard_template($template_key); $products = []; foreach ($template['products'] as $product) { $code = isset($product['code']) ? (string) $product['code'] : ''; $unit_price = isset($product['unit_price']) ? $this->template_price_to_euros($product['unit_price']) : 0; if ($unit_price <= 0) { // Terugval-richtprijzen staan al in euro's. $unit_price = $this->get_template_demo_price($code, $template_key); } $product['unit_price'] = $unit_price; $products[] = $product; } $template['products'] = $products; return [ 'key' => $template_key, 'template' => $template, 'defaults' => $this->get_template_demo_defaults($template_key), 'currency' => WPQUOTEPRO_Regio::huidig()['valuta'], ]; } public function render_template_demo_page() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } $template_key = isset($_GET['template']) ? $this->sanitize_wizard_template_key(sanitize_key(wp_unslash($_GET['template']))) : 'solar'; if ('blank' === $template_key) { $template_key = 'solar'; } $payload = $this->get_template_demo_payload($template_key); $template = $payload['template']; $install_url = admin_url('admin.php?page=wpquotepro-wizard&step=0'); // De repeater van het klantformulier hergebruiken in het voorbeeld: zelfde // blokken, zelfde controles, dus wat de ondernemer hier ziet is wat zijn // klant krijgt. $demo_group_config = []; foreach (WPQUOTEPRO_Groups::normalize_groups($template['groups'] ?? []) as $demo_group) { unset($demo_group['pricing']['product_code']); $demo_group_config[$demo_group['key']] = $demo_group; } if (! empty($demo_group_config)) { wp_enqueue_style('wpquotepro-groups', WPQUOTEPRO_PLUGIN_URL . 'assets/css/groups.css', [], WPQUOTEPRO_VERSION); wp_enqueue_script('wpquotepro-groups', WPQUOTEPRO_PLUGIN_URL . 'assets/js/groups.js', [], WPQUOTEPRO_VERSION, true); wp_localize_script('wpquotepro-groups', 'WPQuoteProGroups', [ 'groups' => $demo_group_config, 'payloadPrefix' => WPQUOTEPRO_Groups::PAYLOAD_PREFIX, 'summarySuffix' => WPQUOTEPRO_Groups::SUMMARY_SUFFIX, 'i18n' => [ 'add' => __('Add item', 'gresquote'), 'remove' => __('Remove', 'gresquote'), 'maxReached' => __('Maximum number reached.', 'gresquote'), 'required' => __('This field is required.', 'gresquote'), 'dimensionInCm' => __('Enter the size in centimetres (for example 145 for 1.45 m).', 'gresquote'), 'dimensionInvalid' => __('Enter a valid size.', 'gresquote'), ], ]); } ?>
GresQuote ›

add_query_arg(WPQUOTEPRO_Regio::catalogus_query(), WPQUOTEPRO_FORM_TEMPLATES_PREVIEW_ENDPOINT), 'currency' => WPQUOTEPRO_Regio::huidig()['valuta'], // Alleen templates die van de backend komen hebben daar een slug; die // kent de server zelf. Templates uit de map templates/ van deze // installatie sturen hun eigen definitie mee. 'slug' => isset($template['counts']) ? $template_key : '', 'locale' => str_replace('_', '-', get_user_locale()), 'i18n' => [ 'noLines' => __('No quote lines yet. Choose products in the form.', 'gresquote'), 'activeRules' => __('Active rules', 'gresquote'), 'rulesHidden' => __('You will see the prices per line and the pricing rules in your own form after installing.', 'gresquote'), 'noRules' => __('No extra rules active.', 'gresquote'), 'explainEmpty' => __('Make a choice in the form to see how the price is built up.', 'gresquote'), 'failed' => __('The example could not be calculated. Check the connection with GresQuote.', 'gresquote'), ], ]) . ';', 'before'); ?> sanitize_wizard_template_key(sanitize_key(wp_unslash($_POST['template_key']))) : 'blank'; $source_type = isset($_POST['source_type']) ? sanitize_text_field(wp_unslash($_POST['source_type'])) : ''; $source_options = $this->get_creatable_source_options(); if ('' === $source_type && 1 === count($source_options)) { $keys = array_keys($source_options); $source_type = $keys[0]; } if ('blank' === $template_key) { wp_safe_redirect(admin_url('admin.php?page=wpquotepro-wizard&step=1&template=blank')); exit; } // Alleen templates die de kiezer toont: gratis, of van deze installatie. if (! $this->is_template_free($template_key) && ! $this->is_eigen_template($template_key)) { wp_safe_redirect(admin_url('admin.php?page=wpquotepro-wizard&step=0&message=template_unavailable')); exit; } if (empty($source_type) || ! isset($source_options[$source_type])) { wp_safe_redirect(admin_url('admin.php?page=wpquotepro-wizard&step=0&message=template_source_missing')); exit; } $form_id = $this->install_template_for_source($template_key, $source_type); if (is_wp_error($form_id) || ! $form_id) { wp_safe_redirect(admin_url('admin.php?page=wpquotepro-wizard&step=0&message=template_install_failed')); exit; } // Gebruik registreren bij de backend (telt gratis templates; betaald is al // geteld bij aankoop). Best-effort, blokkeert de installatie niet. $this->track_form_template_use($template_key); wp_safe_redirect(admin_url('admin.php?page=wpquotepro-wizard&step=2&form_id=' . (int) $form_id . '&message=template_installed')); exit; } private function track_form_template_use($template_key) { $template_key = sanitize_key((string) $template_key); if ('' === $template_key || 'blank' === $template_key) { return; } $api_key = $this->get_configured_api_key(); if (empty($api_key) || ! defined('WPQUOTEPRO_FORM_TEMPLATES_USE_ENDPOINT') || ! WPQUOTEPRO_FORM_TEMPLATES_USE_ENDPOINT) { return; } wp_remote_post(WPQUOTEPRO_FORM_TEMPLATES_USE_ENDPOINT, [ 'timeout' => 8, 'blocking' => false, 'headers' => ['Content-Type' => 'application/json', 'Apikey' => $api_key, 'Authorization' => 'Bearer ' . $api_key], 'body' => wp_json_encode(['slug' => $template_key, 'site_url' => home_url('/')]), ]); } public function handle_wizard_save_step1() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_wizard_save_step1'); global $wpdb; $form_id = isset($_POST['form_id']) ? absint($_POST['form_id']) : 0; $name = isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : ''; $slug = isset($_POST['slug']) ? sanitize_title(wp_unslash($_POST['slug'])) : ''; $source_type = isset($_POST['source_type']) ? sanitize_text_field(wp_unslash($_POST['source_type'])) : $this->get_default_wizard_source_type(); $external_form_id = isset($_POST['external_form_id']) ? sanitize_text_field(wp_unslash($_POST['external_form_id'])) : ''; $description_text = isset($_POST['description_text']) ? sanitize_textarea_field(wp_unslash($_POST['description_text'])) : ''; $template_key = isset($_POST['template_key']) ? $this->sanitize_wizard_template_key(sanitize_key(wp_unslash($_POST['template_key']))) : ($form_id ? $this->get_form_template_key($form_id) : 'blank'); $bestaande_bron = $form_id ? (string) $wpdb->get_var($wpdb->prepare("SELECT source_type FROM %i WHERE id = %d", $this->forms_table, $form_id)) : ''; $allowed_sources = array_keys($this->get_wizard_source_options($bestaande_bron)); if ('internal' === $source_type || ! in_array($source_type, $allowed_sources, true)) { $source_type = $this->get_default_wizard_source_type(); } if (empty($name) || empty($external_form_id)) { $this->redirect_wizard(1, $form_id, 'saved_error'); } if (empty($slug)) { $slug = sanitize_title($name); } $external_label = ''; if (! empty($external_form_id)) { foreach ($this->registry->get_external_form_choices($source_type) as $choice) { if ((string) $choice['id'] === (string) $external_form_id) { $external_label = $choice['label']; break; } } } $data = [ 'name' => $name, 'slug' => $slug, 'source_type' => $source_type, 'external_form_id' => $external_form_id, 'external_form_label' => $external_label, 'description_text' => $description_text, 'status' => 'draft', 'updated_at' => current_time('mysql'), ]; if ($form_id) { $wpdb->update($this->forms_table, $data, ['id' => $form_id]); } else { $data['created_at'] = current_time('mysql'); $wpdb->insert($this->forms_table, $data); $form_id = (int) $wpdb->insert_id; } if ($form_id) { update_option('wpquotepro_form_template_' . (int) $form_id, $template_key, false); } // 1.9.13: klantgegevens staan op dezelfde pagina (was stap 2). if ($form_id && isset($_POST['customer_mapping']) && is_array($_POST['customer_mapping'])) { $this->save_customer_mapping($form_id, map_deep((array) wp_unslash($_POST['customer_mapping']), 'sanitize_key')); } $state = $this->get_wizard_state($form_id, false); $this->redirect_wizard(! empty($state['step_valid'][1]) ? 2 : 1, $form_id, ! empty($state['step_valid'][1]) ? 'form_saved' : 'wizard_customer_missing'); } private function save_customer_mapping($form_id, array $customer_mapping) { global $wpdb; $wpdb->delete($this->form_customer_mappings_table, ['form_id' => (int) $form_id]); foreach (array_keys($this->get_customer_mapping_fields()) as $customer_key) { $field_name = isset($customer_mapping[$customer_key]) ? sanitize_key($customer_mapping[$customer_key]) : ''; if ('' === $field_name) { continue; } $wpdb->insert($this->form_customer_mappings_table, [ 'form_id' => (int) $form_id, 'customer_key' => $customer_key, 'field_name' => $field_name, 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql'), ]); } } /** * 1.9.13: "Met een leeg formulier". Maakt in de gekozen formulierplugin een * formulier met naam, e-mail, telefoon, postcode en plaats, en koppelt die * meteen als klantgegevens. Zelfde route als een template. */ public function handle_wizard_create_blank_form() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_wizard_create_blank_form'); $source_type = isset($_POST['source_type']) ? sanitize_text_field(wp_unslash($_POST['source_type'])) : ''; $source_options = $this->get_creatable_source_options(); if ('' === $source_type && 1 === count($source_options)) { $keys = array_keys($source_options); $source_type = $keys[0]; } if (! isset($source_options[$source_type])) { wp_safe_redirect(admin_url('admin.php?page=wpquotepro-wizard&step=0&message=template_source_missing')); exit; } $form_id = $this->install_template_for_source('blank', $source_type, true); if (is_wp_error($form_id) || ! $form_id) { wp_safe_redirect(admin_url('admin.php?page=wpquotepro-wizard&step=0&message=template_install_failed')); exit; } $this->redirect_wizard(1, $form_id, 'blank_form_created'); } /** * 1.9.13: product aanmaken vanuit de stap Prijs, zonder de wizard te verlaten. * Antwoordt met het nieuwe product, zodat het meteen in de keuzelijsten staat. */ public function ajax_wizard_create_product() { if (! current_user_can('manage_options')) { wp_send_json_error(['message' => __('No access.', 'gresquote')], 403); } check_ajax_referer('wpquotepro_wizard_create_product', 'nonce'); global $wpdb; $name = isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : ''; $unit_label = isset($_POST['unit_label']) ? sanitize_text_field(wp_unslash($_POST['unit_label'])) : ''; $unit_price = isset($_POST['unit_price']) ? (float) str_replace(',', '.', sanitize_text_field(wp_unslash($_POST['unit_price']))) : 0; $vat_rate = isset($_POST['vat_rate']) ? (float) str_replace(',', '.', sanitize_text_field(wp_unslash($_POST['vat_rate']))) : WPQUOTEPRO_Regio::standaard_tarief(); if ('' === $name) { wp_send_json_error(['message' => __('Give the product a name.', 'gresquote')], 400); } $now = current_time('mysql'); $ok = $wpdb->insert($this->products_table, [ 'name' => $name, 'code' => '', 'description_text' => '', 'unit_label' => '' !== $unit_label ? $unit_label : __('piece', 'gresquote'), 'unit_price' => max(0, $unit_price), 'vat_rate' => max(0, $vat_rate), 'wc_product_id' => 0, 'is_active' => 1, 'created_at' => $now, 'updated_at' => $now, ]); if (! $ok) { wp_send_json_error(['message' => __('The product could not be saved.', 'gresquote')], 500); } wp_send_json_success([ 'id' => (int) $wpdb->insert_id, 'label' => $name, 'price' => WPQUOTEPRO_Regio::bedrag(max(0, $unit_price)), ]); } public function handle_wizard_save_step3() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_wizard_save_step3'); global $wpdb; $form_id = isset($_POST['form_id']) ? absint($_POST['form_id']) : 0; if (! $form_id) { $this->redirect_wizard(1, 0, 'saved_error'); } $template_product_prices = isset($_POST['template_product_price']) && is_array($_POST['template_product_price']) ? map_deep((array) wp_unslash($_POST['template_product_price']), 'sanitize_text_field') : []; $template_product_vats = isset($_POST['template_product_vat']) && is_array($_POST['template_product_vat']) ? map_deep((array) wp_unslash($_POST['template_product_vat']), 'sanitize_text_field') : []; if (! empty($template_product_prices) || ! empty($template_product_vats)) { $allowed_product_ids = array_map(function($row) { return (int) $row->id; }, $this->get_template_products_for_form($form_id)); foreach ($allowed_product_ids as $product_id) { $price = isset($template_product_prices[$product_id]) ? (float) str_replace(',', '.', $template_product_prices[$product_id]) : null; $vat = isset($template_product_vats[$product_id]) ? (float) str_replace(',', '.', $template_product_vats[$product_id]) : null; $update = ['updated_at' => current_time('mysql')]; if (null !== $price) { $update['unit_price'] = max(0, $price); } if (null !== $vat) { $update['vat_rate'] = max(0, $vat); } $wpdb->update($this->products_table, $update, ['id' => $product_id]); } } $wpdb->delete($this->form_product_mappings_table, ['form_id' => $form_id]); $mapping_field_names = isset($_POST['mapping_field_name']) ? map_deep((array) wp_unslash($_POST['mapping_field_name']), 'sanitize_key') : []; $mapping_compare_values = isset($_POST['mapping_compare_value']) ? map_deep((array) wp_unslash($_POST['mapping_compare_value']), 'sanitize_text_field') : []; $mapping_product_ids = isset($_POST['mapping_product_id']) ? map_deep((array) wp_unslash($_POST['mapping_product_id']), 'absint') : []; $mapping_quantity_fields = isset($_POST['mapping_quantity_field']) ? map_deep((array) wp_unslash($_POST['mapping_quantity_field']), 'sanitize_key') : []; $mapping_item_keys = isset($_POST['mapping_item_key']) ? map_deep((array) wp_unslash($_POST['mapping_item_key']), 'sanitize_text_field') : []; $mapping_sort_orders = isset($_POST['mapping_sort_order']) ? map_deep((array) wp_unslash($_POST['mapping_sort_order']), 'absint') : []; $mapping_is_active = isset($_POST['mapping_is_active']) ? map_deep((array) wp_unslash($_POST['mapping_is_active']), 'sanitize_text_field') : []; $row_count = max(count($mapping_field_names), count($mapping_compare_values), count($mapping_product_ids), count($mapping_quantity_fields), count($mapping_item_keys), count($mapping_sort_orders)); for ($i = 0; $i < $row_count; $i++) { $field_name = isset($mapping_field_names[$i]) ? sanitize_key($mapping_field_names[$i]) : ''; $compare_value = isset($mapping_compare_values[$i]) ? sanitize_text_field($mapping_compare_values[$i]) : ''; $product_id = isset($mapping_product_ids[$i]) ? absint($mapping_product_ids[$i]) : 0; $quantity_field = isset($mapping_quantity_fields[$i]) ? sanitize_key($mapping_quantity_fields[$i]) : ''; $item_key = isset($mapping_item_keys[$i]) ? sanitize_text_field($mapping_item_keys[$i]) : ''; if ('' === $item_key) { $item_key = wp_generate_uuid4(); } $sort_order = isset($mapping_sort_orders[$i]) ? absint($mapping_sort_orders[$i]) : 10; $is_active_map = ! empty($mapping_is_active[$i]) ? 1 : 0; if ('' === $field_name || ! $product_id) { continue; } $wpdb->insert($this->form_product_mappings_table, [ 'form_id' => $form_id, 'field_name' => $field_name, 'compare_value' => $compare_value, 'product_id' => $product_id, 'quantity_field' => $quantity_field, 'item_key' => $item_key, 'sort_order' => $sort_order, 'is_active' => $is_active_map, 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql'), ]); } // 1.9.13: een klik op een regel (nieuw/bewerken) slaat eerst de // koppelingen op en gaat daarna naar dat regelpaneel in dezelfde stap. $doel = isset($_POST['wpqp_redirect']) ? esc_url_raw(wp_unslash($_POST['wpqp_redirect'])) : ''; if ('' !== $doel && 0 === strpos($doel, admin_url('admin.php?page=wpquotepro-wizard'))) { wp_safe_redirect($doel); exit; } $state = $this->get_wizard_state($form_id, false); $this->redirect_wizard(! empty($state['step_valid'][2]) ? 3 : 2, $form_id, ! empty($state['step_valid'][2]) ? 'form_saved' : 'wizard_product_missing'); } public function handle_wizard_activate() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_wizard_activate'); global $wpdb; $form_id = isset($_POST['form_id']) ? absint($_POST['form_id']) : 0; // 1.9.31: live gaan kan pas als de website gekoppeld is (zie de stap // Testen & live). De knop staat dan uit; dit vangt een nagemaakt verzoek. if (! $this->is_gekoppeld()) { $this->redirect_wizard(3, $form_id, 'koppel_eerst'); } $state = $this->get_wizard_state($form_id, false); if ($form_id && ! empty($state['step_valid'][1]) && ! empty($state['step_valid'][2])) { $wpdb->update($this->forms_table, ['status' => 'active', 'updated_at' => current_time('mysql')], ['id' => $form_id]); $this->redirect_wizard(3, $form_id, 'form_saved'); } $this->redirect_wizard(3, $form_id, 'saved_error'); } public function handle_wizard_set_test_mode() { 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_wizard_set_test_mode_' . $form_id); if (! $form_id) { $this->redirect_wizard(3, $form_id, 'saved_error'); } global $wpdb; $updated = $wpdb->update( $this->forms_table, [ 'status' => 'draft', 'updated_at' => current_time('mysql'), ], ['id' => $form_id] ); $this->redirect_wizard(3, $form_id, false !== $updated ? 'test_mode_enabled' : 'saved_error'); } public function handle_save_form() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_save_form'); global $wpdb; $form_id = isset($_POST['form_id']) ? absint($_POST['form_id']) : 0; $name = isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : ''; $slug = isset($_POST['slug']) ? sanitize_title(wp_unslash($_POST['slug'])) : ''; $source_type = isset($_POST['source_type']) ? sanitize_text_field(wp_unslash($_POST['source_type'])) : 'internal'; $external_form_id = isset($_POST['external_form_id']) ? sanitize_text_field(wp_unslash($_POST['external_form_id'])) : ''; $description_text = isset($_POST['description_text']) ? sanitize_textarea_field(wp_unslash($_POST['description_text'])) : ''; $status = isset($_POST['status']) ? $this->sanitize_form_status(sanitize_key(wp_unslash($_POST['status']))) : 'draft'; // 1.9.31: ook hier niet live zonder koppeling. Een formulier dat al // actief was, blijft dat; de backend beslist dan of de site bekend is. if ('active' === $status && ! $this->is_gekoppeld()) { $was = $form_id ? (string) $wpdb->get_var($wpdb->prepare("SELECT status FROM %i WHERE id = %d", $this->forms_table, $form_id)) : ''; if ('active' !== $was) { $status = 'draft'; } } if (empty($name)) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-forms', 'saved_error')); exit; } if (empty($slug)) { $slug = sanitize_title($name); } $external_label = ''; if (! empty($external_form_id)) { foreach ($this->registry->get_external_form_choices($source_type) as $choice) { if ((string) $choice['id'] === (string) $external_form_id) { $external_label = $choice['label']; break; } } } $data = [ 'name' => $name, 'slug' => $slug, 'source_type' => $source_type, 'external_form_id' => $external_form_id, 'external_form_label' => $external_label, 'description_text' => $description_text, 'status' => $status, 'updated_at' => current_time('mysql'), ]; if ($form_id) { $wpdb->update($this->forms_table, $data, ['id' => $form_id]); } else { $data['created_at'] = current_time('mysql'); $wpdb->insert($this->forms_table, $data); $form_id = (int) $wpdb->insert_id; } if ($form_id) { $wpdb->delete($this->form_product_mappings_table, ['form_id' => $form_id]); $mapping_field_names = isset($_POST['mapping_field_name']) ? map_deep((array) wp_unslash($_POST['mapping_field_name']), 'sanitize_key') : []; $mapping_compare_values = isset($_POST['mapping_compare_value']) ? map_deep((array) wp_unslash($_POST['mapping_compare_value']), 'sanitize_text_field') : []; $mapping_product_ids = isset($_POST['mapping_product_id']) ? map_deep((array) wp_unslash($_POST['mapping_product_id']), 'absint') : []; $mapping_quantity_fields = isset($_POST['mapping_quantity_field']) ? map_deep((array) wp_unslash($_POST['mapping_quantity_field']), 'sanitize_key') : []; $mapping_item_keys = isset($_POST['mapping_item_key']) ? map_deep((array) wp_unslash($_POST['mapping_item_key']), 'sanitize_text_field') : []; $mapping_sort_orders = isset($_POST['mapping_sort_order']) ? map_deep((array) wp_unslash($_POST['mapping_sort_order']), 'absint') : []; $mapping_is_active = isset($_POST['mapping_is_active']) ? map_deep((array) wp_unslash($_POST['mapping_is_active']), 'sanitize_text_field') : []; $row_count = max(count($mapping_field_names), count($mapping_compare_values), count($mapping_product_ids), count($mapping_quantity_fields), count($mapping_item_keys), count($mapping_sort_orders)); for ($i = 0; $i < $row_count; $i++) { $field_name = isset($mapping_field_names[$i]) ? sanitize_key($mapping_field_names[$i]) : ''; $compare_value = isset($mapping_compare_values[$i]) ? sanitize_text_field($mapping_compare_values[$i]) : ''; $product_id = isset($mapping_product_ids[$i]) ? absint($mapping_product_ids[$i]) : 0; $quantity_field = isset($mapping_quantity_fields[$i]) ? sanitize_key($mapping_quantity_fields[$i]) : ''; $item_key = isset($mapping_item_keys[$i]) ? sanitize_text_field($mapping_item_keys[$i]) : ''; if ('' === $item_key) { $item_key = wp_generate_uuid4(); } $sort_order = isset($mapping_sort_orders[$i]) ? absint($mapping_sort_orders[$i]) : 10; $is_active_map = ! empty($mapping_is_active[$i]) ? 1 : 0; if ('' === $field_name || ! $product_id) { continue; } $wpdb->insert( $this->form_product_mappings_table, [ 'form_id' => $form_id, 'field_name' => $field_name, 'compare_value' => $compare_value, 'product_id' => $product_id, 'quantity_field' => $quantity_field, 'item_key' => $item_key, 'sort_order' => $sort_order, 'is_active' => $is_active_map, 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql'), ] ); } } if ($form_id) { $wpdb->delete($this->form_customer_mappings_table, ['form_id' => $form_id]); $customer_mapping = isset($_POST['customer_mapping']) && is_array($_POST['customer_mapping']) ? map_deep((array) wp_unslash($_POST['customer_mapping']), 'sanitize_key') : []; $allowed_customer_keys = array_keys($this->get_customer_mapping_fields()); foreach ($allowed_customer_keys as $customer_key) { $field_name = isset($customer_mapping[$customer_key]) ? sanitize_key($customer_mapping[$customer_key]) : ''; if ('' === $field_name) { continue; } $wpdb->insert( $this->form_customer_mappings_table, [ 'form_id' => $form_id, 'customer_key' => $customer_key, 'field_name' => $field_name, 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql'), ] ); } } wp_safe_redirect($this->admin_url_with_message('wpquotepro-forms', 'form_saved')); exit; } public function handle_delete_form() { 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_delete_form_' . $form_id); global $wpdb; if ($form_id) { $wpdb->delete($this->forms_table, ['id' => $form_id]); $wpdb->delete($this->form_product_mappings_table, ['form_id' => $form_id]); $wpdb->delete($this->form_customer_mappings_table, ['form_id' => $form_id]); } wp_safe_redirect($this->admin_url_with_message('wpquotepro-forms', 'form_deleted')); exit; } public function handle_save_product() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_save_product'); global $wpdb; $product_id = isset($_POST['product_id']) ? absint($_POST['product_id']) : 0; $name = isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : ''; $code = isset($_POST['code']) ? sanitize_text_field(wp_unslash($_POST['code'])) : ''; $description_text = isset($_POST['description_text']) ? sanitize_textarea_field(wp_unslash($_POST['description_text'])) : ''; $unit_label = isset($_POST['unit_label']) ? sanitize_text_field(wp_unslash($_POST['unit_label'])) : __('piece', 'gresquote'); $unit_price = isset($_POST['unit_price']) ? (float) sanitize_text_field(wp_unslash($_POST['unit_price'])) : 0; $vat_rate = isset($_POST['vat_rate']) ? (float) sanitize_text_field(wp_unslash($_POST['vat_rate'])) : WPQUOTEPRO_Regio::standaard_tarief(); $is_active = isset($_POST['is_active']) ? 1 : 0; $wc_product_id = isset($_POST['wc_product_id']) ? absint(wp_unslash($_POST['wc_product_id'])) : 0; if (empty($name)) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-products', 'saved_error')); exit; } $data = [ 'name' => $name, 'code' => $code, 'description_text' => $description_text, 'unit_label' => $unit_label, 'unit_price' => $unit_price, 'vat_rate' => $vat_rate, 'wc_product_id' => $wc_product_id, 'is_active' => $is_active, 'updated_at' => current_time('mysql'), ]; // Opnieuw koppelen betekent: de oude klacht hoort weg. Blijft die staan, // dan waarschuwt de productenpagina over iets dat allang opgelost is. if ($wc_product_id && class_exists('WPQUOTEPRO_WooCommerce')) { WPQUOTEPRO_WooCommerce::clear_broken_link($wc_product_id); } if ($product_id) { $wpdb->update($this->products_table, $data, ['id' => $product_id]); } else { $data['created_at'] = current_time('mysql'); $wpdb->insert($this->products_table, $data); } wp_safe_redirect($this->admin_url_with_message('wpquotepro-products', 'product_saved')); exit; } public function handle_delete_product() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } $product_id = isset($_GET['product_id']) ? absint($_GET['product_id']) : 0; check_admin_referer('wpquotepro_delete_product_' . $product_id); global $wpdb; if ($product_id) { $wpdb->delete($this->products_table, ['id' => $product_id]); } wp_safe_redirect($this->admin_url_with_message('wpquotepro-products', 'product_deleted')); exit; } public function handle_toggle_product() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } $product_id = isset($_GET['product_id']) ? absint($_GET['product_id']) : 0; check_admin_referer('wpquotepro_toggle_product_' . $product_id); global $wpdb; $product = $product_id ? $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->products_table, $product_id)) : null; if ($product) { $wpdb->update( $this->products_table, [ 'is_active' => (int) $product->is_active === 1 ? 0 : 1, 'updated_at' => current_time('mysql'), ], ['id' => $product_id] ); } wp_safe_redirect($this->admin_url_with_message('wpquotepro-products', 'product_toggled')); exit; } public function handle_save_rule() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_save_rule'); global $wpdb; $rule_id = isset($_POST['rule_id']) ? absint($_POST['rule_id']) : 0; $form_id = isset($_POST['form_id']) ? absint($_POST['form_id']) : 0; $field_name = isset($_POST['field_name']) ? sanitize_key(wp_unslash($_POST['field_name'])) : ''; $rule_operator = isset($_POST['rule_operator']) ? sanitize_text_field(wp_unslash($_POST['rule_operator'])) : '='; $compare_value = isset($_POST['compare_value']) ? sanitize_text_field(wp_unslash($_POST['compare_value'])) : ''; $action_type = isset($_POST['action_type']) ? sanitize_text_field(wp_unslash($_POST['action_type'])) : 'add_product'; $product_id = isset($_POST['product_id']) ? absint($_POST['product_id']) : 0; $amount = isset($_POST['amount']) ? (float) sanitize_text_field(wp_unslash($_POST['amount'])) : 0; $percentage = isset($_POST['percentage']) ? (float) sanitize_text_field(wp_unslash($_POST['percentage'])) : 0; // 1.9.16: '' = zelfde als de offerte (NULL), anders het gekozen tarief. $vat_keuze = isset($_POST['vat_rate']) ? trim(sanitize_text_field(wp_unslash($_POST['vat_rate']))) : ''; $vat_rate = ('' === $vat_keuze || ! is_numeric($vat_keuze)) ? null : max(0, min(100, (float) $vat_keuze)); // Alleen bij bedragen zonder product; anders zou een verborgen keuze het // tarief van een product of de verdeling van een korting overschrijven. if (! in_array($action_type, ['add_fixed_amount', 'add_amount_per_quantity', 'add_fixed_surcharge'], true)) { $vat_rate = null; } $quantity_field = isset($_POST['quantity_field']) ? sanitize_key(wp_unslash($_POST['quantity_field'])) : ''; $item_key = isset($_POST['item_key']) ? sanitize_text_field(wp_unslash($_POST['item_key'])) : ''; $description_text = isset($_POST['description_text']) ? sanitize_text_field(wp_unslash($_POST['description_text'])) : ''; $is_active = isset($_POST['is_active']) ? 1 : 0; $sort_order = isset($_POST['sort_order']) ? absint($_POST['sort_order']) : 10; $from_wizard = ! empty($_POST['wizard']); if (! $form_id || empty($field_name)) { wp_safe_redirect($this->rule_return_url($from_wizard, $form_id, 'saved_error')); exit; } if ('' === $item_key) { $item_key = $this->find_existing_item_key_for_rule($form_id, $field_name, $product_id, $quantity_field, $compare_value); } if ('' === $item_key) { $item_key = $this->build_generated_item_key($form_id, $field_name, $quantity_field, $compare_value); } $data = [ 'form_id' => $form_id, 'field_name' => $field_name, 'rule_operator' => $rule_operator, 'compare_value' => $compare_value, 'action_type' => $action_type, 'product_id' => $product_id, 'amount' => $amount, 'percentage' => $percentage, 'vat_rate' => $vat_rate, 'quantity_field' => $quantity_field, 'item_key' => $item_key, 'description_text' => $description_text, 'is_active' => $is_active, // TP-35: altijd 0. De kolom blijft bestaan, het vinkje niet meer. 'stop_after_match' => 0, 'sort_order' => $sort_order, 'updated_at' => current_time('mysql'), ]; if ($rule_id) { $wpdb->update($this->rules_table, $data, ['id' => $rule_id]); } else { $data['created_at'] = current_time('mysql'); $wpdb->insert($this->rules_table, $data); } wp_safe_redirect($this->rule_return_url($from_wizard, $form_id, 'rule_saved')); exit; } public function handle_delete_rule() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } $rule_id = isset($_GET['rule_id']) ? absint($_GET['rule_id']) : 0; check_admin_referer('wpquotepro_delete_rule_' . $rule_id); global $wpdb; $rule = $rule_id ? $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->rules_table, $rule_id)) : null; if ($rule) { $wpdb->delete($this->rules_table, ['id' => $rule_id]); } $form_id = $rule ? (int) $rule->form_id : 0; wp_safe_redirect($this->rule_return_url(! empty($_GET['wizard']), $form_id, 'rule_deleted')); exit; } public function handle_toggle_rule() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } $rule_id = isset($_GET['rule_id']) ? absint($_GET['rule_id']) : 0; check_admin_referer('wpquotepro_toggle_rule_' . $rule_id); global $wpdb; $rule = $rule_id ? $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", $this->rules_table, $rule_id)) : null; if ($rule) { $wpdb->update( $this->rules_table, [ 'is_active' => (int) $rule->is_active === 1 ? 0 : 1, 'updated_at' => current_time('mysql'), ], ['id' => $rule_id] ); } $form_id = $rule ? (int) $rule->form_id : 0; wp_safe_redirect($this->rule_return_url(! empty($_GET['wizard']), $form_id, 'rule_toggled')); exit; } private function find_existing_item_key_for_rule($form_id, $field_name, $product_id, $quantity_field, $compare_value) { global $wpdb; $item_key = ''; if ($form_id > 0 && $product_id > 0) { $item_key = (string) $wpdb->get_var( $wpdb->prepare( "SELECT item_key FROM %i WHERE form_id = %d AND product_id = %d AND field_name = %s AND quantity_field = %s ORDER BY id ASC LIMIT 1", $this->form_product_mappings_table, $form_id, $product_id, $field_name, $quantity_field ) ); } if ('' === $item_key && $form_id > 0) { $item_key = (string) $wpdb->get_var( $wpdb->prepare( "SELECT item_key FROM %i WHERE form_id = %d AND field_name = %s AND compare_value = %s ORDER BY id ASC LIMIT 1", $this->form_product_mappings_table, $form_id, $field_name, $compare_value ) ); } return sanitize_text_field($item_key); } private function build_generated_item_key($form_id, $field_name, $quantity_field = '', $compare_value = '') { $form_id = absint($form_id); $field_name = sanitize_key((string) $field_name); $quantity_field = sanitize_key((string) $quantity_field); $compare_value = sanitize_text_field((string) $compare_value); $hash_source = implode('|', [ 'wpquotepro-item', $form_id, $field_name, $quantity_field, strtolower(trim($compare_value)), ]); $hash = md5($hash_source); return sprintf('%s-%s-%s-%s-%s', substr($hash, 0, 8), substr($hash, 8, 4), substr($hash, 12, 4), substr($hash, 16, 4), substr($hash, 20, 12)); } public function handle_register_site() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_register_site'); // Toestemming: de backend legt privacy_agreed_at vast bij de registratie. if (empty($_POST['accept_terms'])) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'register_terms_required')); exit; } $company_name = isset($_POST['company_name']) ? sanitize_text_field(wp_unslash($_POST['company_name'])) : (string) get_option('wpquotepro_company_name', get_bloginfo('name')); $company_email = isset($_POST['company_email']) ? sanitize_email(wp_unslash($_POST['company_email'])) : (string) get_option('wpquotepro_company_email', get_option('admin_email', '')); if (empty($company_name)) { $company_name = get_bloginfo('name'); } if (empty($company_email) || ! is_email($company_email)) { $company_email = get_option('admin_email', ''); } $payload = $this->build_tenant_register_payload($company_name, $company_email); // BE-25: zie handle_connect_account(). $payload['site_proof'] = WPQUOTEPRO_Site_Proof::create(); $registered_via_backend = false; if (defined('WPQUOTEPRO_REGISTER_ENDPOINT') && WPQUOTEPRO_REGISTER_ENDPOINT) { $response = wp_remote_post(WPQUOTEPRO_REGISTER_ENDPOINT, [ 'timeout' => 30, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode($payload), ]); if (! is_wp_error($response)) { $code = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); // Er bestaat al een account voor deze site of dit e-mailadres. Viel // dit vroeger door naar de lokale trial hieronder, dan zag de // ondernemer "nog niet gekoppeld" zonder te weten waarom, en bleef // hij opnieuw registreren tegen dezelfde muur. De weg is koppelen. if (403 === $code && is_array($body) && isset($body['error']['code']) && 'SITE_NOT_VERIFIED' === $body['error']['code']) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'site_proof_failed')); exit; } if (409 === $code) { $error_code = is_array($body) && isset($body['error']['code']) ? (string) $body['error']['code'] : ''; wp_safe_redirect($this->admin_url_with_message( 'wpquotepro-settings', 'EMAIL_ALREADY_REGISTERED' === $error_code ? 'register_email_taken' : 'register_site_taken' )); exit; } if ($code >= 200 && $code < 300 && is_array($body)) { $data = isset($body['data']) && is_array($body['data']) ? $body['data'] : []; $tenant = isset($data['tenant']) && is_array($data['tenant']) ? $data['tenant'] : []; $subscription = isset($data['subscription']) && is_array($data['subscription']) ? $data['subscription'] : []; $usage = isset($data['usage']) && is_array($data['usage']) ? $data['usage'] : []; $api_number = (string) ($tenant['api_number'] ?? ($body['api_number'] ?? ($body['api_key'] ?? ''))); if ('' !== $api_number) { update_option('wpquotepro_api_key', sanitize_text_field($api_number), false); update_option('wpquotepro_api_number', sanitize_text_field($api_number), false); update_option('wpquotepro_tenant_id', sanitize_text_field((string) ($tenant['tenant_id'] ?? ($body['tenant_id'] ?? ''))), false); update_option('wpquotepro_plan', sanitize_key((string) ($tenant['plan'] ?? ($subscription['plan'] ?? ($body['plan'] ?? 'trial')))), false); update_option('wpquotepro_license_status', sanitize_key((string) ($tenant['status'] ?? ($subscription['status'] ?? ($body['status'] ?? 'trial')))), false); update_option('wpquotepro_trial_quote_limit', absint($usage['trial_limit'] ?? ($body['trial_quote_limit'] ?? 25)), false); update_option('wpquotepro_registered_at', current_time('mysql'), false); // Per-tenant webhook-secret: automatisch meegekomen in de registratie- // response, klant hoeft hier zelf nooit iets voor in te vullen. $webhook_secret = (string) ($tenant['webhook_secret'] ?? ($body['webhook_secret'] ?? '')); if ('' !== $webhook_secret) { update_option('wpquotepro_webhook_secret', sanitize_text_field($webhook_secret), false); } // Zie handle_connect_account(): geen extra Opslaan nodig. $this->sync_settings_to_backend( sanitize_text_field($api_number), (int) get_option('wpquotepro_acceptance_flow_enabled', 1) ); $this->zet_site_url_als_leeg(); $registered_via_backend = true; } } } } // 1.8.6: geen "lokale proef" meer als de backend niet antwoordt. Die zag // eruit als een werkende proef, maar zonder tenant ging er geen enkele // offerte door. Eerlijk melden dat het niet lukte. if (! $registered_via_backend) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'register_failed')); exit; } // 1.9.4: wat de klant net in het proefformulier invulde ook hier bewaren. // Anders stond de setup na "Gratis proef starten" op 1 van 4, met // "Bedrijfsnaam is ingevuld" en "Bedrijfs-e-mail is geldig" nog open -- // terwijl hij die twee net had ingevuld. Bestaande waarden blijven staan. if ('' === trim((string) get_option('wpquotepro_company_name', '')) && '' !== $company_name) { update_option('wpquotepro_company_name', $company_name, false); } if ('' === trim((string) get_option('wpquotepro_company_email', '')) && is_email($company_email)) { update_option('wpquotepro_company_email', $company_email, false); } wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'trial_created')); exit; } // Verbindt een bestaand (betaald) WPQuotePro-account met deze website, zonder // dat de klant een API-key handmatig moet kopiëren/plakken. De klant vult enkel // het e-mailadres in waarmee hij/zij heeft afgerekend; site_url kent de plugin al. public function handle_connect_account() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_connect_account'); $email = isset($_POST['connect_email']) ? sanitize_email(wp_unslash($_POST['connect_email'])) : ''; if (empty($email) || ! is_email($email)) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'account_connect_invalid_email')); exit; } if (! defined('WPQUOTEPRO_CONNECT_ENDPOINT') || ! WPQUOTEPRO_CONNECT_ENDPOINT) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'account_connect_failed')); exit; } $payload = [ 'email' => $email, 'site_url' => home_url('/'), // BE-25: de backend haalt dit token zelf op bij deze site, als bewijs // dat het verzoek hier vandaan komt. 'site_proof' => WPQUOTEPRO_Site_Proof::create(), ]; // Ruimer dan voorheen: de backend belt tijdens dit verzoek terug naar deze // site om het bewijs op te halen. $response = wp_remote_post(WPQUOTEPRO_CONNECT_ENDPOINT, [ 'timeout' => 30, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'account_connect_failed')); exit; } $code = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); if (429 === $code) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'account_connect_rate_limited')); exit; } if (403 === $code && is_array($body) && isset($body['error']['message']) && false !== strpos((string) $body['error']['message'], 'Could not verify')) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'site_proof_failed')); exit; } // Geen account bij dit adres en deze site: de meest voorkomende oorzaak is // een ander e-mailadres dan waarmee is afgerekend. Dat verdient een eigen // melding die zegt waar het juiste adres te vinden is. if (404 === $code) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'account_connect_not_found')); exit; } if ($code < 200 || $code >= 300 || ! is_array($body) || empty($body['api_key'])) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'account_connect_failed')); exit; } update_option('wpquotepro_api_key', sanitize_text_field((string) $body['api_key']), false); update_option('wpquotepro_api_number', sanitize_text_field((string) $body['api_key']), false); update_option('wpquotepro_plan', sanitize_key((string) ($body['plan'] ?? 'starter')), false); update_option('wpquotepro_license_status', sanitize_key((string) ($body['status'] ?? 'active')), false); update_option('wpquotepro_registered_at', current_time('mysql'), false); if (! empty($body['webhook_secret'])) { update_option('wpquotepro_webhook_secret', sanitize_text_field((string) $body['webhook_secret']), false); } // Volledige abonnementsstatus ophalen (limiet/gebruik/geldig tot). $this->refresh_subscription_from_backend(sanitize_text_field((string) $body['api_key'])); // Wat Opslaan anders nog deed: de acceptatie-instelling aan de backend // doorgeven. Zonder dit moest de ondernemer na "Account verbonden" nog op // Opslaan klikken, anders kon de backend een andere stand hebben dan hij // op zijn scherm ziet. $this->sync_settings_to_backend( sanitize_text_field((string) $body['api_key']), (int) get_option('wpquotepro_acceptance_flow_enabled', 1) ); $this->zet_site_url_als_leeg(); wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'account_connected')); exit; } public function handle_sync_templates() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_sync_templates'); $api_key = $this->get_configured_api_key(); if (empty($api_key)) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'trial_required')); exit; } // Ververs abonnementsstatus (plan/limiet/gebruik) bij de backend. $this->refresh_subscription_from_backend($api_key); // Backend form-templates opnieuw ophalen bij volgende wizard-bezoek. delete_transient('wpquotepro_form_templates_catalog'); wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', 'templates_synced', ['tab' => 'abonnement'])); exit; } public function handle_save_settings() { if (! current_user_can('manage_options')) { wp_die(esc_html__('No access.', 'gresquote')); } check_admin_referer('wpquotepro_save_settings'); // 1.9.18: elk tabblad slaat alleen zijn eigen velden op. Zonder deze // scheiding zou Opslaan op Bedrijf het logo en de API-sleutel leegmaken, // omdat die velden daar niet in het formulier staan. Zonder wpqp_tab // (een oud formulier) gaat alles mee, zoals tot 1.9.17. $tab = isset($_POST['wpqp_tab']) ? sanitize_key(wp_unslash($_POST['wpqp_tab'])) : ''; $groepen = in_array($tab, ['bedrijf', 'offerte', 'sleutel'], true) ? [$tab] : ['bedrijf', 'offerte', 'sleutel']; $terug_tab = in_array($tab, ['sleutel', 'verwijderen'], true) ? 'abonnement' : $tab; $terug = function ($message) use ($terug_tab) { wp_safe_redirect($this->admin_url_with_message('wpquotepro-settings', $message, '' !== $terug_tab ? ['tab' => $terug_tab] : [])); exit; }; // 1.9.30: de keuze voor het verwijderen staat los van de rest en hoeft // niet naar de backend; WPQUOTEPRO_Activation::uninstall() leest hem. if ('verwijderen' === $tab) { $wissen = isset($_POST['wis_bij_verwijderen']) && '1' === sanitize_text_field(wp_unslash($_POST['wis_bij_verwijderen'])); update_option('wpquotepro_wis_bij_verwijderen', $wissen ? 1 : 0, false); $terug($wissen ? 'verwijderkeuze_wissen' : 'verwijderkeuze_bewaren'); } if (in_array('sleutel', $groepen, true)) { $api_key = isset($_POST['api_key']) ? sanitize_text_field(wp_unslash($_POST['api_key'])) : ''; if ('' === $api_key && isset($_POST['api_number'])) { $api_key = sanitize_text_field(wp_unslash($_POST['api_number'])); } update_option('wpquotepro_api_key', $api_key, false); update_option('wpquotepro_api_number', $api_key, false); } if (in_array('bedrijf', $groepen, true)) { $tekst = function ($naam) { return isset($_POST[$naam]) ? sanitize_text_field(wp_unslash($_POST[$naam])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() bovenaan handle_save_settings() }; update_option('wpquotepro_company_name', $tekst('company_name'), false); update_option('wpquotepro_company_address', $tekst('company_address'), false); update_option('wpquotepro_company_postal_city', $tekst('company_postal_city'), false); update_option('wpquotepro_company_phone', $tekst('company_phone'), false); update_option('wpquotepro_company_email', isset($_POST['company_email']) ? sanitize_email(wp_unslash($_POST['company_email'])) : '', false); update_option('wpquotepro_company_website', isset($_POST['company_website']) ? esc_url_raw(wp_unslash($_POST['company_website'])) : '', false); update_option('wpquotepro_company_kvk', $tekst('company_kvk'), false); update_option('wpquotepro_company_vat_number', $tekst('company_vat_number'), false); update_option('wpquotepro_company_iban', $tekst('company_iban'), false); } if (in_array('offerte', $groepen, true)) { $quotation_from_email = isset($_POST['quotation_from_email']) ? sanitize_email(wp_unslash($_POST['quotation_from_email'])) : ''; // De afzender moet bij het domein van de site horen, anders belandt // de mail in de spam of wordt hij geweigerd. if (! empty($quotation_from_email)) { $site_domain = str_replace('www.', '', (string) wp_parse_url(get_site_url(), PHP_URL_HOST)); $email_domain = str_replace('www.', '', substr(strrchr($quotation_from_email, '@'), 1)); if (strcasecmp($site_domain, $email_domain) !== 0) { $terug('email_domain_invalid'); } } $logo_id = isset($_POST['logo_id']) ? absint(wp_unslash($_POST['logo_id'])) : 0; $logo_url = isset($_POST['logo_url']) ? esc_url_raw(wp_unslash($_POST['logo_url'])) : ''; if ($logo_id > 0) { $attachment_url = wp_get_attachment_url($logo_id); if (! empty($attachment_url)) { $logo_url = esc_url_raw($attachment_url); } } $pdf_accent_color = isset($_POST['pdf_accent_color']) ? sanitize_hex_color(wp_unslash($_POST['pdf_accent_color'])) : ''; if (empty($pdf_accent_color)) { $pdf_accent_color = '#0B2E59'; } update_option('wpquotepro_logo_id', $logo_id, false); update_option('wpquotepro_logo_url', $logo_url, false); update_option('wpquotepro_quotation_validity_days', isset($_POST['quotation_validity_days']) ? max(1, min(365, absint(wp_unslash($_POST['quotation_validity_days'])))) : 30, false); update_option('wpquotepro_quotation_intro_text', isset($_POST['quotation_intro_text']) ? sanitize_textarea_field(wp_unslash($_POST['quotation_intro_text'])) : '', false); update_option('wpquotepro_quotation_footer_text', isset($_POST['quotation_footer_text']) ? sanitize_textarea_field(wp_unslash($_POST['quotation_footer_text'])) : '', false); update_option('wpquotepro_quotation_from_email', $quotation_from_email, false); update_option('wpquotepro_pdf_accent_color', $pdf_accent_color, false); update_option('wpquotepro_acceptance_flow_enabled', (isset($_POST['acceptance_flow_enabled']) && '1' === sanitize_text_field(wp_unslash($_POST['acceptance_flow_enabled']))) ? 1 : 0, false); // Taal, valuta en land (fase 1, blok 5d). Alleen bekende waarden. $standaard = WPQUOTEPRO_Regio::standaard(get_locale()); $regio_veld = function ($naam) { return isset($_POST[$naam]) ? sanitize_text_field(wp_unslash($_POST[$naam])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() bovenaan handle_save_settings() }; update_option('wpquotepro_quote_language', WPQUOTEPRO_Regio::geldig($regio_veld('quote_language'), WPQUOTEPRO_Regio::TALEN, $standaard['taal']), false); update_option('wpquotepro_currency', WPQUOTEPRO_Regio::geldig($regio_veld('currency'), WPQUOTEPRO_Regio::VALUTA, $standaard['valuta']), false); update_option('wpquotepro_country', WPQUOTEPRO_Regio::geldig($regio_veld('country'), WPQUOTEPRO_Regio::LANDEN, $standaard['land']), false); } // Na opslaan: instellingen naar de backend syncen (los van abonnementsstatus) // en daarna abonnementsinfo ophalen zodat plan/status/limiet kloppen. $api_key = $this->get_configured_api_key(); if ('' !== $api_key) { $this->sync_settings_to_backend($api_key, (int) get_option('wpquotepro_acceptance_flow_enabled', 1)); $refreshed = $this->refresh_subscription_from_backend($api_key); $terug($refreshed ? 'settings_saved_synced' : 'settings_saved_no_sub'); } $terug('settings_saved_no_key'); } /** * Haalt de actuele abonnementsstatus op bij de backend (op basis van de API-key) * en bewaart plan/status/limiet/gebruik lokaal. Retourneert true als er een * gekoppeld abonnement gevonden is. */ private function refresh_subscription_from_backend($api_key) { if ('' === (string) $api_key || ! defined('WPQUOTEPRO_TENANT_STATUS_ENDPOINT') || ! WPQUOTEPRO_TENANT_STATUS_ENDPOINT) { return false; } $response = wp_remote_get(WPQUOTEPRO_TENANT_STATUS_ENDPOINT, [ 'timeout' => 12, 'headers' => [ 'Accept' => 'application/json', 'Apikey' => $api_key, 'Authorization' => 'Bearer ' . $api_key, ], ]); if (is_wp_error($response)) { return false; } $code = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); if (function_exists('wpquotepro_verwerk_sleutelcontrole') && function_exists('wpquotepro_sleutel_oordeel')) { // 1.0.0: dit is dezelfde vraag als de sleutelcontrole, dus hetzelfde gevolg. wpquotepro_verwerk_sleutelcontrole(wpquotepro_sleutel_oordeel($code)); } if ($code < 200 || $code >= 300 || ! is_array($body) || empty($body['connected'])) { return false; } if (! empty($body['tenant_id'])) { update_option('wpquotepro_tenant_id', sanitize_text_field((string) $body['tenant_id']), false); } update_option('wpquotepro_plan', sanitize_key((string) ($body['plan'] ?? 'starter')), false); update_option('wpquotepro_license_status', sanitize_key((string) ($body['status'] ?? 'active')), false); update_option('wpquotepro_trial_quote_limit', (int) ($body['limit'] ?? 0), false); update_option('wpquotepro_live_used_count', (int) ($body['live_used'] ?? ($body['used'] ?? 0)), false); update_option('wpquotepro_trial_used_count', (int) ($body['trial_used'] ?? 0), false); update_option('wpquotepro_subscription_valid_until', sanitize_text_field((string) ($body['expires_at'] ?? '')), false); update_option('wpquotepro_registered_at', current_time('mysql'), false); // Vul Site-URL, bedrijfsnaam en e-mail aan als die lokaal nog leeg zijn. if ('' === trim((string) get_option('wpquotepro_site_url', '')) && ! empty($body['site_url'])) { update_option('wpquotepro_site_url', esc_url_raw((string) $body['site_url']), false); } if ('' === trim((string) get_option('wpquotepro_company_name', '')) && ! empty($body['company_name'])) { update_option('wpquotepro_company_name', sanitize_text_field((string) $body['company_name']), false); } if ('' === trim((string) get_option('wpquotepro_company_email', '')) && ! empty($body['email'])) { update_option('wpquotepro_company_email', sanitize_email((string) $body['email']), false); } return true; } /** * Stuurt tenant-instellingen (zoals acceptance_flow_enabled) naar de backend. * Draait los van de abonnementsstatus zodat de sync ook werkt als de * status-call faalt. Backend verwacht PUT met Bearer/Apikey auth. */ private function sync_settings_to_backend($api_key, $acceptance_flow_enabled) { if ('' === (string) $api_key || ! defined('WPQUOTEPRO_TENANT_SETTINGS_ENDPOINT') || ! WPQUOTEPRO_TENANT_SETTINGS_ENDPOINT) { return false; } if (0 === strpos((string) $api_key, 'ofmx_trial_')) { return false; } $response = wp_remote_request(WPQUOTEPRO_TENANT_SETTINGS_ENDPOINT, [ 'method' => 'PUT', 'timeout' => 12, 'headers' => [ 'Content-Type' => 'application/json', 'Apikey' => $api_key, 'Authorization' => 'Bearer ' . $api_key, ], 'body' => wp_json_encode(array_merge( ['acceptance_flow_enabled' => (bool) $acceptance_flow_enabled], // Taal, valuta en land van de offertes (fase 1, blok 5d). WPQUOTEPRO_Regio::voor_backend() )), ]); if (is_wp_error($response)) { return false; } $code = (int) wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 300) { return false; } // WERELD B7: de backend geeft de tarieven van de ondernemer terug (eigen // tarieven, anders die van zijn land). Die worden de keuzelijst bij een // prijsregel en het voorstel bij een nieuw product. $body = json_decode((string) wp_remote_retrieve_body($response), true); if (is_array($body) && isset($body['tax_rates'])) { $tarieven = WPQUOTEPRO_Regio::schone_tarieven($body['tax_rates']); if (! empty($tarieven)) { update_option('wpquotepro_tax_rates', $tarieven, false); } } return true; } private function render_notice() { if ('' !== $this->catalogus_fout) { echo '
' . esc_html($this->catalogus_fout) . '
'; } if (! isset($_GET['message'])) { return; } $message = sanitize_text_field(wp_unslash($_GET['message'])); $text = $this->get_notice_text($message); if (empty($text)) { return; } $is_error = (false !== strpos($message, '_failed')) || (false !== strpos($message, '_error')) || (false !== strpos($message, '_invalid')) || (false !== strpos($message, '_locked')) || (false !== strpos($message, 'rate_limited')) || (false !== strpos($message, '_taken')) || (false !== strpos($message, '_not_found')); ?>
__('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; } ?>
GresQuote ›

render_notice(); ?> render_template_bron_keuze($source_options); ?> render_template_raster($catalogus, $source_options, $gebruik); ?>
$alle, 'geaccepteerd' => $geaccepteerd]; } /** * De factuurknop bij een offerte. * * Een factuurnummer zonder downloadadres levert bewust geen link op. Hier * stond href="#": de ondernemer klikte en er gebeurde niets. Een zichtbaar * nummer zonder knop is eerlijker dan een knop die liegt. */ private function render_invoice_cell($q) { $q = is_array($q) ? $q : []; $nummer = isset($q['invoice_number']) ? trim((string) $q['invoice_number']) : ''; if ('' === $nummer) { return '—'; } $laatste = strlen($nummer) > 4 ? substr($nummer, -4) : $nummer; $staart = '' . esc_html($laatste) . ''; $url = isset($q['invoice_pdf_url']) ? trim((string) $q['invoice_pdf_url']) : ''; if ('' === $url) { return '' . esc_html__('Invoice', 'gresquote') . '' . $staart; } return '' . esc_html__('Invoice', 'gresquote') . '' . $staart; } /** * De downloadknop voor de offerte-pdf. Zelfde knop op beide tabbladen, want * een geaccepteerde offerte moet net zo goed te downloaden zijn als een * openstaande -- juist dan. */ private function render_pdf_cell($q) { $q = is_array($q) ? $q : []; $url = isset($q['pdf_url']) ? trim((string) $q['pdf_url']) : ''; if ('' === $url) { return '—'; } return 'PDF'; } private function quotation_status_label($status) { $labels = [ 'concept' => __('Draft', 'gresquote'), 'ready_for_pdf' => __('Ready for PDF', 'gresquote'), 'pdf_queued' => __('In the queue', 'gresquote'), 'generating_pdf' => __('Creating PDF', 'gresquote'), 'generated' => __('PDF created', 'gresquote'), 'sent' => __('Sent', 'gresquote'), 'accepted' => __('Accepted', 'gresquote'), 'rejected' => __('Rejected', 'gresquote'), 'failed' => __('Failed', 'gresquote'), 'active' => __('Active', 'gresquote'), ]; $sleutel = strtolower(trim((string) $status)); if ('' === $sleutel) { return __('Unknown', 'gresquote'); } if (isset($labels[$sleutel])) { return $labels[$sleutel]; } return ucfirst(str_replace('_', ' ', $sleutel)); } private function format_backend_date($waarde, $formaat = 'd-m-Y', $naar_sitezone = true, $terugval = '—') { if (empty($waarde)) { return $terugval; } try { $datum = new DateTime((string) $waarde, new DateTimeZone('UTC')); if ($naar_sitezone) { $datum->setTimezone(new DateTimeZone(wp_timezone_string())); } return $datum->format($formaat); } catch (\Throwable $fout) { return $terugval; } } private function resolve_stats_period() { $toegestaan = ['30d', 'month', 'quarter', 'year']; $gebruiker = get_current_user_id(); // De keuze wordt per gebruiker onthouden, dus alleen opgeslagen met een // geldige nonce uit stats_period_url(). Zonder nonce (een gedeelde of // oude link) wordt de periode wel getoond, maar niet bewaard. if (isset($_GET['wpqp_period'])) { $gekozen = sanitize_text_field(wp_unslash($_GET['wpqp_period'])); if (in_array($gekozen, $toegestaan, true)) { $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : ''; if (current_user_can('manage_options') && wp_verify_nonce($nonce, 'wpquotepro_stats_period')) { update_user_meta($gebruiker, 'wpquotepro_stats_period', $gekozen); } return $gekozen; } } $bewaard = get_user_meta($gebruiker, 'wpquotepro_stats_period', true); return in_array($bewaard, $toegestaan, true) ? $bewaard : '30d'; } /** * Haalt alle cijfers op bij de backend: één aanroep per paginaweergave. * * Server-side, want de API-sleutel hoort niet in de browser. En de plugin * rekent zelf niets uit (besluit 2 in docs/features/tenant-dashboard.md): * zou hij dat wel doen, dan ontstaan twee waarheden over hetzelfde getal en * is er geen manier meer om te zeggen welke klopt. */ private function fetch_tenant_stats($api_key, $period) { $backend_url = defined('WPQUOTEPRO_BACKEND_BASE') ? WPQUOTEPRO_BACKEND_BASE : 'https://app.wpquotepro.nl'; $endpoint = rtrim($backend_url, '/') . '/api/v1/tenant/stats?period=' . rawurlencode($period); $response = wp_remote_get($endpoint, [ 'timeout' => 15, 'headers' => [ 'Accept' => 'application/json', 'Apikey' => $api_key, 'Authorization' => 'Bearer ' . $api_key, ], ]); if (is_wp_error($response)) { /* translators: %s: error message */ return ['stats' => null, 'error' => sprintf(__('The figures 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)) { // De backend zegt zelf wat er mis is (onbekende periode, ongeldige // sleutel). Dat bericht doorgeven is bruikbaarder dan "er ging iets // mis" met de echte reden in een logbestand dat niemand opent. $melding = (is_array($body) && ! empty($body['error']['message'])) ? (string) $body['error']['message'] /* translators: %s: HTTP status code */ : sprintf(__('The backend returned status %s.', 'gresquote'), $code); /* translators: %s: reason given by the backend */ return ['stats' => null, 'error' => sprintf(__('The figures could not be retrieved. %s', 'gresquote'), $melding)]; } // De valuta van ons meerverbruik (euro of dollar, per winkel), voor de // schermen die geen stats ophalen (tabblad Abonnement). if (isset($body['usage']['overage_currency']) && in_array($body['usage']['overage_currency'], ['EUR', 'USD'], true)) { update_option('wpquotepro_overage_currency', $body['usage']['overage_currency'], false); } return ['stats' => $body, 'error' => '']; } /** Bedragen in hele euro's: centen zeggen niets bij een maandtotaal. */ private function format_stats_money($bedrag) { return esc_html(WPQUOTEPRO_Regio::bedrag($bedrag, null, ' ', 0)); } /** * Het verschil met de vorige periode. * * null is niet "0%" maar "de vorige periode was leeg" — dat is nieuw, geen * oneindige groei. Daarom staat er dan "nieuw" en geen percentage. */ private function format_stats_change($pct) { if (null === $pct) { return '' . esc_html__('new', 'gresquote') . ''; } $pct = (int) $pct; if (0 === $pct) { return '' . esc_html__('unchanged', 'gresquote') . ''; } $klasse = $pct > 0 ? 'is-up' : 'is-down'; $teken = $pct > 0 ? '▲' : '▼'; return '' . $teken . ' ' . abs($pct) . '%'; } /** Dagen met één decimaal. null betekent "nog te weinig gegevens", niet nul. */ private function format_stats_days($waarde) { if (null === $waarde) { return null; } /* translators: %s: number of days with one decimal */ return sprintf(__('%s days', 'gresquote'), number_format_i18n((float) $waarde, 1)); } /** De link waarmee je van periode wisselt, met de tab-keuze erin. */ private function stats_period_url($period) { return wp_nonce_url( add_query_arg( ['page' => 'wpquotepro-my-quotes', 'wpqp_period' => $period], admin_url('admin.php') ), 'wpquotepro_stats_period' ); } /** * 1.9.24: de tabbladen van Offertes hebben een eigen adres * (&tab=offertes&filter=geaccepteerd), zodat Cijfers of een mail ernaar * kan linken. Tot 1.9.23 wisselde JavaScript zonder adres. * * @return array{tab:string, filter:string} */ private function offertes_keuze() { $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : ''; $filter = isset($_GET['filter']) ? sanitize_key(wp_unslash($_GET['filter'])) : ''; if (! in_array($filter, ['open', 'geaccepteerd', 'afgewezen'], true)) { $filter = ''; } if (! in_array($tab, ['cijfers', 'offertes', 'mails'], true)) { $tab = '' !== $filter ? 'offertes' : 'cijfers'; } return ['tab' => $tab, 'filter' => $filter]; } /** Adres van een tabblad of filter op Offertes. */ private function offertes_url($tab, $filter = '') { $args = ['page' => 'wpquotepro-my-quotes', 'tab' => $tab]; if ('' !== $filter) { $args['filter'] = $filter; } return add_query_arg($args, admin_url('admin.php')); } /** * Naam en e-mail van de klant uit de snapshot op de offerte. * * @return array{naam:string, email:string} */ private function offerte_klant($q) { $info = isset($q['customer_info']) ? $q['customer_info'] : []; if (is_string($info)) { $info = json_decode($info, true); } $info = is_array($info) ? $info : []; $naam = ''; foreach (['full_name', 'name', 'company_name', 'company'] as $veld) { if (! empty($info[$veld]) && is_scalar($info[$veld])) { $naam = trim((string) $info[$veld]); break; } } if ('' === $naam) { $naam = trim(implode(' ', array_filter([ isset($info['first_name']) && is_scalar($info['first_name']) ? (string) $info['first_name'] : '', isset($info['last_name']) && is_scalar($info['last_name']) ? (string) $info['last_name'] : '', ]))); } $email = isset($info['email']) && is_scalar($info['email']) ? trim((string) $info['email']) : ''; return ['naam' => $naam, 'email' => $email]; } /** Een backenddatum (UTC) als "vandaag 09:14", "gisteren" of "3 okt", in de tijd van de site. */ private function offerte_wanneer($waarde, $nu = null) { if (empty($waarde) || ! is_string($waarde)) { return ''; } $lokaal = $this->format_backend_date($waarde, 'Y-m-d H:i:s', true, ''); return '' === $lokaal ? '' : $this->wanneer($lokaal, $nu); } /** * De stand van een offerte in één woord plus één regel uitleg. Vervangt * sinds 1.9.24 de vier smalle kolommen Status, Klant deed, Herinnering en * Mail: de ondernemer wil weten waar hij staat, niet vier vinkjes. * * groep is waar de filters op werken: open, geaccepteerd of afgewezen. * klasse is die van .wpqp-stand (is-live, is-let-op, is-fout of leeg). * * @return array{groep:string, label:string, klasse:string, uitleg:string} */ private function offerte_stand($q, $nu = null) { $q = is_array($q) ? $q : []; $nu = null === $nu ? (int) current_time('timestamp') : (int) $nu; $status = strtolower(trim((string) (isset($q['status']) ? $q['status'] : ''))); if (! empty($q['accepted_at']) || 'accepted' === $status) { $op = $this->offerte_wanneer(isset($q['accepted_at']) ? $q['accepted_at'] : '', $nu); $uitleg = '' !== $op ? $op : ''; if (! empty($q['accepted_after_reminder'])) { $uitleg .= ('' !== $uitleg ? ', ' : '') . __('after a reminder', 'gresquote'); } return ['groep' => 'geaccepteerd', 'label' => __('Accepted', 'gresquote'), 'klasse' => 'is-live', 'uitleg' => $uitleg]; } if (! empty($q['rejected_at']) || 'rejected' === $status) { $uitleg = $this->offerte_wanneer(isset($q['rejected_at']) ? $q['rejected_at'] : '', $nu); $reden = isset($q['rejection_reason']) ? trim((string) $q['rejection_reason']) : ''; if ('' !== $reden) { $uitleg .= ('' !== $uitleg ? ' · ' : '') . '"' . $reden . '"'; } return ['groep' => 'afgewezen', 'label' => __('Rejected', 'gresquote'), 'klasse' => '', 'uitleg' => $uitleg]; } if ('failed' === $status) { return ['groep' => 'open', 'label' => __('Failed', 'gresquote'), 'klasse' => 'is-fout', 'uitleg' => __('The PDF could not be created.', 'gresquote')]; } if (in_array($status, ['concept', 'ready_for_pdf', 'pdf_queued', 'generating_pdf'], true)) { return ['groep' => 'open', 'label' => __('In progress', 'gresquote'), 'klasse' => '', 'uitleg' => __('The PDF is being created.', 'gresquote')]; } // Geldig tot is een datum zonder tijd; vergelijk op de dag van de site. $geldig = isset($q['valid_until']) ? substr(trim((string) $q['valid_until']), 0, 10) : ''; $vandaag = gmdate('Y-m-d', $nu); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $geldig) && $geldig < $vandaag) { /* translators: %s: date until which the quote was valid */ return ['groep' => 'open', 'label' => __('Expired', 'gresquote'), 'klasse' => '', 'uitleg' => sprintf(__('Was valid until %s.', 'gresquote'), $this->wanneer($geldig . ' 12:00:00', $nu))]; } if (! empty($q['email_failed']) && empty($q['email_sent'])) { return ['groep' => 'open', 'label' => __('Email failed', 'gresquote'), 'klasse' => 'is-fout', 'uitleg' => __('The quote did not reach the customer.', 'gresquote')]; } $delen = []; $label = __('Ready', 'gresquote'); if (! empty($q['email_sent'])) { $label = __('Sent', 'gresquote'); $op = $this->offerte_wanneer(is_string($q['email_sent']) ? $q['email_sent'] : '', $nu); /* translators: %s: when the quote was emailed, e.g. today 09:14 */ $delen[] = '' !== $op ? sprintf(__('emailed %s', 'gresquote'), $op) : __('emailed', 'gresquote'); } else { $delen[] = __('PDF ready, not emailed', 'gresquote'); } $herinnerd = (int) (isset($q['reminders_sent']) ? $q['reminders_sent'] : 0); $klasse = ''; if ($herinnerd > 0) { $laatste = $this->offerte_wanneer(isset($q['last_reminder_at']) ? $q['last_reminder_at'] : '', $nu); /* translators: %s: number of reminders */ $delen[] = sprintf(_n('%s reminder', '%s reminders', $herinnerd, 'gresquote'), $herinnerd) /* translators: %s: when the last reminder was sent */ . ('' !== $laatste ? ', ' . sprintf(__('last %s', 'gresquote'), $laatste) : ''); } if ((int) (isset($q['reminders_failed']) ? $q['reminders_failed'] : 0) > 0) { $delen[] = __('reminder failed', 'gresquote'); $klasse = 'is-fout'; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $geldig) && $geldig <= gmdate('Y-m-d', $nu + 7 * DAY_IN_SECONDS)) { /* translators: %s: today, or the date the quote expires */ $delen[] = sprintf(__('expires %s', 'gresquote'), $geldig === $vandaag ? __('today', 'gresquote') : $this->wanneer($geldig . ' 12:00:00', $nu)); if ('' === $klasse) { $klasse = 'is-let-op'; } } return ['groep' => 'open', 'label' => $label, 'klasse' => $klasse, 'uitleg' => ucfirst(implode(' · ', $delen))]; } /** * Hoeveel offertes er per filter zijn, voor de chips. * * @return array{open:int, geaccepteerd:int, afgewezen:int} */ private function offertes_tellen(array $quotations, $nu = null) { $tellingen = ['open' => 0, 'geaccepteerd' => 0, 'afgewezen' => 0]; foreach ($quotations as $q) { $stand = $this->offerte_stand($q, $nu); $tellingen[$stand['groep']]++; } return $tellingen; } public function render_my_quotes_page() { $api_key = $this->get_configured_api_key(); $backend_url = defined('WPQUOTEPRO_BACKEND_BASE') ? WPQUOTEPRO_BACKEND_BASE : 'https://app.wpquotepro.nl'; $quotations = []; $emails = []; $acceptances = get_option('wpquotepro_acceptances', []); if (! is_array($acceptances)) { $acceptances = []; } $tenant_name = ''; $error_message = ''; // Zonder boekhoudkoppeling bestaat er geen factuur bij een geaccepteerde // offerte -- wij maken er zelf geen meer. Dan hoort er ook geen // factuurkolom te staan: een kolom die altijd leeg is, ziet eruit als // iets dat stuk is. Standaard uit; de backend zegt of het aan mag. $boekhouding_gekoppeld = false; // Haal offertes op via API key (geverifieerd) OF via site_url (trial) if ($api_key) { // Authenticated: gebruik API key $endpoint = rtrim($backend_url, '/') . '/api/v1/tenant/quotations-and-emails'; $response = wp_remote_get($endpoint, [ 'timeout' => 15, 'headers' => [ 'Accept' => 'application/json', 'Apikey' => $api_key, 'Authorization' => 'Bearer ' . $api_key, ], ]); } else { // Trial mode: via Next.js proxy endpoint (no auth needed, site_url is unique) $endpoint = rtrim($backend_url, '/') . '/api/v1/plugin/quotations-by-site?site_url=' . urlencode(home_url('/')); $response = wp_remote_get($endpoint, [ 'timeout' => 15, 'headers' => [ 'Accept' => 'application/json', ], ]); } if (!$api_key && is_wp_error($response)) { // Als anoniem endpoint faalt, toon melding ?>
GresQuote ›

debug_log('[render_my_quotes_page] Response code: ' . $code . ', raw body length: ' . strlen($raw_body) . ', first 500 chars: ' . substr($raw_body, 0, 500)); if ($code >= 200 && $code < 300 && is_array($body)) { $response_data = $body; } } else { $this->debug_log('[render_my_quotes_page] wp_error: ' . $response->get_error_message()); } // Parse response (verschilden voor API key vs anonymous) if ($api_key && isset($response_data['quotations'])) { // API key response $quotations = $response_data['quotations'] ?? []; $emails = $response_data['emails'] ?? []; $tenant = $response_data['tenant'] ?? []; $tenant_name = $tenant['name'] ?? ''; $boekhouding = $response_data['accounting'] ?? []; $boekhouding_gekoppeld = ! empty($boekhouding['connected']); } elseif (!$api_key && isset($response_data['quotations'])) { // Anonymous response $quotations = $response_data['quotations'] ?? []; $emails = $response_data['emails'] ?? []; $tenant_name = $response_data['tenant_name'] ?? ''; $this->debug_log('[render_my_quotes_page] Anonymous mode: ' . count($quotations) . ' quotations found'); } // Merge local acceptance data from WordPress options $verdeling = $this->verdeel_offertes_naar_acceptatie($quotations, $acceptances); $quotations = $verdeling['alle']; $keuze = $this->offertes_keuze(); $tab = $keuze['tab']; $filter = $keuze['filter']; $tellingen = $this->offertes_tellen($quotations); // De cijfers voor het eerste tabblad (onderdeel B). Bewust een eigen // aanroep met een eigen foutpad: als de cijfers niet komen, moet de // offertelijst het gewoon nog doen. Alleen op dat tabblad: de andere // hoeven er niet op te wachten. $stats = null; $stats_error = ''; $stats_period = $this->resolve_stats_period(); $has_api_key = ('' !== $api_key); if ($has_api_key && 'cijfers' === $tab) { $uitkomst = $this->fetch_tenant_stats($api_key, $stats_period); $stats = $uitkomst['stats']; $stats_error = $uitkomst['error']; } // 1.0.5: bijlagen die de klant meestuurde, per offertenummer. Uit het // eigen logboek: de bestanden en hun namen staan niet bij de backend. $bijlagen_per_offerte = WPQUOTEPRO_Bijlagen::per_offertenummer(); include WPQUOTEPRO_PLUGIN_DIR . 'admin/my-quotes-page.php'; } }