[], '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
%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
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'] ) {
?>
/ — 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'] ?? [];
?>
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'] ) {
?>
',
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(
'
', 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_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' ) . '
' . esc_html__( 'PayPal client ID not configured — add one in ChargeForms > Global Settings > Payment Methods. This message only shows to admins.', 'chargeforms' ) . '
';
}
}
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 ) );
}
}