$field_name, 'label' => sanitize_text_field((string) ($field['label'] ?? $field_name)), 'type' => sanitize_key((string) ($field['type'] ?? 'text')), 'required' => ! empty($field['required']), 'choices' => $choices, ]; } if (empty($item_fields)) { continue; } $count_source = is_array($group['count_source'] ?? null) ? $group['count_source'] : []; $count_mode = sanitize_key((string) ($count_source['mode'] ?? 'field')); if (! in_array($count_mode, ['fixed', 'field'], true)) { $count_mode = 'field'; } $pricing = is_array($group['pricing'] ?? null) ? $group['pricing'] : []; $pricing_mode = sanitize_key((string) ($pricing['mode'] ?? 'per_item')); if (! in_array($pricing_mode, ['per_item', 'area'], true)) { $pricing_mode = 'per_item'; } $max_items = isset($group['max_items']) ? absint($group['max_items']) : 0; if ($max_items < 1) { $max_items = self::DEFAULT_MAX_ITEMS; } // Prijs per keuze (1.0.1): per keuze van een sub-veld een eigen product. $choice_field = sanitize_key((string) ($pricing['choice_field'] ?? '')); $choice_products = []; foreach ((array) ($pricing['choice_products'] ?? []) as $choice) { if (! is_array($choice)) { continue; } $choice_value = sanitize_text_field((string) ($choice['value'] ?? '')); $choice_code = sanitize_text_field((string) ($choice['product_code'] ?? '')); if ('' !== $choice_value && '' !== $choice_code) { $choice_products[] = ['value' => $choice_value, 'product_code' => $choice_code]; } } if ('' === $choice_field || empty($choice_products)) { $choice_field = ''; $choice_products = []; } $normalized[] = [ 'key' => $key, 'label' => sanitize_text_field((string) ($group['label'] ?? $key)), 'max_items' => $max_items, 'count_source' => [ 'mode' => $count_mode, 'value' => isset($count_source['value']) ? absint($count_source['value']) : 0, 'field_name' => sanitize_key((string) ($count_source['field_name'] ?? '')), ], 'item_fields' => $item_fields, 'pricing' => [ 'mode' => $pricing_mode, 'product_code' => sanitize_text_field((string) ($pricing['product_code'] ?? '')), 'quantity_field' => sanitize_key((string) ($pricing['quantity_field'] ?? '')), 'width_field' => sanitize_key((string) ($pricing['width_field'] ?? '')), 'height_field' => sanitize_key((string) ($pricing['height_field'] ?? '')), 'choice_field' => $choice_field, 'choice_products' => $choice_products, ], ]; } return $normalized; } /** * Alle productcodes waar de groepen een prijs van nodig hebben: het product * van de groep zelf plus de producten per keuze. */ public static function product_codes(array $groups) { $codes = []; foreach ($groups as $group) { $pricing = isset($group['pricing']) && is_array($group['pricing']) ? $group['pricing'] : []; $code = isset($pricing['product_code']) ? (string) $pricing['product_code'] : ''; if ('' !== $code) { $codes[$code] = true; } foreach ((array) ($pricing['choice_products'] ?? []) as $choice) { $choice_code = is_array($choice) && isset($choice['product_code']) ? (string) $choice['product_code'] : ''; if ('' !== $choice_code) { $codes[$choice_code] = true; } } } return array_map('strval', array_keys($codes)); } /** * Vult de veldenlijst van een template aan met de velden die de adapters in * het bronformulier moeten aanmaken: per groep een dataveld + een leesbaar * samenvattingsveld. Zet daarnaast op het aantal-veld van een groep een * herkenbare klasse, zodat de repeater dat veld kan meebewegen. * * Elk toegevoegd veld krijgt 'css_classes'; iedere adapter vertaalt dat naar * zijn eigen mechaniek (CF7 `class:`, WPForms `css`, Gravity `cssClass`). */ public static function augment_template_fields($template) { $fields = isset($template['fields']) && is_array($template['fields']) ? $template['fields'] : []; $groups = self::normalize_groups($template['groups'] ?? []); if (empty($groups)) { return $fields; } $count_field_names = []; foreach ($groups as $group) { if ('field' === $group['count_source']['mode'] && '' !== $group['count_source']['field_name']) { $count_field_names[$group['count_source']['field_name']] = true; } } foreach ($fields as $index => $field) { $name = sanitize_key((string) ($field['name'] ?? '')); if ('' !== $name && isset($count_field_names[$name])) { $fields[$index]['css_classes'] = trim((string) ($field['css_classes'] ?? '') . ' ' . self::count_field_class($name)); } } foreach ($groups as $group) { $fields[] = [ 'name' => self::field_name($group['key']), 'label' => $group['label'], 'type' => 'text', 'required' => false, 'css_classes' => self::field_classes($group['key']), ]; $fields[] = [ 'name' => self::summary_field_name($group['key']), 'label' => $group['label'] . ' (overzicht)', 'type' => 'text', 'required' => false, 'css_classes' => 'wpqp-group-summary wpqp-group-summary-' . $group['key'], ]; } return $fields; } /** * Decodeert de waarde die de repeater in het dataveld heeft gezet naar een * lijst items (elk item = sub-veldnaam => waarde). Accepteert zowel het * base64-formaat als kale JSON (handmatig ingevuld of getest). */ public static function decode_items($raw) { $raw = trim((string) $raw); if ('' === $raw) { return []; } if (0 === strpos($raw, self::PAYLOAD_PREFIX)) { $decoded = base64_decode(substr($raw, strlen(self::PAYLOAD_PREFIX)), true); if (false === $decoded) { return []; } $raw = $decoded; } $data = json_decode($raw, true); if (! is_array($data)) { return []; } $items = []; foreach ($data as $item) { if (! is_array($item)) { continue; } $clean = []; foreach ($item as $field_name => $value) { $field_name = sanitize_key((string) $field_name); if ('' === $field_name || is_array($value) || is_object($value)) { continue; } $clean[$field_name] = sanitize_text_field((string) $value); } if (! empty($clean)) { $items[] = $clean; } } return $items; } /** * Bouwt de GroupPricingSpec-lijst die de backend verwacht (camelCase, prijzen * als decimal string). $prices_by_code bevat de prijzen van de ondernemer * zelf: ['CODE' => ['unit_price' => 125.0, 'vat_rate' => 21.0]]. */ public static function build_specs(array $groups, array $prices_by_code, array $only_keys = []) { $specs = []; $sort_base = 500; foreach ($groups as $group) { if (! empty($only_keys) && ! in_array($group['key'], $only_keys, true)) { continue; } $code = (string) $group['pricing']['product_code']; if ('' === $code || ! isset($prices_by_code[$code])) { continue; } $price = $prices_by_code[$code]; $spec = [ 'key' => $group['key'], 'label' => $group['label'], 'mode' => $group['pricing']['mode'], 'productCode' => $code, 'unitPrice' => number_format((float) $price['unit_price'], 2, '.', ''), // De calculator rekent met een decimale BTW-factor (0.2100); de // plugin bewaart een percentage (21.00). 'vatRate' => number_format(((float) $price['vat_rate']) / 100, 4, '.', ''), 'maxItems' => (int) $group['max_items'], 'sortOrderBase' => $sort_base, ]; if ('area' === $group['pricing']['mode']) { $spec['widthField'] = $group['pricing']['width_field']; $spec['heightField'] = $group['pricing']['height_field']; } elseif ('' !== $group['pricing']['quantity_field']) { $spec['quantityField'] = $group['pricing']['quantity_field']; } // De overige sub-velden mee, zodat op de offerte te zien blijft wat de // klant per item gekozen heeft (type, kleur, ...) en niet alleen de maat. $used = [ $group['pricing']['width_field'], $group['pricing']['height_field'], $group['pricing']['quantity_field'], ]; $details = []; foreach ($group['item_fields'] as $field) { if (in_array($field['name'], $used, true)) { continue; } $details[] = ['name' => $field['name'], 'label' => $field['label']]; } if (! empty($details)) { $spec['detailFields'] = $details; } // Prijs per keuze: alleen keuzes waarvan het product hier bestaat en // Aan staat. De rest rekent bij de backend met de prijs van de groep. $choice_field = (string) ($group['pricing']['choice_field'] ?? ''); $choice_prices = []; foreach ((array) ($group['pricing']['choice_products'] ?? []) as $choice) { $choice_code = (string) ($choice['product_code'] ?? ''); if ('' === $choice_code || ! isset($prices_by_code[$choice_code])) { continue; } $choice_prices[] = [ 'value' => (string) $choice['value'], 'productCode' => $choice_code, 'unitPrice' => number_format((float) $prices_by_code[$choice_code]['unit_price'], 2, '.', ''), 'vatRate' => number_format(((float) $prices_by_code[$choice_code]['vat_rate']) / 100, 4, '.', ''), ]; } if ('' !== $choice_field && ! empty($choice_prices)) { $spec['choiceField'] = $choice_field; $spec['choicePrices'] = $choice_prices; } $specs[] = $spec; $sort_base += 100; } return $specs; } /** Alle groepen van actieve formulieren, gebundeld op groepssleutel. */ public static function get_active_groups() { global $wpdb; $forms_table = WPQUOTEPRO_DB::forms_table(); $form_ids = $wpdb->get_col($wpdb->prepare("SELECT id FROM %i WHERE status = 'active'", $forms_table)); if (empty($form_ids)) { return []; } $by_key = []; foreach ($form_ids as $form_id) { foreach (self::get_for_form((int) $form_id) as $group) { // Prijsinstellingen horen niet op de frontend thuis; de repeater // heeft alleen de structuur nodig. unset($group['pricing']['product_code']); $by_key[$group['key']] = $group; } } return $by_key; } public function enqueue_frontend_assets() { if (is_admin()) { return; } $groups = self::get_active_groups(); if (empty($groups)) { return; } 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' => $groups, 'payloadPrefix' => self::PAYLOAD_PREFIX, 'summarySuffix' => self::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'), ], ]); } }