Forms — the "All Forms" landing screen. Overwrites the * top-level menu's default submenu entry (same 'chargeforms' slug) so it reads * "Forms" instead of duplicating "ChargeForms". Registered after Menu so the * relabel trick applies — see boot order in chargeforms.php. */ class Forms_List_Page { public function __construct() { // Real bug found live: the "relabel the phantom first row as Forms" // trick below only works when THIS registration is the very FIRST // add_submenu_page() call WordPress sees for the 'chargeforms' // parent — it only suppresses its own auto-generated duplicate // entry (labeled with the top-level menu's own title) when the // matching (parent===menu_slug) submenu is registered before any // OTHER submenu under that parent. Dashboard_Page registers at // priority 8; this was still at 10, so Dashboard's registration // (a different slug) ran first and the phantom "ChargeForms" row // appeared anyway, sitting above Dashboard/Forms as a genuine // duplicate of Forms' own link. Moved to priority 6 — right after // Menu::register_menu()'s own priority 5, before anything else. add_action( 'admin_menu', [ $this, 'register' ], 6 ); add_action( 'admin_post_chargeforms_duplicate_form', [ $this, 'handle_duplicate' ] ); add_action( 'admin_post_chargeforms_delete_form', [ $this, 'handle_delete' ] ); add_action( 'admin_post_chargeforms_toggle_form_status', [ $this, 'handle_toggle_status' ] ); add_action( 'admin_post_chargeforms_bulk_forms', [ $this, 'handle_bulk_forms' ] ); add_action( 'admin_post_chargeforms_preview_form', [ $this, 'handle_preview' ] ); add_action( 'admin_post_chargeforms_export_single_form', [ $this, 'handle_export_single' ] ); add_action( 'wp_ajax_chargeforms_find_form_usage', [ $this, 'ajax_find_usage' ] ); // Body class hook: forms-list-refine.css scopes its gray-canvas // rules to body.chargeforms-screen-forms-list so they never leak // onto Entries, Reports, or Global Settings (all under the same // ChargeForms menu). add_filter( 'admin_body_class', [ $this, 'admin_body_class' ] ); } public function register() { add_submenu_page( 'chargeforms', __( 'Forms', 'chargeforms' ), __( 'Forms', 'chargeforms' ), 'chargeforms_manage_forms', 'chargeforms', [ $this, 'render' ] ); } /** * Screen-scoped body class for the All Forms screen: the reference product skin paints * the gray #f2f2f2 page canvas only on page=chargeforms (forms-list-refine.css), * never on the sibling screens that share this admin body. * * @param string $classes Space-separated admin body classes. * @return string */ public function admin_body_class( $classes ) { if ( isset( $_GET['page'] ) && 'chargeforms' === sanitize_key( wp_unslash( $_GET['page'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $classes .= ' chargeforms-screen-forms-list'; } return $classes; } public function render() { $query_args = $this->parse_query_args(); $result = Form::query( $query_args ); // One GROUP BY query for the whole page of forms instead of one // Entry::count_for_form() call per row (roadmap issue #36) — this // page is paginated, so the win is smaller than the Reports page's // identical fix (all-forms, not one-page), but still a real, // free reduction for every page load regardless. $entry_counts = Entry::count_by_form( array_column( $result['items'], 'id' ) ); // Fields/Views/Conversion — real gap found live (user report): these // three columns exist in design_handoff_chargeforms's own Forms List // spec but were never actually implemented anywhere (neither this // page nor the now-retired Vue rewrite had them). One GROUP BY query // for the whole page's view counts, same reasoning as $entry_counts // above — not one Form_Start::count_for_form() call per row. $view_counts = Form_Start::count_by_form( array_column( $result['items'], 'id' ), 'view' ); ?>

