[], 'version' => CHARGEFORMS_VERSION ]; wp_register_script( 'chargeforms-frontend', CHARGEFORMS_URL . 'assets/build/frontend.js', $frontend_asset['dependencies'], $frontend_asset['version'], true ); wp_register_script( 'chargeforms-stripe', CHARGEFORMS_URL . 'assets/js/stripe-payment.js', [ 'chargeforms-stripe-js' ], CHARGEFORMS_VERSION, true ); // `null` is deliberate — Stripe.js is versioned/cache-busted by Stripe itself; this plugin has no version of its own to attach to a vendor-hosted URL. wp_register_script( 'chargeforms-stripe-js', 'https://js.stripe.com/v3/', [], null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion // PayPal's own SDK loader URL carries the client id and currency as // query params (unlike Stripe.js, which is generic and configured at // runtime instead) — registered with whatever's saved right now; // re-registered per-request, so a key change in Settings takes effect // on the next page load with no cache to bust. $paypal_client_id = Settings::get( 'paypal_client_id' ); wp_register_script( 'chargeforms-paypal', CHARGEFORMS_URL . 'assets/js/paypal-payment.js', [ 'chargeforms-paypal-js' ], CHARGEFORMS_VERSION, true ); wp_register_script( 'chargeforms-paypal-js', $paypal_client_id ? add_query_arg( [ 'client-id' => $paypal_client_id, 'currency' => strtoupper( Settings::get( 'paypal_currency', 'usd' ) ), 'intent' => 'capture', ], 'https://www.paypal.com/sdk/js' ) : '', [], null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- PayPal's own SDK loader is versioned/cache-busted by PayPal itself. true ); wp_register_style( 'chargeforms-address-autocomplete', CHARGEFORMS_URL . 'assets/css/address-autocomplete.css', [ 'chargeforms-frontend' ], CHARGEFORMS_VERSION ); wp_register_script( 'chargeforms-address-autocomplete', CHARGEFORMS_URL . 'assets/js/address-autocomplete.js', [], CHARGEFORMS_VERSION, true ); } /** * Resolves the effective autocomplete provider for whatever Address * field(s) exist on this form, or '' if none should get it. A * per-field override ('none'/'osm'/'google') always wins over the * site default (Settings::get('address_autocomplete_provider')); * 'default' (or unset — every Address field predating this feature) * falls through to the site setting. Only the FIRST resolved non-'none' * provider is used — one script config per page, matching how the * Stripe/PayPal keys above are also resolved once per page, not * per-field; a form mixing google+osm across multiple Address fields * is an edge case not worth a more complex per-field asset story for. */ private function active_address_autocomplete_provider( array $fields ) { $site_default = Settings::get( 'address_autocomplete_provider', 'none' ); foreach ( $this->flatten_fields( $fields ) as $field ) { if ( 'address' !== $field['type'] ) { continue; } $provider = $field['autocompleteProvider'] ?? 'default'; if ( 'default' === $provider ) { $provider = $site_default; } if ( in_array( $provider, [ 'osm', 'google' ], true ) ) { return $provider; } } return ''; } /** Returns ' tabindex="N"' (incrementing) when the setting is on, '' otherwise — called once per field's primary input. */ private function next_tabindex_attr() { if ( ! Settings::get( 'auto_tab_index_enabled' ) ) { return ''; } $this->tabindex_counter++; return ' tabindex="' . (int) $this->tabindex_counter . '"'; } /** * the reference product parity: a Required field can show either the site-wide * Global message (Global Settings > Validation Messages) or a per-field * Custom one (Builder > Field Settings). Emits `required` plus, only * when the field is actually required, a `data-required-message` * carrying whichever one applies — `frontend-entry.js` reads this to * call setCustomValidity() so the browser's native validation popup and * this plugin's own inline error both show the configured message * instead of the browser's untranslated default ("Please fill out this * field"). Mirrors SubmissionHandler::required_message()'s server-side * fallback logic exactly, so what a visitor sees client-side and what * the server would say on a bypassed submit are always the same text. */ private function required_attr( array $field ) { if ( empty( $field['required'] ) ) { return $this->next_tabindex_attr(); } if ( 'custom' === ( $field['requiredMessageMode'] ?? '' ) && '' !== trim( (string) ( $field['requiredMessage'] ?? '' ) ) ) { $message = $field['requiredMessage']; } else { $message = Settings::get( 'validation_msg_required', __( 'This field is required', 'chargeforms' ) ); } return ' required data-required-message="' . esc_attr( $message ) . '"' . $this->next_tabindex_attr(); } /** * Same Global/Custom fallback as required_attr() above, generalized to * the Email/Numeric/Url format-validation rules — mirrors * SubmissionHandler::format_message()'s server-side logic exactly (same * `{rule}MessageMode`/`{rule}Message` field keys) so the client-side * setCustomValidity() text a visitor sees always matches what a * bypassed direct POST would get back from the server. */ private function format_message_attr( array $field, $rule, $global_key ) { if ( 'custom' === ( $field[ $rule . 'MessageMode' ] ?? '' ) && '' !== trim( (string) ( $field[ $rule . 'Message' ] ?? '' ) ) ) { return $field[ $rule . 'Message' ]; } return Settings::get( $global_key ); } /** * The builder's Developer settings (Field ID / HTML ID Override) let an * author override the rendered element's id — e.g. to match an id a * theme's CSS or a third-party script already targets. Falls back to * the existing `chargeforms-{field-id}` convention when unset, so every * pre-existing form keeps rendering exactly as before. */ private function field_html_id( array $field ) { return ! empty( $field['htmlId'] ) ? $field['htmlId'] : 'chargeforms-' . $field['id']; } /** * Replaces every {query.param_name} placeholder in $raw with that * param's real value from the CURRENT request's query string — an * unmatched placeholder (param absent from the URL) is replaced with * an empty string rather than left as literal `{query.x}` text, so a * visitor who didn't arrive via the expected link sees a blank default * instead of template syntax. Values already come sanitized via * Context_Builder::current_request_query_params(); this only needs to * additionally guard against a param value that itself happens to * contain `{query.` (extremely unlikely, but str_replace with an * array of needles/replacements is single-pass over the ORIGINAL * string regardless, so this is actually already safe against that). */ private static function resolve_query_placeholders( $raw ) { $params = Context_Builder::current_request_query_params(); return preg_replace_callback( '/\{query\.([a-zA-Z0-9_-]+)\}/', function ( $matches ) use ( $params ) { return $params[ $matches[1] ] ?? ''; }, $raw ); } /** * Id for the `.chargeforms-field-help` span render_field_row() renders * below a field's control(s) — shared with extra_input_attrs() below so * the control(s) and the span they're described by always agree on the * same id without either side re-deriving it independently. */ private function field_help_id( array $field ) { return $this->field_html_id( $field ) . '-help'; } /** * Read Only / Disabled / Custom Attributes have been real, stored * builder settings since Milestone 5 but were never enforced on the * actual rendered input (roadmap: the reference product comparison, "Field * Settings" — tracked as a known gap in FEATURE_STATUS.md). Builds one * attribute string covering all three so every input-rendering call * site only needs one insertion point rather than three. * * Custom attribute names are restricted to a safe HTML-attribute-name * pattern (not escaped-and-trusted verbatim) so a stray typo in the * builder can't emit malformed markup — the value itself is always * esc_attr()'d regardless of the name. */ private function extra_input_attrs( array $field ) { $attrs = ''; if ( ! empty( $field['readOnly'] ) ) { $attrs .= ' readonly'; } if ( ! empty( $field['disabled'] ) ) { $attrs .= ' disabled'; } // render_field_row() renders a `.chargeforms-field-help` span below // the control whenever helpMessage is set, but nothing ever pointed // a control at it — a screen-reader visitor never heard the help // text at all, unlike the beside-label tooltip (`?` icon), which // already carries its text via aria-label. A radio/checkbox group // gets this on every option (each printf in that switch threads the // same $extra through), which is intentional: they share one help // span the same way they'll share one error message on failed // validation (see frontend-entry.js's showFieldError). if ( ! empty( $field['helpMessage'] ) ) { $attrs .= sprintf( ' aria-describedby="%s"', esc_attr( $this->field_help_id( $field ) ) ); } foreach ( $field['customAttributes'] ?? [] as $pair ) { $key = trim( $pair['key'] ?? '' ); if ( '' === $key || ! preg_match( '/^[a-zA-Z][a-zA-Z0-9_:-]*$/', $key ) ) { continue; } $attrs .= sprintf( ' %s="%s"', $key, esc_attr( $pair['value'] ?? '' ) ); } return $attrs; } /** * The builder's own canvas preview (FieldPreview.js) shows a sensible * default placeholder — "you@example.com", "Your Name" — whenever a * field's own placeholder is blank, so the canvas never looks like a * row of empty boxes while editing. That fallback never existed on * this side: a real visitor got a genuinely blank input with no hint * at all, a real gap found by comparing the builder's own preview * against the actual rendered form side by side. Mirrors * FieldPreview.js's switch exactly, type for type. */ private function default_placeholder_for( array $field ) { switch ( $field['type'] ) { case 'email': return 'you@example.com'; case 'url': return 'https://'; case 'phone': return '(555) 555-5555'; case 'number': return '0'; case 'mask_input': return $field['mask'] ?? '(999) 999-9999'; case 'textarea': case 'post_content': case 'post_excerpt': return $field['label'] ?? ''; default: return ! empty( $field['label'] ) ? 'Your ' . $field['label'] : ''; } } public function render( $atts ) { $atts = shortcode_atts( [ 'id' => 0 ], $atts, 'chargeforms' ); $form = Form::get( (int) $atts['id'] ); if ( ! $form || 'published' !== $form['status'] ) { return ''; } $blocked = Restrictions::check( $form ); if ( null !== $blocked ) { // render_form_html() is what normally enqueues 'chargeforms-frontend' // (base stylesheet) — this early-return path skips it entirely, so // without this line a blocked visitor's card would render as bare, // unstyled text (a real, pre-existing bug found live while // verifying the new restriction-card styling: the CSS class names // were correct, but the stylesheet itself was never on the page). wp_enqueue_style( 'chargeforms-frontend' ); wp_enqueue_style( 'chargeforms-frontend-tokens-refine' ); return $this->render_restriction_card( $blocked ); } return $this->render_form_html( $form ); } /** * A visitor-facing card for a blocked form — one distinct tone/icon per * restriction type instead of a single generic gray box, matching * design_handoff_chargeforms/ChargeForms Front-End.dc.html's isRestricted * state. The "setting" hint always names the real Form Settings tab it * came from (there's one "Restrictions" tab holding all three checks — * see admin/class-form-settings-page.php — not per-restriction * sub-pages, so the hint doesn't claim a deeper path than actually exists). */ private function render_restriction_card( array $blocked ) { $meta = [ 'login' => [ 'glyph' => '🔒', 'tone' => 'info', 'title' => __( 'Login required', 'chargeforms' ) ], 'schedule_pending' => [ 'glyph' => '🕓', 'tone' => 'warn', 'title' => __( 'Not open yet', 'chargeforms' ) ], 'schedule_expired' => [ 'glyph' => '🕓', 'tone' => 'warn', 'title' => __( 'Closed', 'chargeforms' ) ], 'limit_entries' => [ 'glyph' => '⚠', 'tone' => 'warn', 'title' => __( 'Entry limit reached', 'chargeforms' ) ], 'restricted_ip' => [ 'glyph' => '🚫', 'tone' => 'warn', 'title' => __( 'Not available', 'chargeforms' ) ], ]; $type = $meta[ $blocked['type'] ] ?? [ 'glyph' => '⚠', 'tone' => 'warn', 'title' => __( 'Unavailable', 'chargeforms' ) ]; return sprintf( '
%s
%s
%s
', esc_attr( $type['tone'] ), esc_html( $type['glyph'] ), esc_html( $type['title'] ), esc_html( $blocked['message'] ), esc_html__( 'Form Settings → Restrictions', 'chargeforms' ) ); } /** * Builds the actual
markup for a given form record. Shared by the * [chargeforms] shortcode and Forms_List_Page's "Preview" action — Preview * needs to render a form regardless of its published/draft status, which * the shortcode itself deliberately refuses to do. */ public function render_form_html( array $form, $is_preview = false ) { // The base stylesheet always loads — a form has to look usable out // of the box. "Default Style Template" (Global Settings > // Miscellaneous) is a separate, purely additive polish layer on // top of that, matching how the real product's own toggle behaves // (off by default still means a normal, styled form). wp_enqueue_style( 'chargeforms-frontend' ); $this->current_form_id = (int) $form['id']; $this->tabindex_counter = 0; $pages = $this->split_into_pages( $form['fields'] ); $multi_step = count( $pages ) > 1; // Fallback matches Settings::defaults(). Save & Resume is opt-in so // forms do not gain draft controls until the site owner enables them. $save_resume_enabled = ! $is_preview && Settings::get( 'save_resume_enabled', false ); // Every form renders with novalidate now (see the tag below) // and relies on this script's initValidation() for the styled // error-summary/inline-error UI that replaces the browser's native // popups — so this can no longer be conditional on multi-step/ // mask/signature/etc the way it used to be; a plain two-field form // needs it just as much as a complex one. wp_enqueue_script( 'chargeforms-frontend' ); if ( $this->has_payment_field_for_gateway( $form['fields'], 'stripe' ) ) { wp_enqueue_script( 'chargeforms-stripe' ); } if ( $this->has_payment_field_for_gateway( $form['fields'], 'paypal' ) && Settings::get( 'paypal_client_id' ) ) { wp_enqueue_script( 'chargeforms-paypal' ); } $address_provider = $this->active_address_autocomplete_provider( $form['fields'] ); if ( $address_provider ) { wp_enqueue_style( 'chargeforms-address-autocomplete' ); wp_enqueue_script( 'chargeforms-address-autocomplete' ); // The API key is only ever printed when a form on THIS page // actually resolved to the Google provider — never unconditionally // on every page load, same discipline as the Stripe/PayPal keys // above (only enqueued when a payment field for that gateway is // actually present). wp_localize_script( 'chargeforms-address-autocomplete', 'ChargeFormsAddress', [ 'provider' => $address_provider, 'apiKey' => 'google' === $address_provider ? Settings::get( 'google_maps_api_key' ) : '', ] ); } $notice = ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flags on our own post-submit redirect URL (set by Submission_Handler::redirect_back()), not a state-changing request. if ( ! $is_preview && isset( $_GET['chargeforms_submitted'] ) && (int) $_GET['chargeforms_submitted'] === (int) $form['id'] ) { // A double-opt-in submission is genuinely unfinished — showing // the same "your submission was received" wording a normal // success gets would be misleading, since nothing is actually // recorded as complete until the visitor clicks the emailed // confirmation link (see Submission_Handler::redirect_back()'s // $pending flag, the only thing that sets this query arg). if ( isset( $_GET['chargeforms_pending'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, see the ignore comment above. $notice = '
' . esc_html__( 'Check your inbox', 'chargeforms' ) . '
' . esc_html__( 'Your submission is held as pending until you click the confirmation link we just emailed you.', 'chargeforms' ) . '
'; } else { // Conditional Confirmations (the reference product parity) — Submission_Handler::redirect_back() // puts only the matched row's INDEX on the URL, never its message text, so this looks // the real message up fresh from the form's own current settings rather than trusting // anything from the query string beyond "which row". $message = $form['settings']['success_message'] ?? __( 'Thanks — your submission was received.', 'chargeforms' ); if ( isset( $_GET['chargeforms_confirmation'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, see the ignore comment above. $confirmation_index = (int) $_GET['chargeforms_confirmation']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, see the ignore comment above. $confirmation_row = $form['settings']['confirmations'][ $confirmation_index ] ?? null; if ( is_array( $confirmation_row ) && 'message' === ( $confirmation_row['type'] ?? 'message' ) && '' !== ( $confirmation_row['message'] ?? '' ) ) { $message = $confirmation_row['message']; } } $notice = '
' . esc_html( $message ) . '
'; } } // Real data for the conditional-logic data sources that need a // server round-trip to resolve (currentUser/userRole/postMeta/ // userMeta) — scoped to only what this form's own rules reference // (Context_Builder never dumps more than that). urlParam/query and // cookie are deliberately NOT included here: both are natively // readable straight from the browser (window.location.search, // document.cookie), so assets/src/frontend/context.js resolves // those live instead of embedding a copy that would go stale the // moment the visitor navigates within a single-page-app-style page // without a full reload. $logic_context_attr = ''; if ( $this->has_conditional_fields( $form['fields'] ) ) { $logic_rules = Evaluator::extract_field_rules( $this->flatten_fields( $form['fields'] ) ); $logic_extra = Context_Builder::build_extra( $logic_rules, get_the_ID() ?: 0 ); unset( $logic_extra['urlParams'], $logic_extra['cookies'] ); if ( ! empty( $logic_extra ) ) { $logic_context_attr = ' data-fc-context="' . esc_attr( wp_json_encode( $logic_extra ) ) . '"'; } } // Save & Resume config, embedded once here rather than fetched by a // second request — a resumable draft is detected server-side // (either an emailed ?chargeforms_resume= token, validated the same // way any other draft REST call validates one, or a logged-in // visitor's own prior draft for this exact form) and its // already-sanitized/re-validated values are handed straight to // save-resume.js, which only shows the "Resume?" banner — it never // applies them until the visitor explicitly confirms (this // feature's own "Restore confirmation" requirement). $draft_config = ''; if ( $save_resume_enabled ) { $resume = null; $resume_token = isset( $_GET['chargeforms_resume'] ) ? sanitize_text_field( wp_unslash( $_GET['chargeforms_resume'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a bearer token, not a state-changing action; verified by hash lookup + expiry inside Draft_Service::restore(), not by a nonce. if ( $resume_token ) { $resume = Draft_Service::restore( $form, $resume_token ); } elseif ( is_user_logged_in() ) { $resume = Draft_Service::restore_for_user( $form, get_current_user_id() ); } $draft_config = wp_json_encode( [ 'enabled' => true, 'restUrl' => esc_url_raw( rest_url( 'chargeforms/v1' ) ), 'formId' => $form['id'], 'nonce' => wp_create_nonce( 'chargeforms_draft_' . $form['id'] ), // The draft REST routes are registered public (permission_callback: // true) and rely on the ChargeForms-specific nonce above for CSRF // protection, not WordPress's own REST cookie auth — but WP core's // rest_cookie_check_errors() still silently treats ANY REST // request as anonymous (current_user = 0) whenever cookies are // present without this standard 'wp_rest' nonce header attached, // even on a fully public route. Left out, a logged-in visitor's // browser would generate this nonce as themselves (an ordinary // page render, unaffected by that REST-only rule) but then fail // verification on every draft save (the REST request re-checks it // as anonymous) — confirmed by live testing against a real // WordPress instance, not something a stubbed-wp_verify_nonce() // unit test can catch. save-resume.js sends this as the // X-WP-Nonce header on every draft request so verification runs // under the SAME user context it was generated in, for both // anonymous and logged-in visitors alike. 'restNonce' => wp_create_nonce( 'wp_rest' ), 'autosaveSeconds' => (int) Settings::get( 'save_resume_autosave_seconds', 20 ), 'token' => $resume['token'] ?? '', 'resume' => $resume, ] ); } ob_start(); ?> has_file_field( $form['fields'] ) ? ' enctype="multipart/form-data"' : ''; ?>> payment_wiring_notice( $form['fields'] ); // phpcs:ignore ?>
$page ) : ?>
$page ) : ?>
string, 'fields' => array ], ... ] — always at * least one page, even for forms with no page breaks. */ private function split_into_pages( array $fields ) { $pages = [ [ 'title' => __( 'Step 1', 'chargeforms' ), 'fields' => [] ] ]; foreach ( $fields as $field ) { if ( 'page_break' === $field['type'] ) { $pages[] = [ 'title' => ! empty( $field['label'] ) ? $field['label'] : sprintf( /* translators: %d: step number */ __( 'Step %d', 'chargeforms' ), count( $pages ) + 1 ), 'fields' => [], ]; continue; } $pages[ count( $pages ) - 1 ]['fields'][] = $field; } return $pages; } /** * Container node types the builder has supported since Milestone 2 * (unlimited nesting — see assets/src/builder/core/tree.js) that this * file's rendering now walks recursively as of Milestone 5. Mirrors * assets/src/builder/fields/classifications.js's CONTAINER_TYPES. */ const CONTAINER_TYPES = [ 'section', 'row', 'column', 'container', 'repeat_container', 'tabs', 'tab_pane', 'accordion', 'accordion_panel' ]; /** * Recursively flattens a field tree (including CONTAINER_TYPES' * children) into one flat list — mirrors * assets/src/builder/core/tree.js's flattenFields(). Needed because the * enqueue/detection helpers below (has_field_type, * has_conditional_fields, and by extension has_file_field/ * has_payment_field) previously only scanned top-level fields; a * payment/file/mask/signature/conditional field nested inside a * Section/Row/Column/Container would otherwise silently fail to * trigger the script it needs. */ private function flatten_fields( array $fields ) { $flat = []; foreach ( $fields as $field ) { $flat[] = $field; if ( ! empty( $field['children'] ) && is_array( $field['children'] ) ) { $flat = array_merge( $flat, $this->flatten_fields( $field['children'] ) ); } } return $flat; } private function has_field_type( array $fields, $type ) { foreach ( $this->flatten_fields( $fields ) as $field ) { if ( $type === $field['type'] ) { return true; } } return false; } /** * Payment Item / Custom Amount only ever add a line item to the * commerce total (see COMMERCE_FIELD_TYPES and assets/js/frontend.js's * computeCommerce()) — neither one collects a card or actually charges * anyone. That happens exclusively in a Payment Method field (Stripe * or PayPal). A form with a priced field but no Payment Method field * is real, confirmed dead weight: it shows a total, visitors fill it * in, and nothing ever bills them — silently, with no error anywhere. * Admin-only, same discipline as the missing-API-key notices above. */ private function payment_wiring_notice( array $fields ) { if ( ! current_user_can( 'chargeforms_manage_forms' ) ) { return ''; } $has_priced_field = $this->has_field_type( $fields, 'payment_item' ) || $this->has_field_type( $fields, 'custom_amount' ); if ( ! $has_priced_field || $this->has_field_type( $fields, 'payment_method' ) ) { return ''; } return '

' . esc_html__( 'This form has a Payment Item or Custom Amount field but no Payment Method field — it will show a price, but nothing collects payment or charges the visitor. Add a Payment Method field (Stripe or PayPal) so the total is actually charged. This message only shows to admins.', 'chargeforms' ) . '

'; } private function has_file_field( array $fields ) { return $this->has_field_type( $fields, 'file' ) || $this->has_field_type( $fields, 'image_upload' ) || $this->has_field_type( $fields, 'featured_image' ); } /** `$gateway` defaults unset payment_method fields to 'stripe', same convention as render_payment_method() — a form saved before PayPal existed must still enqueue Stripe's script, not neither. */ private function has_payment_field_for_gateway( array $fields, $gateway ) { foreach ( $this->flatten_fields( $fields ) as $field ) { if ( 'payment_method' === $field['type'] && $gateway === ( $field['gateway'] ?? 'stripe' ) ) { return true; } } return false; } /** Milestone 5C — any field type the client-side commerce calculator (assets/js/frontend.js's computeCommerce()) needs to run for. */ const COMMERCE_FIELD_TYPES = [ 'product', 'quantity', 'shipping', 'tax', 'discount', 'subtotal', 'total', 'math_field', 'payment_summary' ]; private function has_commerce_field( array $fields ) { foreach ( self::COMMERCE_FIELD_TYPES as $type ) { if ( $this->has_field_type( $fields, $type ) ) { return true; } } return false; } private function has_conditional_fields( array $fields ) { foreach ( $this->flatten_fields( $fields ) as $field ) { $rule = $field['logicRule'] ?? null; if ( ! empty( $rule['enabled'] ) && ! empty( $rule['trigger']['children'] ) ) { return true; } } return false; } /** * Builds the inline style for one column node. The width is emitted as * CSS custom properties rather than a direct flex-basis/max-width so the * stylesheet can layer per-breakpoint overrides on top (see * frontend.css's .chargeforms-column rules) - an inline flex-basis would * outrank every media query in the sheet, which is exactly why * per-device widths were impossible before. * * The desktop value lives in `width` (the key every form saved before * this existed already has); `widthTablet`/`widthMobile` are optional * overrides. A blank override means "inherit the next-wider device", * which the CSS var() fallback chain handles without extra markup. */ private function column_width_style( array $column ) { $style = '--fc-col:' . $this->normalize_column_width( $column['width'] ?? '', '50' ) . '%;'; $tablet = $this->normalize_column_width( $column['widthTablet'] ?? '', '' ); $mobile = $this->normalize_column_width( $column['widthMobile'] ?? '', '' ); if ( '' !== $tablet ) { $style .= '--fc-col-tablet:' . $tablet . '%;'; } if ( '' !== $mobile ) { $style .= '--fc-col-mobile:' . $mobile . '%;'; } return $style; } /** * Column widths are author-supplied but land in a style attribute, so * they are clamped to 1-100 and rejected outright if non-numeric - * never passed through as free text. Returns the fallback (itself '' * to mean "inherit") when there is no usable value. */ private function normalize_column_width( $value, $fallback ) { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; if ( '' === $value || ! is_numeric( $value ) ) { return $fallback; } $width = (float) $value; $width = max( 1.0, min( 100.0, $width ) ); // Trim the pointless trailing zeroes number_format adds (50.00 -> 50). return rtrim( rtrim( number_format( $width, 2, '.', '' ), '0' ), '.' ); } /** * Milestone 5 — real recursive rendering for the four container types * the builder has supported since Milestone 2 but this file never * rendered (confirmed gap, see FEATURE_STATUS.md's "Container Fields * (0 of 4)" entry — not a regression). `row`'s children are always * `column` nodes (enforced builder-side, see * assets/src/builder/fields/classifications.js's CONTAINER_TYPES doc); * everything else recurses back through render_field_row() exactly the * way the top-level page loop in render_form_html() already does. */ private function render_container( array $field ) { $children = $field['children'] ?? []; if ( 'row' === $field['type'] ) { ?>
conditional_attrs( $field ), [] ); ?>>
render_field_row( $child ); ?>
render_tabs( $field ); return; } if ( 'accordion' === $field['type'] ) { $this->render_accordion( $field ); return; } if ( 'repeat_container' === $field['type'] ) { $this->render_repeater( $field ); return; } $wrapper_classes = [ 'chargeforms-container', 'chargeforms-container-' . $field['type'] ]; if ( ! empty( $field['containerClass'] ) ) { $wrapper_classes[] = $field['containerClass']; } ?>
conditional_attrs( $field ), [] ); ?>>