render_mode() ) : ?>
render_toolbar( $query_args, $result['total'] ); ?> render_bulk_bar(); ?>
0 ? round( ( $entries / $views ) * 100, 1 ) : null; /* translators: %d: number of fields in the form */ $meta_parts = [ sprintf( _n( '%d field', '%d fields', $field_count, 'chargeforms' ), $field_count ) ]; if ( Commerce_Calculator::find_payment_method_field( $form['fields'] ?? [] ) ) { $meta_parts[] = __( 'payment', 'chargeforms' ); } $meta_text = implode( ' · ', $meta_parts ); ?>
render_pagination( $result, $query_args ); ?>
resolve_date_preset( $date_preset, isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : '' // phpcs:ignore WordPress.Security.NonceVerification.Recommended ); return [ 'status' => isset( $_GET['category'] ) && in_array( $_GET['category'], [ 'published', 'draft' ], true ) ? sanitize_key( wp_unslash( $_GET['category'] ) ) : 'all', // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'category' => isset( $_GET['category'] ) && 'multistep' === $_GET['category'] ? 'multistep' : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'search' => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'sort' => isset( $_GET['sort'] ) ? sanitize_key( wp_unslash( $_GET['sort'] ) ) : 'newest', // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'page' => isset( $_GET['paged'] ) ? max( 1, (int) $_GET['paged'] ) : 1, // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'per_page' => isset( $_GET['per_page'] ) ? min( 200, max( 1, (int) $_GET['per_page'] ) ) : 10, // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'date_from' => $date_from, 'date_to' => $date_to, ]; } /** * Turns a preset key (or an explicit custom range) into * [ date_from, date_to ] — both '' when no filter applies. Mirrors * Entries_Page's identical helper, including the GMT-not-site-local * `$today` fix (roadmap issue #27 — see that copy's doc comment for * why: this range gets compared against `created_at` via gmdate()- * built boundaries elsewhere, so `$today` needs the same reference * frame). */ private function resolve_date_preset( $preset, $custom_from, $custom_to ) { $today = current_time( 'Y-m-d', true ); switch ( $preset ) { case 'today': return [ $today, $today ]; case 'yesterday': $y = gmdate( 'Y-m-d', strtotime( $today . ' -1 day' ) ); return [ $y, $y ]; case 'last_week': return [ gmdate( 'Y-m-d', strtotime( $today . ' -7 days' ) ), $today ]; case 'last_month': return [ gmdate( 'Y-m-d', strtotime( $today . ' -30 days' ) ), $today ]; case 'custom': return [ $custom_from, $custom_to ]; default: return [ '', '' ]; } } /** The single "Category" select — All / Active / Inactive / Multi-step Form. Payment Form and Conversational Form aren't real ChargeForms categories yet, so they're intentionally not offered here rather than faked. */ private function current_category( array $query_args ) { if ( 'multistep' === $query_args['category'] ) { return 'multistep'; } if ( 'published' === $query_args['status'] ) { return 'published'; } if ( 'draft' === $query_args['status'] ) { return 'draft'; } return 'all'; } private function render_toolbar( array $query_args, $total = 0 ) { $category = $this->current_category( $query_args ); $date_preset = isset( $_GET['date_preset'] ) ? sanitize_key( wp_unslash( $_GET['date_preset'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $clear_url = remove_query_arg( [ 'date_preset', 'date_from', 'date_to' ] ); ?>
> –
current_category( $query_args ); $base_args = array_filter( [ 'page' => 'chargeforms', 'category' => 'all' !== $category ? $category : null, 's' => '' !== $query_args['search'] ? $query_args['search'] : null, 'sort' => 'newest' === $query_args['sort'] ? null : $query_args['sort'], 'date_from' => $query_args['date_from'] ?: null, 'date_to' => $query_args['date_to'] ?: null, 'per_page' => $result['per_page'], ] ); $first = ( $result['page'] - 1 ) * $result['per_page'] + 1; $last = min( $result['page'] * $result['per_page'], $result['total'] ); ?>
$value ) : ?>
‹ ›
$id > 0 ) ); foreach ( $form_ids as $form_id ) { if ( 'duplicate' === $bulk_action ) { Form::duplicate( $form_id ); } elseif ( 'delete' === $bulk_action ) { Form::delete( $form_id ); } } wp_safe_redirect( admin_url( 'admin.php?page=chargeforms&chargeforms_notice=1' ) ); exit; } public function handle_duplicate() { $form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_duplicate_' . $form_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } Form::duplicate( $form_id ); wp_safe_redirect( admin_url( 'admin.php?page=chargeforms&chargeforms_notice=1' ) ); exit; } public function handle_delete() { $form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_delete_form_' . $form_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } Form::delete( $form_id ); wp_safe_redirect( admin_url( 'admin.php?page=chargeforms&chargeforms_notice=1' ) ); exit; } public function handle_toggle_status() { $form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0; $to = isset( $_GET['to'] ) ? sanitize_key( $_GET['to'] ) : 'published'; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_toggle_status_' . $form_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } Form::update( $form_id, [ 'status' => in_array( $to, [ 'published', 'draft' ], true ) ? $to : 'published' ] ); wp_safe_redirect( wp_get_referer() ?: admin_url( 'admin.php?page=chargeforms' ) ); exit; } /** * The "Find" row action — answers "where is this form actually used on * the site?" by scanning post_content for the form's shortcode, rather * than making the site owner guess or check every page by hand. Casts a * broad net at the database level (any post containing the literal * "[chargeforms" substring, which is cheap to index-scan even without a * dedicated index since it's a bounded LIMIT) and narrows to the exact * form id in PHP via a word-boundary-aware regex — a plain `LIKE * '%id="5"%'` would also match id="50", id="51", etc., which is a real * false-positive risk once a site has more than a handful of forms. */ public function ajax_find_usage() { if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! check_ajax_referer( 'chargeforms_find_usage', 'nonce', false ) ) { wp_send_json_error( [ 'message' => __( 'Not allowed.', 'chargeforms' ) ], 403 ); } $form_id = isset( $_POST['form_id'] ) ? (int) $_POST['form_id'] : 0; if ( ! $form_id ) { wp_send_json_error( [ 'message' => __( 'Invalid form.', 'chargeforms' ) ], 400 ); } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- no Form/Entry model wraps a cross-post-table content scan; bounded by LIMIT below. $candidates = $wpdb->get_results( "SELECT ID, post_title, post_type, post_status, post_content FROM {$wpdb->posts} WHERE post_status NOT IN ( 'trash', 'auto-draft' ) AND post_type NOT IN ( 'revision', 'attachment' ) AND post_content LIKE '%[chargeforms%' ORDER BY post_modified DESC LIMIT 500" ); $pattern = '/\[chargeforms\b[^\]]*\bid=[\'"]?' . preg_quote( (string) $form_id, '/' ) . '[\'"]?(?=[\s\]])[^\]]*\]/'; $items = []; foreach ( $candidates as $row ) { if ( ! preg_match( $pattern, $row->post_content ) ) { continue; } $post_type_obj = get_post_type_object( $row->post_type ); $items[] = [ 'title' => $row->post_title ? $row->post_title : __( '(no title)', 'chargeforms' ), 'edit_url' => get_edit_post_link( $row->ID, 'raw' ), 'view_url' => 'publish' === $row->post_status ? get_permalink( $row->ID ) : '', 'type' => $post_type_obj ? $post_type_obj->labels->singular_name : $row->post_type, 'status' => get_post_status_object( $row->post_status ) ? get_post_status_object( $row->post_status )->label : $row->post_status, ]; if ( count( $items ) >= 50 ) { break; } } wp_send_json_success( [ 'items' => $items, 'scanned_limit' => 500 === count( $candidates ) ] ); } /** Renders the form standalone in a new tab, bypassing the published-only check the real shortcode enforces. */ /** * The real "Preview & Design" screen — opens in its own tab, a browser- * chrome mockup with Desktop/Tablet/Mobile width toggles around the * actual rendered form, plus a mini top-nav back into the builder/ * settings/entries. Deliberately does NOT include a marketing panel * listing paid-tier features ChargeForms doesn't have (the real product's * equivalent screen does) — that would misrepresent what upgrading * (there is no paid tier yet) would actually unlock. */ public function handle_preview() { $form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_preview_' . $form_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } $form = Form::get( $form_id ); if ( ! $form ) { wp_die( esc_html__( 'Form not found.', 'chargeforms' ), 404 ); } /* * register_assets() normally runs on 'wp_enqueue_scripts', which this * admin-post.php request never fires — register and enqueue the * style directly, and give add-ons the same chance via an action * hook. A field type an add-on contributes (ChargeForms Bookings' * Booking Calendar, e.g.) enqueues its OWN script/style from inside * its render callback below, same as every field render always has * — but that call is a silent no-op unless the handle was already * registered, which normally happens on 'wp_enqueue_scripts' too. * Real bug found live: without this, Booking Calendar previewed as * an empty shell (no calendar grid, no console error either) since * its picker script/style never got registered at all on this one * request type. */ wp_register_style( 'chargeforms-frontend', CHARGEFORMS_URL . 'assets/css/frontend.css', [], CHARGEFORMS_VERSION ); wp_enqueue_style( 'chargeforms-frontend' ); wp_add_inline_style( 'chargeforms-frontend', ' * { box-sizing: border-box; } body { margin: 0; background: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #16181d; } .fcp-topbar { display: flex; align-items: center; gap: 28px; background: #fff; border-bottom: 1px solid rgba(15,23,42,.08); padding: 16px 28px; } .fcp-topbar h1 { font-size: 16px; margin: 0; font-weight: 700; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 240px; } .fcp-topbar nav { display: flex; gap: 24px; flex: 1; } .fcp-topbar nav a { color: #606266; text-decoration: none; font-size: 13px; font-weight: 600; transition: color .12s ease; } .fcp-topbar nav a:hover { color: #16181d; } .fcp-topbar-right { display: flex; align-items: center; gap: 12px; } .fcp-preview-only { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #606266; } .fcp-shortcode { display: inline-flex; align-items: center; gap: 6px; background: #f5f7fa; border: 1px solid rgba(15,23,42,.08); border-radius: 8px; padding: 7px 12px; font-size: 12px; font-family: monospace; color: #606266; } .fcp-body { display: flex; align-items: flex-start; gap: 28px; padding: 32px; } .fcp-frame-col { flex: 1; min-width: 0; } .fcp-chrome { background: #fff; border: 1px solid rgba(15,23,42,.08); border-radius: 16px; box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 20px -6px rgba(15,23,42,.10); overflow: hidden; } .fcp-chrome-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; background: #fafbfc; border-bottom: 1px solid rgba(15,23,42,.08); } .fcp-dots { display: flex; gap: 7px; } .fcp-dots span { width: 10px; height: 10px; border-radius: 50%; } .fcp-dots span:nth-child(1) { background: #e5484d; } .fcp-dots span:nth-child(2) { background: #f0a020; } .fcp-dots span:nth-child(3) { background: #16a672; } .fcp-devices { display: flex; gap: 4px; } .fcp-devices button { display: flex; align-items: center; justify-content: center; width: 32px; height: 28px; border: 1px solid transparent; background: none; border-radius: 8px; cursor: pointer; color: #606266; transition: background .12s ease, color .12s ease; } .fcp-devices button.is-active { background: #f2effe; color: #6c4ef5; border-color: #e1d8fd; } .fcp-devices button svg { display: block; } .fcp-canvas { padding: 56px 24px; display: flex; justify-content: center; background: #f5f7fa; transition: none; } .fcp-viewport { width: 100%; max-width: 680px; transition: max-width 0.25s ease; background: #fff; padding: 20px; border-radius: 6px; } .fcp-viewport.is-tablet { max-width: 480px; } .fcp-viewport.is-mobile { max-width: 320px; } .fcp-sidebar { width: 280px; flex-shrink: 0; background: #fff; border: 1px solid rgba(15,23,42,.08); border-radius: 16px; box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 20px -6px rgba(15,23,42,.10); padding: 24px; } .fcp-sidebar h2 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 16px; } .fcp-sidebar select { width: 100%; padding: 10px 12px; border: 1px solid rgba(15,23,42,.08); border-radius: 8px; background: #f5f7fa; font-size: 13px; margin-bottom: 20px; } .fcp-roadmap { border-top: 1px solid rgba(15,23,42,.08); padding-top: 18px; margin-top: 4px; } .fcp-roadmap h3 { font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; font-weight: 700; color: #909399; margin: 0 0 12px; } .fcp-roadmap ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } .fcp-roadmap li { font-size: 12.5px; color: #606266; display: flex; gap: 6px; } @media (max-width: 900px) { .fcp-body { flex-direction: column; } .fcp-sidebar { width: 100%; } }' ); // Dedicated no-src handle so this admin-post standalone preview page // (outside the normal wp_enqueue_scripts lifecycle) can carry its own // inline JS through wp_add_inline_script() instead of a raw