render_field_row( $child ); ?>
conditional_attrs( $field ), [] ); ?>>
$pane ) : $is_active = 0 === $index; ?>
$pane ) : $is_active = 0 === $index; ?>
render_field_row( $child ); ?>
/ — real expand/collapse with zero JS required, full keyboard operability and screen-reader semantics for free from the browser itself. */ private function render_accordion( array $field ) { $panels = $field['children'] ?? []; ?>
conditional_attrs( $field ), [] ); ?>> $panel ) : ?>
>
render_field_row( $child ); ?>
render_field_row( $child ); } $template_html = ob_get_clean(); ?>
rewrite_repeater_names( $template_html, $repeater_id, $i ); // phpcs:ignore ?>
$min_rows ) : ?>
copy JS clones — assets/js/frontend.js's addRepeaterRow() does the same substitution client-side for a freshly-cloned row. */ private function rewrite_repeater_names( $html, $repeater_id, $index ) { $html = preg_replace( '/chargeforms_data\[([a-zA-Z0-9_]+)\]/', 'chargeforms_data[' . $repeater_id . '][' . $index . '][$1]', $html ); return preg_replace( '/(id|for)="chargeforms-([a-zA-Z0-9_-]+)"/', '$1="chargeforms-' . $repeater_id . '-' . $index . '-$2"', $html ); } /** * Renders one node from the field tree: a container (recurses via * render_container()) or a leaf field. `isHidden` (Milestone 5's * per-instance "kept in the form, not shown to visitors" capability — * see FieldSettings.js) short-circuits before either path; it's * distinct from the existing `hidden` field TYPE (an actual HTML * hidden input, still rendered) handled further down. */ private function render_field_row( array $field ) { if ( ! empty( $field['isHidden'] ) ) { return; } if ( in_array( $field['type'], self::CONTAINER_TYPES, true ) ) { $this->render_container( $field ); return; } if ( 'section_break' === $field['type'] ) { ?>
conditional_attrs( $field ), [] ); ?>>

conditional_attrs( $field ), [] ); ?>>
render_field( $field ); return; } if ( 'payment_item' === $field['type'] ) { printf( '
%s%s
', esc_attr( (float) ( $field['amount'] ?? 0 ) ), esc_html( $field['label'] ), esc_html( $this->format_money( (float) ( $field['amount'] ?? 0 ) ) ) ); return; } if ( 'payment_summary' === $field['type'] ) { printf( '
%s %s
', esc_html__( 'Total:', 'chargeforms' ), esc_html( $this->format_money( 0 ) ) ); return; } // Milestone 5C — Commerce & Calculation fields. Product/Price are // static "this costs $X" lines (same shape as the pre-existing // Payment Item above, just semantically distinct types); Subtotal/ // Tax/Discount/Total are computed DISPLAY lines with no submitted // value of their own, live-updated by assets/js/frontend.js's // computeCommerce() via their data-fc-display attribute. Initial // server-rendered numbers are 0/blank — real values only exist // client-side once the visitor interacts with the form, same // honesty-about-what's-static-vs-live approach Payment Summary // above already established. if ( 'product' === $field['type'] || 'price' === $field['type'] ) { printf( '
%s%s
', esc_attr( (float) ( $field['price'] ?? 0 ) ), esc_html( $field['label'] ), esc_html( $this->format_money( (float) ( $field['price'] ?? 0 ) ) ) ); return; } if ( 'subtotal' === $field['type'] ) { printf( '
%s%s
', esc_html( $field['label'] ?: __( 'Subtotal', 'chargeforms' ) ), esc_html( $this->format_money( 0 ) ) ); return; } if ( 'tax' === $field['type'] ) { printf( '
%s%s
', esc_attr( (float) ( $field['rate'] ?? 0 ) ), esc_html( $field['label'] ?: __( 'Tax', 'chargeforms' ) ), esc_html( $this->format_money( 0 ) ) ); return; } if ( 'discount' === $field['type'] ) { printf( '
%s-%s
', esc_attr( $field['discountType'] ?? 'fixed' ), esc_attr( (float) ( $field['discountValue'] ?? 0 ) ), esc_html( $field['label'] ?: __( 'Discount', 'chargeforms' ) ), esc_html( $this->format_money( 0 ) ) ); return; } if ( 'total' === $field['type'] ) { printf( '
%s%s
', esc_html( $field['label'] ?: __( 'Total', 'chargeforms' ) ), esc_html( $this->format_money( 0 ) ) ); return; } if ( 'formula' === $field['type'] ) { printf( '
%s0
', esc_html( $field['label'] ?? __( 'Calculated value', 'chargeforms' ) ), esc_attr( $field['expression'] ?? '' ), esc_attr( $field['id'] ), 'chargeforms_data[' . esc_attr( $field['id'] ) . ']' ); return; } if ( 'quiz_score' === $field['type'] ) { printf( '
%4$s0
', esc_attr( $field['id'] ), esc_attr( wp_json_encode( is_array( $field['quizRules'] ?? null ) ? $field['quizRules'] : [] ) ), esc_attr( wp_json_encode( is_array( $field['quizResults'] ?? null ) ? $field['quizResults'] : [] ) ), esc_html( $field['label'] ?? __( 'Quiz score', 'chargeforms' ) ) ); return; } // Math Field IS a real (if simple) two-operand calculation — // live-computed client-side from two other fields' current values, // distinct from Formula's honestly-unevaluated expression above. if ( 'math_field' === $field['type'] ) { printf( '
%s0
', esc_html( $field['label'] ), esc_attr( $field['fieldA'] ?? '' ), esc_attr( $field['fieldB'] ?? '' ), esc_attr( $field['operator'] ?? '+' ), esc_attr( $field['id'] ), 'chargeforms_data[' . esc_attr( $field['id'] ) . ']' ); return; } if ( 'address' === $field['type'] ) { $this->render_address_field( $field ); return; } if ( 'social_links' === $field['type'] ) { $this->render_social_links_field( $field ); return; } $wrapper_classes = [ 'chargeforms-field', 'chargeforms-field-' . $field['type'] ]; if ( ! empty( $field['labelPlacement'] ) && 'default' !== $field['labelPlacement'] ) { $wrapper_classes[] = 'chargeforms-field-label-' . $field['labelPlacement']; } if ( ! empty( $field['containerClass'] ) ) { $wrapper_classes[] = $field['containerClass']; } ?>
conditional_attrs( $field ), [] ); ?>> render_field( $field ); ?>
— the set that supports placeholder/default/prefix/suffix/max-length. */ const TEXT_LIKE_TYPES = [ 'text', 'email', 'number', 'phone', 'url', 'password', 'name', 'post_title', 'tags' ]; /** Plain data for the Country List field — mirrors assets/src/builder/fields.js COUNTRIES exactly. */ const COUNTRIES = [ 'United States', 'United Kingdom', 'Canada', 'Australia', 'Germany', 'France', 'Italy', 'Spain', 'Netherlands', 'Belgium', 'Switzerland', 'Sweden', 'Norway', 'Denmark', 'Finland', 'Ireland', 'Portugal', 'Austria', 'Poland', 'Greece', 'India', 'Pakistan', 'Bangladesh', 'China', 'Japan', 'South Korea', 'Singapore', 'Malaysia', 'Indonesia', 'Philippines', 'Vietnam', 'Thailand', 'United Arab Emirates', 'Saudi Arabia', 'Qatar', 'Turkey', 'Egypt', 'South Africa', 'Nigeria', 'Kenya', 'Brazil', 'Mexico', 'Argentina', 'Chile', 'Colombia', 'Peru', 'New Zealand', ]; /** * The Address field is a composite of five sub-inputs sharing one field * id (chargeforms_data[id][street], [city], [state], [zip], [country]) — * stored and validated as one associative array, not five separate * fields, so it stays a single row in Entries rather than five. */ private function render_address_field( array $field ) { $id = esc_attr( $field['id'] ); $name = 'chargeforms_data[' . esc_attr( $field['id'] ) . ']'; $provider = $field['autocompleteProvider'] ?? 'default'; if ( 'default' === $provider ) { $provider = Settings::get( 'address_autocomplete_provider', 'none' ); } $autocomplete_on = in_array( $provider, [ 'osm', 'google' ], true ); ?>
conditional_attrs( $field ), [] ); ?> data-fc-address-provider="" data-fc-address-geolocation="" data-fc-address-map="1" data-fc-address-save-coords="1" >
>
', $id, esc_attr( $index ), $name, esc_attr( $index ), esc_attr__( 'https://…', 'chargeforms' ) ); }; ?> true, 'class' => true, 'title' => true, 'style' => true, 'aria-label' => true, 'aria-describedby' => true, 'aria-required' => true, 'data-fc-field-id' => true, 'data-fc-rule' => true, ]; foreach ( [ 'data-chains', 'data-digits', 'data-digits-message', 'data-fc-address-geolocation', 'data-fc-address-map', 'data-fc-address-provider', 'data-fc-address-save-coords', 'data-fc-commerce-amount', 'data-fc-commerce-qty', 'data-fc-commerce-shipping', 'data-fc-context', 'data-fc-cost', 'data-fc-custom-amount', 'data-fc-discount', 'data-fc-discount-type', 'data-fc-discount-value', 'data-fc-display', 'data-fc-draft-banner', 'data-fc-draft-discard', 'data-fc-draft-email-btn', 'data-fc-draft-email-input', 'data-fc-draft-resume', 'data-fc-draft-save', 'data-fc-draft-saved-at', 'data-fc-draft-status', 'data-fc-formula', 'data-fc-formula-expression', 'data-fc-formula-id', 'data-fc-mask', 'data-fc-math', 'data-fc-math-a', 'data-fc-math-b', 'data-fc-math-id', 'data-fc-math-op', 'data-fc-payment-amount', 'data-fc-payment-total', 'data-fc-paypal-config', 'data-fc-quiz-id', 'data-fc-quiz-result', 'data-fc-quiz-results', 'data-fc-quiz-rules', 'data-fc-quiz-score', 'data-fc-quiz-value', 'data-fc-ranking', 'data-fc-repeater', 'data-fc-repeater-max', 'data-fc-repeater-min', 'data-fc-signature', 'data-fc-stripe-config', 'data-fc-tab-index', 'data-fc-tab-panel', 'data-fc-tabs', 'data-fc-tax-rate', 'data-fc-unit-price', 'data-format-message', 'data-n', 'data-required-message', 'data-sitekey', 'data-step', 'data-value', ] as $data_attribute ) { $common[ $data_attribute ] = true; } $allowed['input'] = array_merge( $common, [ 'type' => true, 'name' => true, 'value' => true, 'placeholder' => true, 'required' => true, 'readonly' => true, 'disabled' => true, 'checked' => true, 'min' => true, 'max' => true, 'step' => true, 'multiple' => true, 'accept' => true, 'minlength' => true, 'maxlength' => true, 'pattern' => true, 'autocomplete' => true, 'tabindex' => true, ] ); $allowed['select'] = array_merge( $common, [ 'name' => true, 'required' => true, 'disabled' => true, 'multiple' => true ] ); $allowed['option'] = [ 'value' => true, 'selected' => true, 'disabled' => true ]; $allowed['textarea'] = array_merge( $common, [ 'name' => true, 'placeholder' => true, 'required' => true, 'readonly' => true, 'disabled' => true, 'rows' => true, 'cols' => true ] ); $allowed['label'] = array_merge( $common, [ 'for' => true ] ); $allowed['button'] = array_merge( $common, [ 'type' => true, 'name' => true, 'value' => true, 'disabled' => true ] ); $allowed['form'] = array_merge( $common, [ 'method' => true, 'action' => true, 'enctype' => true, 'novalidate' => true, 'data-chargeforms-track-url' => true, 'data-chargeforms-nonce-url' => true, 'data-chargeforms-rest-nonce' => true, 'data-fc-draft-config' => true, 'data-fc-logic-context' => true ] ); $allowed['svg'] = array_merge( $common, [ 'width' => true, 'height' => true, 'viewbox' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'aria-hidden' => true, 'focusable' => true ] ); foreach ( [ 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse' ] as $svg_tag ) { $allowed[ $svg_tag ] = [ 'd' => true, 'cx' => true, 'cy' => true, 'r' => true, 'rx' => true, 'ry' => true, 'x' => true, 'y' => true, 'x1' => true, 'x2' => true, 'y1' => true, 'y2' => true, 'width' => true, 'height' => true, 'points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true ]; } return apply_filters( 'chargeforms_allowed_form_html', $allowed ); } private function render_field( array $field ) { // the reference product parity — its free tier docs this as "populate from // URL parameters": a field's static Default Value can instead // contain {query.param_name} placeholders, resolved once here // against the CURRENT page load's own query string (not the // referer's — Context_Builder::current_request_query_params() was // already written for exactly this and sanitizes every value, but // had no caller anywhere in the codebase until now). Resolved once // per field, before any of the per-type render branches below read // $field['default'], so every field type gets this for free without // each one needing its own substitution call. if ( ! empty( $field['default'] ) && is_string( $field['default'] ) && false !== strpos( $field['default'], '{query.' ) ) { $field['default'] = self::resolve_query_placeholders( $field['default'] ); } $id = esc_attr( $this->field_html_id( $field ) ); $name = 'chargeforms_data[' . esc_attr( $field['id'] ) . ']'; $extra = $this->extra_input_attrs( $field ); /** * Lets an add-on render a field type this switch doesn't know about. * * Returning a non-null string short-circuits the built-in rendering * entirely, which is what a genuinely new field type needs — the * switch below has no default branch, so without this an unknown * type renders nothing at all and the field silently vanishes from * the form. * * The returned markup is echoed as-is, so a listener owns escaping * its own output. It must also use the $name passed here for its * input, or the submitted value won't reach $values under the * field's id. * * The form id is passed explicitly because a listener has no other * reliable way to get it — the field array doesn't carry it, and * get_the_ID() is the post the form is embedded in, not the form. * * @param string|null $html Null to fall through to built-in rendering. * @param array $field The field definition. * @param string $name The input name this field must post under. * @param string $id The field's HTML id. * @param int $form_id The form being rendered. */ $custom = apply_filters( 'chargeforms_render_custom_field', null, $field, $name, $id, $this->current_form_id ); if ( null !== $custom ) { echo wp_kses( $custom, self::allowed_form_html() ); return; } // TEXT_LIKE_TYPES delegates to render_text_like(), which computes // its own $required + tabindex independently — must not also // increment the tabindex counter here, or those fields double-count. if ( in_array( $field['type'], self::TEXT_LIKE_TYPES, true ) ) { $html_types = [ 'name' => 'text', 'phone' => 'tel' ]; $this->render_text_like( $field, $html_types[ $field['type'] ] ?? $field['type'] ); return; } $required = $this->required_attr( $field ); switch ( $field['type'] ) { case 'textarea': case 'post_content': case 'post_excerpt': $validation = is_array( $field['validation'] ?? null ) ? $field['validation'] : []; $constraints = ''; if ( ! empty( $validation['minLength'] ) ) { $constraints .= sprintf( ' minlength="%d"', (int) $validation['minLength'] ); } if ( ! empty( $validation['customMessage'] ) ) { $constraints .= sprintf( ' title="%s"', esc_attr( $validation['customMessage'] ) ); } printf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( $field['elementClass'] ?? '' ), esc_attr( ! empty( $field['placeholder'] ) ? $field['placeholder'] : $this->default_placeholder_for( $field ) ), esc_attr( $field['maxLength'] ?? '' ), wp_kses( $constraints, [] ), wp_kses( $required, [] ), wp_kses( $extra, [] ), esc_textarea( $field['default'] ?? '' ) ); break; case 'chained_select': $chains = is_array( $field['chains'] ?? null ) ? array_values( $field['chains'] ) : []; $chains = array_values( array_filter( $chains, static function ( $chain ) { return is_array( $chain ) && ! empty( $chain['options'] ) && is_array( $chain['options'] ); } ) ); if ( empty( $chains ) ) { printf( '', $id, $name, $required, $extra, esc_html__( 'Choose an option', 'chargeforms' ) ); break; } $chain_json = wp_json_encode( $chains ); echo '
'; foreach ( $chains as $index => $chain ) { $level_id = $id . '-' . $index; printf( ''; } echo '
'; break; case 'select': printf( ''; break; case 'dynamic_field': printf( ''; break; case 'checkable_grid': $rows = array_values( array_filter( $field['options'] ?? [] ) ); $columns = array_values( array_filter( $field['gridColumns'] ?? [ 'Yes', 'No' ] ) ); echo '
'; foreach ( $columns as $column ) { printf( '', esc_html( $column ) ); } echo ''; foreach ( $rows as $row_index => $row ) { $row_label = is_array( $row ) ? ( $row['label'] ?? $row['value'] ?? $row_index ) : $row; echo ''; foreach ( $columns as $column_index => $column ) { printf( '', esc_attr( $name ), (int) $row_index, (int) $column_index, $extra ); } echo ''; } echo '
%s
' . esc_html( $row_label ) . '
'; break; case 'post_update': printf( ''; break; case 'categories': printf( ''; break; case 'radio': echo '
'; foreach ( $field['options'] ?? [] as $index => $option ) { printf( '', esc_attr( $name ), esc_attr( $option ), wp_kses( 0 === $index ? $required : '', [] ), wp_kses( $extra, [] ), esc_html( $option ) ); } echo '
'; break; case 'checkbox': printf( '', esc_attr( $id ), esc_attr( $name ), wp_kses( $required, [] ), wp_kses( $extra, [] ) ); break; case 'date': printf( '', esc_attr( $id ), esc_attr( $name ), wp_kses( $required, [] ), wp_kses( $extra, [] ) ); break; case 'time': printf( '', esc_attr( $id ), esc_attr( $name ), wp_kses( $required, [] ), wp_kses( $extra, [] ) ); break; case 'datetime': printf( '', esc_attr( $id ), esc_attr( $name ), wp_kses( $required, [] ), wp_kses( $extra, [] ) ); break; case 'quantity': printf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( $field['min'] ?? 1 ), esc_attr( $field['max'] ?? 99 ), esc_attr( $field['step'] ?? 1 ), esc_attr( $field['default'] ?? ( $field['min'] ?? 1 ) ), esc_attr( (float) ( $field['unitPrice'] ?? 0 ) ), wp_kses( $required, [] ), wp_kses( $extra, [] ) ); break; case 'coupon': printf( '
%s
', esc_attr( $id ), esc_attr( $name ), esc_attr( $field['placeholder'] ?? __( 'Enter coupon code', 'chargeforms' ) ), wp_kses( $required, [] ), wp_kses( $extra, [] ), esc_html__( 'Applied at checkout — not validated here yet.', 'chargeforms' ) ); break; case 'shipping': echo '
'; foreach ( $field['shippingOptions'] ?? [] as $index => $option ) { printf( '', esc_attr( $name ), esc_attr( $option['label'] ?? '' ), esc_attr( (float) ( $option['cost'] ?? 0 ) ), wp_kses( 0 === $index ? $this->required_attr( $field ) : '', [] ), esc_html( $option['label'] ?? '' ), esc_html( $this->format_money( (float) ( $option['cost'] ?? 0 ) ) ) ); } echo '
'; break; case 'toggle': printf( '', esc_attr( $id ), esc_attr( $name ), ! empty( $field['default'] ) ? 'checked ' : '', wp_kses( $required, [] ), wp_kses( $extra, [] ) ); break; case 'image_choice': $input_type = ! empty( $field['multiple'] ) ? 'checkbox' : 'radio'; echo '
'; foreach ( $field['imageOptions'] ?? [] as $index => $option ) { printf( '', esc_attr( $input_type ), esc_attr( $name ), 'checkbox' === $input_type ? '[]' : '', esc_attr( $option['label'] ?? '' ), wp_kses( 0 === $index ? $required : '', [] ), ! empty( $option['image'] ) ? '' : '', esc_html( $option['label'] ?? '' ) ); } echo '
'; break; case 'hidden': printf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( $field['default'] ?? '' ) ); break; case 'file': case 'featured_image': $accept = ! empty( $field['allowedExtensions'] ) ? implode( ',', array_map( fn( $ext ) => '.' . ltrim( trim( $ext ), '.' ), explode( ',', $field['allowedExtensions'] ) ) ) : ''; printf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( $accept ), wp_kses( $required, [] ), wp_kses( $extra, [] ) ); if ( ! empty( $field['allowedExtensions'] ) || ! empty( $field['maxSizeMb'] ) ) { printf( '

%s

', esc_html( sprintf( /* translators: 1: allowed extensions, 2: max size in MB */ __( 'Allowed: %1$s — max %2$sMB', 'chargeforms' ), $field['allowedExtensions'] ?? '', $field['maxSizeMb'] ?? 5 ) ) ); } break; case 'multi_select': printf( ''; break; case 'terms': case 'gdpr': case 'consent': printf( '', esc_attr( $id ), esc_attr( $name ), wp_kses( $required, [] ), wp_kses( $extra, [] ), wp_kses_post( $field['content'] ?? '' ) ); break; case 'recaptcha': $this->render_recaptcha( $id, $name ); break; case 'custom_amount': printf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( (float) ( $field['minAmount'] ?? 0 ) ), esc_attr( $this->format_money( (float) ( $field['minAmount'] ?? 0 ) ) ), wp_kses( $required, [] ), wp_kses( $extra, [] ) ); break; case 'payment_method': $this->render_payment_method( $id, $name, $field ); break; case 'hcaptcha': $this->render_hcaptcha( $id, $name ); break; case 'turnstile': $this->render_turnstile( $id, $name ); break; case 'range_slider': printf( ' %s', esc_attr( $id ), esc_attr( $name ), esc_attr( $field['min'] ?? 0 ), esc_attr( $field['max'] ?? 100 ), esc_attr( $field['step'] ?? 1 ), esc_attr( $field['default'] ?? ( $field['min'] ?? 0 ) ), wp_kses( $required, [] ), wp_kses( $extra, [] ), esc_html( $field['default'] ?? ( $field['min'] ?? 0 ) ) ); break; case 'ratings': $max = (int) ( $field['maxStars'] ?? 5 ); echo '
'; for ( $i = $max; $i >= 1; $i-- ) { printf( '', esc_attr( $id ), (int) $i, esc_attr( $name ), wp_kses( 0 === ( $max - $i ) ? $required : '', [] ) ); } echo '
'; break; case 'nps': echo '
'; for ( $i = 0; $i <= 10; $i++ ) { printf( '', esc_attr( $name ), (int) $i, wp_kses( 0 === $i ? $required : '', [] ), (int) $i ); } echo '
'; if ( ! empty( $field['lowLabel'] ) || ! empty( $field['highLabel'] ) ) { printf( '
%s%s
', esc_html( $field['lowLabel'] ?? '' ), esc_html( $field['highLabel'] ?? '' ) ); } break; case 'multiple_choice': echo '
'; foreach ( $field['options'] ?? [] as $option ) { printf( '', esc_attr( $name ), esc_attr( $option ), esc_html( $option ) ); } echo '
'; break; case 'ranking': $options = array_values( array_filter( array_map( 'strval', (array) ( $field['options'] ?? [] ) ) ) ); echo '
    '; foreach ( $options as $index => $option ) { printf( '
  1. %2$s
  2. ', esc_attr( $option ), esc_html( $option ), $name, (int) $index, esc_attr__( 'Move up', 'chargeforms' ), esc_attr__( 'Move down', 'chargeforms' ) ); } echo '
'; break; case 'country': printf( ''; break; case 'image_upload': case 'featured_image': printf( '', $id, $name, $required, wp_kses( $extra, [] ) ); break; case 'mask_input': printf( '', $id, $name, esc_attr( $field['mask'] ?? '' ), esc_attr( $this->default_placeholder_for( $field ) ), $required, wp_kses( $extra, [] ) ); break; case 'color_picker': printf( '', $id, $name, esc_attr( $field['default'] ?? '#6c4ef5' ), $required, wp_kses( $extra, [] ) ); break; case 'signature': $this->render_signature( $id, $name, $required ); break; } } /** * A real HTML5 canvas the visitor draws on (assets/js/frontend.js' * initSignatures() handles mouse/touch drawing), synced into a hidden * input as a base64 PNG data URL on every stroke. Submission_Handler * decodes and saves that as a real uploaded image file (not the raw * data URL) — see its own handle_signature_field(), same reasoning as * any other file-upload field: a data URL embedded straight into the * JSON response blob would be tens of KB of text per submission. */ private function render_signature( $id, $name, $required ) { printf( '
', esc_attr( $id ), esc_html__( 'Clear', 'chargeforms' ), esc_attr( $name ), wp_kses( $required, [] ) ); } private function render_hcaptcha( $id, $name ) { $site_key = Settings::get( 'hcaptcha_site_key' ); if ( ! $site_key ) { if ( current_user_can( 'chargeforms_manage_forms' ) ) { echo '

' . esc_html__( 'hCaptcha site key not configured — add one in ChargeForms > Global Settings. This message only shows to admins.', 'chargeforms' ) . '

'; } return; } // hCaptcha's widget script must load from hCaptcha's own CDN — it is the CAPTCHA challenge/verification client itself, not an offloadable static asset (same model the official hCaptcha plugin uses). wp_enqueue_script( 'chargeforms-hcaptcha', 'https://js.hcaptcha.com/1/api.js', [], null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- vendor-hosted CAPTCHA script, versioned by the vendor itself. printf( '
', esc_attr( $site_key ) ); printf( '', esc_attr( $id ), esc_attr( $name ) ); } private function render_turnstile( $id, $name ) { $site_key = Settings::get( 'turnstile_site_key' ); if ( ! $site_key ) { if ( current_user_can( 'chargeforms_manage_forms' ) ) { echo '

' . esc_html__( 'Cloudflare Turnstile site key not configured — add one in ChargeForms > Global Settings. This message only shows to admins.', 'chargeforms' ) . '

'; } return; } // Cloudflare Turnstile's widget script must load from Cloudflare's own CDN for the same reason as hCaptcha above. wp_enqueue_script( 'chargeforms-turnstile', 'https://challenges.cloudflare.com/turnstile/v0/api.js', [], null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- vendor-hosted CAPTCHA script, versioned by the vendor itself. printf( '
', esc_attr( $site_key ) ); printf( '', esc_attr( $id ), esc_attr( $name ) ); } /** $1.00 style formatting using the configured Stripe currency's symbol — falls back to the currency code for anything not in the common list. */ private function format_money( $amount ) { $currency = Settings::get( 'stripe_currency', 'usd' ); $symbols = [ 'usd' => '$', 'eur' => '€', 'gbp' => '£', 'cad' => 'C$', 'aud' => 'A$', 'inr' => '₹' ]; $symbol = $symbols[ $currency ] ?? ( strtoupper( $currency ) . ' ' ); return $symbol . number_format( $amount, 2 ); } /** * Mounts a Stripe Elements card input. The publishable key and REST URL * are scoped to this one field instance (not a shared global), so a page * with more than one payment form never has one form's config clobber * another's. assets/js/stripe-payment.js reads this data attribute. */ /** * `$field['gateway']` is unset for every form saved before PayPal existed * — `?? 'stripe'` is what keeps those forms rendering exactly as they * did before this field gained a second gateway option. */ private function render_payment_method( $id, $name, array $field ) { $gateway = $field['gateway'] ?? 'stripe'; if ( 'paypal' === $gateway ) { $this->render_paypal_payment_method( $id, $name ); return; } $this->render_stripe_payment_method( $id, $name, $field ); } /** * `billingType`/`billingInterval`/`trialDays` are read straight from * the field config (never re-derived) and handed to stripe-payment.js * as-is — the actual amount and recurrence are always re-verified * server-side (RestApi::create_subscription(), * SubmissionHandler::verify_stripe_subscription()) from the form's OWN * stored config, same anti-tampering discipline as the one-time flow; * this config only tells the client which REST endpoint and which * `stripe.confirm*()` call to use, it never itself authorizes a charge. * `chargeforms_subscription_id` is rendered unconditionally alongside the * existing `chargeforms_payment_intent_id`, even for a one-time field — * one is simply left empty and ignored server-side, simpler than * branching the markup on billingType here too. */ private function render_stripe_payment_method( $id, $name, array $field = [] ) { $publishable_key = Settings::get( 'stripe_publishable_key' ); if ( ! $publishable_key ) { if ( current_user_can( 'chargeforms_manage_forms' ) ) { echo '

' . esc_html__( 'Stripe publishable key not configured — add one in ChargeForms > Global Settings > Payments. This message only shows to admins.', 'chargeforms' ) . '

'; } return; } $billing_type = 'recurring' === ( $field['billingType'] ?? 'one_time' ) ? 'recurring' : 'one_time'; $config = wp_json_encode( [ 'publishableKey' => $publishable_key, 'restUrl' => esc_url_raw( rest_url( 'chargeforms/v1' ) ), 'formId' => $this->current_form_id, 'billingType' => $billing_type, ] ); printf( '
', esc_attr( $id ), esc_attr( $config ) ); } private function render_paypal_payment_method( $id, $name ) { $client_id = Settings::get( 'paypal_client_id' ); if ( ! $client_id ) { if ( current_user_can( 'chargeforms_manage_forms' ) ) { echo '

' . esc_html__( 'PayPal client ID not configured — add one in ChargeForms > Global Settings > Payment Methods. This message only shows to admins.', 'chargeforms' ) . '

'; } return; } $config = wp_json_encode( [ 'clientId' => $client_id, 'currency' => strtoupper( Settings::get( 'paypal_currency', 'usd' ) ), 'restUrl' => esc_url_raw( rest_url( 'chargeforms/v1' ) ), 'formId' => $this->current_form_id, ] ); printf( '
', esc_attr( $id ), esc_attr( $config ) ); } /** * Renders a single-line with optional prefix/suffix labels, * placeholder, default value, and max length — shared by every * TEXT_LIKE_TYPES field. `field.validation` (minLength/regex/min/max/ * customMessage — settings-panel-only until now, see Field_Validator's * doc comment) is mirrored here as native HTML5 constraint attributes, * so the browser enforces the SAME rule PHP now authoritatively * re-checks server-side, not just decorative settings-panel inputs. */ private function render_text_like( array $field, $html_type ) { $id = esc_attr( $this->field_html_id( $field ) ); $name = 'chargeforms_data[' . esc_attr( $field['id'] ) . ']'; $required = $this->required_attr( $field ); $autocomplete = 'password' === $html_type ? ' autocomplete="new-password"' : ''; $extra = $this->extra_input_attrs( $field ); $validation = is_array( $field['validation'] ?? null ) ? $field['validation'] : []; $constraints = ''; if ( ! empty( $validation['minLength'] ) ) { $constraints .= sprintf( ' minlength="%d"', (int) $validation['minLength'] ); } if ( ! empty( $validation['regex'] ) ) { $constraints .= sprintf( ' pattern="%s"', esc_attr( $validation['regex'] ) ); } if ( 'number' === $field['type'] ) { if ( isset( $validation['min'] ) && '' !== $validation['min'] ) { $constraints .= sprintf( ' min="%s"', esc_attr( $validation['min'] ) ); } if ( isset( $validation['max'] ) && '' !== $validation['max'] ) { $constraints .= sprintf( ' max="%s"', esc_attr( $validation['max'] ) ); } if ( ! empty( $validation['digits'] ) ) { $constraints .= sprintf( ' data-digits="%d" data-digits-message="%s"', (int) $validation['digits'], esc_attr( ! empty( $validation['customMessage'] ) ? $validation['customMessage'] : Settings::get( 'validation_msg_digits', sprintf( /* translators: %d: required digit count */ __( 'Must be exactly %d digits.', 'chargeforms' ), (int) $validation['digits'] ) ) ) ); } } if ( ! empty( $validation['customMessage'] ) ) { $constraints .= sprintf( ' title="%s"', esc_attr( $validation['customMessage'] ) ); } $format_rules = [ 'email' => 'validation_msg_email', 'number' => 'validation_msg_numeric', 'url' => 'validation_msg_url' ]; if ( isset( $format_rules[ $field['type'] ] ) ) { $constraints .= sprintf( ' data-format-message="%s"', esc_attr( $this->format_message_attr( $field, $field['type'], $format_rules[ $field['type'] ] ) ) ); } $has_affix = ! empty( $field['prefixLabel'] ) || ! empty( $field['suffixLabel'] ); if ( $has_affix ) { echo '
'; if ( ! empty( $field['prefixLabel'] ) ) { echo '' . esc_html( $field['prefixLabel'] ) . ''; } } printf( '', esc_attr( $html_type ), esc_attr( $id ), esc_attr( $name ), esc_attr( $field['elementClass'] ?? '' ), esc_attr( ! empty( $field['placeholder'] ) ? $field['placeholder'] : $this->default_placeholder_for( $field ) ), esc_attr( $field['default'] ?? '' ), esc_attr( $field['maxLength'] ?? '' ), esc_attr( $autocomplete ), wp_kses( $constraints, [] ), wp_kses( $required, [] ), wp_kses( $extra, [] ), 'tags' === $field['type'] ? ' list="chargeforms-' . esc_attr( $field['id'] ) . '-tag-suggestions"' : '' ); if ( 'tags' === $field['type'] ) { $tag_terms = get_terms( [ 'taxonomy' => 'post_tag', 'hide_empty' => false, 'number' => 100, 'orderby' => 'name', 'order' => 'ASC' ] ); if ( ! is_wp_error( $tag_terms ) && ! empty( $tag_terms ) ) { echo ''; foreach ( $tag_terms as $tag_term ) { printf( '', esc_attr( $tag_term->name ) ); } echo ''; } } if ( $has_affix ) { if ( ! empty( $field['suffixLabel'] ) ) { echo '' . esc_html( $field['suffixLabel'] ) . ''; } echo '
'; } } private function render_recaptcha( $id, $name ) { $site_key = Settings::get( 'recaptcha_site_key' ); if ( ! $site_key ) { if ( current_user_can( 'chargeforms_manage_forms' ) ) { echo '

' . esc_html__( 'reCAPTCHA site key not configured — add one in ChargeForms > Global Settings. This message only shows to admins.', 'chargeforms' ) . '

'; } return; } // Google reCAPTCHA's widget script must load from Google's own CDN for the same reason as hCaptcha above (the official Google reCAPTCHA plugin does the same). wp_enqueue_script( 'chargeforms-recaptcha', 'https://www.google.com/recaptcha/api.js', [], null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- vendor-hosted CAPTCHA script, versioned by the vendor itself. printf( '
', esc_attr( $site_key ) ); printf( '', esc_attr( $id ), esc_attr( $name ) ); } }