form -> resource -> (steps Pro adds) -> publish * * Each step records what it created in a "journal" kept for an hour. A funnel stays in draft until the last step has * run, so a half-built funnel never shows as live. If any step fails, the journal is walked backwards and only what this * run created is deleted; anything the funnel merely reuses is never touched. * * What a funnel says about a form (the message after submitting, the redirect, the notification, the delivery email) is * not written into the form. It is looked up from the funnel when a lead arrives (see runtime()), which is what lets a * funnel use an existing form without changing it. */ class ACQUIS_Funnel_Builder { const JOB_PREFIX = 'acquis_fnl_job_'; // ── Steps ────────────────────────────────────────────────────────────── /** @return string[] */ public static function steps( $template_id ) { $steps = array( 'form', 'resource', 'publish' ); /** * Steps between the form and publishing. Pro adds the step that creates its automation rule. A step name must * have a handler on 'acquis_funnel_run_step_{name}'. * * @param string[] $steps * @param string $template_id */ return array_values( array_unique( (array) apply_filters( 'acquis_funnel_build_steps', $steps, $template_id ) ) ); } private static function job_key( $token ) { return self::JOB_PREFIX . preg_replace( '/[^A-Za-z0-9]/', '', (string) $token ); } private static function load_job( $token ) { $job = get_transient( self::job_key( $token ) ); return ( is_array( $job ) && (int) $job['user'] === get_current_user_id() ) ? $job : null; } private static function save_job( $token, array $job ) { set_transient( self::job_key( $token ), $job, HOUR_IN_SECONDS ); } /** * Validate the answers and open a job. * * @return array|WP_Error { token, steps[] } */ public static function start( $template_id, array $raw ) { $answers = ACQUIS_Funnel_Templates::sanitize( $template_id, $raw ); if ( is_wp_error( $answers ) ) { return $answers; } $token = wp_generate_password( 20, false ); self::save_job( $token, array( 'user' => get_current_user_id(), 'template' => $answers['template'], 'answers' => $answers, 'created' => array(), 'form_id' => 0, 'funnel' => 0, 'done' => array(), ) ); return array( 'token' => $token, 'steps' => self::steps( $answers['template'] ) ); } /** * Run one step of a job. Safe to repeat: a step that already ran does nothing. * * @return array|WP_Error */ public static function run_step( $token, $step ) { $job = self::load_job( $token ); if ( ! $job ) { return new WP_Error( 'job', __( 'This setup expired. Please start again.', 'acquisitionsuite' ) ); } $step = sanitize_key( $step ); if ( ! in_array( $step, self::steps( $job['template'] ), true ) ) { return new WP_Error( 'step', __( 'Unknown step.', 'acquisitionsuite' ) ); } if ( in_array( $step, $job['done'], true ) ) { return array( 'step' => $step, 'repeat' => true ); } try { $result = self::dispatch( $step, $job ); } catch ( \Throwable $e ) { $result = new WP_Error( 'step_failed', __( 'A step failed.', 'acquisitionsuite' ) ); } if ( is_wp_error( $result ) ) { return $result; } $job = $result['job']; $job['done'][] = $step; self::save_job( $token, $job ); unset( $result['job'] ); $result['step'] = $step; return $result; } private static function dispatch( $step, array $job ) { switch ( $step ) { case 'form': return self::step_form( $job ); case 'resource': return self::step_resource( $job ); case 'publish': return self::step_publish( $job ); } /** * Runs a step Pro or an add-on added. Return the job array (optionally with created objects appended) in * array( 'job' => $job ), or a WP_Error. * * @param array|WP_Error|null $result * @param array $job */ $result = apply_filters( 'acquis_funnel_run_step_' . $step, null, $job ); return $result ? $result : new WP_Error( 'step', __( 'Unknown step.', 'acquisitionsuite' ) ); } // ── The three built-in steps ─────────────────────────────────────────── private static function step_form( array $job ) { $a = $job['answers']; $tpl = ACQUIS_Funnel_Templates::get( $job['template'] ); if ( 'existing' === $a['form_mode'] ) { $job['form_id'] = (int) $a['form_id']; $job['created'][] = array( 'type' => 'form', 'id' => (int) $a['form_id'], 'owned' => 0, 'role' => 'form' ); return array( 'job' => $job ); } $form_id = ACQUIS_P3_DB::create_form( $a['name'], $a['description'], 1, self::form_settings( $tpl, $a ) ); if ( ! $form_id ) { return new WP_Error( 'form', __( 'The form could not be created.', 'acquisitionsuite' ) ); } $job['form_id'] = (int) $form_id; $job['created'][] = array( 'type' => 'form', 'id' => (int) $form_id, 'owned' => 1, 'role' => 'form' ); foreach ( self::field_definitions( $a ) as $order => $def ) { $def['sort_order'] = $order; ACQUIS_P3_DB::save_field( $form_id, $def ); } return array( 'job' => $job ); } private static function step_resource( array $job ) { $tpl = ACQUIS_Funnel_Templates::get( $job['template'] ); if ( $tpl && ! empty( $tpl['needs_file'] ) ) { $file_id = (int) $job['answers']['file_id']; if ( ! ACQUIS_File_Manager::get_file_data( $file_id ) ) { return new WP_Error( 'file', __( 'The file is no longer available. Choose another.', 'acquisitionsuite' ) ); } $job['created'][] = array( 'type' => 'file', 'id' => $file_id, 'owned' => 0, 'role' => 'file' ); } return array( 'job' => $job ); } private static function step_publish( array $job ) { $a = $job['answers']; $funnel_id = ACQUIS_Funnels::insert( array( 'name' => $a['name'], 'template' => $job['template'], 'config' => $a, 'form_id' => $job['form_id'], 'file_id' => (int) $a['file_id'], ) ); if ( ! $funnel_id ) { return new WP_Error( 'publish', __( 'The funnel could not be saved.', 'acquisitionsuite' ) ); } $job['funnel'] = $funnel_id; foreach ( $job['created'] as $obj ) { ACQUIS_Funnels::link( $funnel_id, $obj['type'], $obj['id'], $obj['owned'], $obj['role'] ); } ACQUIS_Funnels::update( $funnel_id, array( 'status' => 'active', 'synced_at' => self::form_stamp( $job['form_id'] ) ) ); /** * A funnel was created. Pro uses the job's own record of what it made; nothing else is needed here. * * @param int $funnel_id * @param array $answers */ do_action( 'acquis_funnel_created', $funnel_id, $a ); return array( 'job' => $job, 'funnel_id' => $funnel_id, 'summary' => self::summary( $funnel_id ), ); } /** Undo a job that did not finish. Deletes only what this job created. */ public static function rollback( $token ) { $job = self::load_job( $token ); if ( ! $job ) { return false; } foreach ( array_reverse( $job['created'] ) as $obj ) { if ( empty( $obj['owned'] ) ) { continue; } if ( 'form' === $obj['type'] ) { ACQUIS_P3_DB::delete_form( $obj['id'] ); } else { /** * Delete an object a funnel step created (Pro's automation rule, for example). * * @param array $obj { type, id, owned, role } */ do_action( 'acquis_funnel_delete_object', $obj ); } } if ( ! empty( $job['funnel'] ) ) { ACQUIS_Funnels::delete( $job['funnel'] ); } delete_transient( self::job_key( $token ) ); return true; } // ── What a form looks like ───────────────────────────────────────────── /** @return array The form's settings JSON for a funnel. */ public static function form_settings( $tpl, array $a ) { $purpose = $tpl['purpose'] ?? 'custom'; return array( 'purpose' => $purpose, 'standalone' => ( 'file_gate' !== $purpose ), 'display_style' => $a['display'] ?? 'embedded', 'button_label' => $a['button_label'] ?? '', 'modal_title' => $a['headline'] ?? '', 'modal_subtitle' => $a['description'] ?? '', 'modal_submit_label' => $a['submit_label'] ?? '', 'slidein_delay' => isset( $a['slidein_delay'] ) ? (int) $a['slidein_delay'] : 8, 'email_field' => 'email', ); } /** * @param array $a The funnel's answers. * @param string[] $taken Field keys that already exist on the form, so a new custom field never takes one of them. * @return array[] Field rows for ACQUIS_P3_DB::save_field(), in order, keyed by field_key. */ public static function field_definitions( array $a, array $taken = array() ) { $std = ACQUIS_Funnel_Templates::standard_fields(); $defs = array(); foreach ( (array) ( $a['fields'] ?? array() ) as $key ) { if ( isset( $std[ $key ] ) ) { $defs[] = array( 'field_key' => $key, 'field_type' => $std[ $key ]['type'], 'label' => $std[ $key ]['label'], 'required' => $std[ $key ]['required'] ); } } $used = wp_list_pluck( $defs, 'field_key' ); $custom = array_values( (array) ( $a['custom'] ?? array() ) ); // A field that already has a key keeps it, whatever its label is now called. foreach ( $custom as $c ) { if ( ! empty( $c['key'] ) ) { $used[] = (string) $c['key']; } } $used = array_merge( $used, $taken ); $seen = array(); foreach ( $custom as $c ) { if ( ! empty( $c['key'] ) && ! in_array( (string) $c['key'], $seen, true ) ) { $key = (string) $c['key']; } else { $base = 'custom_' . sanitize_key( str_replace( '-', '_', sanitize_title( $c['label'] ) ) ); $key = $base; $n = 2; while ( in_array( $key, $used, true ) || in_array( $key, $seen, true ) ) { $key = $base . '_' . $n++; } $used[] = $key; } $seen[] = $key; $defs[] = array( 'field_key' => $key, 'field_type' => $c['type'], 'label' => $c['label'], 'required' => (int) $c['required'] ); } return $defs; } /** * The funnel's answers as its form is right now. This is what the wizard shows when a funnel is edited, and what a * save is compared with, so a change made in Form Builder is neither lost nor undone: only what the person changes * in the wizard is applied to the form. * * What is read back: the form's name, its display and wording, its standard fields and EVERY other field on it * (whatever its type), in the form's order. What the wizard may change is a field's label and whether it stays; * everything else about a field (its type and options, placeholder, help text, conditions, colours...) is left exactly * as it is. * * @param object $funnel * @return array The answers, with the form's own values laid over the saved ones. */ public static function answers_from_form( $funnel ) { $a = (array) $funnel->config; $form = (int) $funnel->form_id ? ACQUIS_P3_DB::get_form( (int) $funnel->form_id ) : null; if ( ! $form ) { return $a; } $s = is_array( $form->settings ) ? $form->settings : array(); $put = function ( $key, $value ) use ( &$a ) { if ( is_scalar( $value ) && '' !== trim( (string) $value ) ) { $a[ $key ] = $value; } }; $put( 'name', $form->title ); $put( 'headline', $s['modal_title'] ?? '' ); $put( 'description', ! empty( $s['modal_subtitle'] ) ? $s['modal_subtitle'] : $form->description ); $put( 'button_label', $s['button_label'] ?? '' ); $put( 'submit_label', $s['modal_submit_label'] ?? '' ); $put( 'display', $s['display_style'] ?? '' ); if ( isset( $s['slidein_delay'] ) && is_numeric( $s['slidein_delay'] ) ) { $a['slidein_delay'] = (int) $s['slidein_delay']; } $std = ACQUIS_Funnel_Templates::standard_fields(); $fields = array(); $custom = array(); foreach ( (array) $form->fields as $f ) { if ( isset( $std[ $f->field_key ] ) ) { $fields[] = $f->field_key; } else { // Every other field on the form, whatever its type or how it was made: the wizard lists it, in the // form's own order, so what is on the form is what is shown. $label = trim( (string) $f->label ); $custom[] = array( 'key' => $f->field_key, 'label' => '' !== $label ? $label : (string) $f->field_key, 'type' => (string) $f->field_type, 'required' => (int) $f->required ? 1 : 0 ); } } $a['fields'] = $fields; $a['custom'] = $custom; return $a; } /** The field types the wizard can create and fully describe. Fields of any other type can be renamed or removed here, never re-typed. */ public static function simple_types() { return array( 'text', 'textarea', 'number', 'phone', 'url', 'date' ); } /** How many fields on a funnel's form are of a type the wizard only lets you rename or remove (a dropdown, a checkbox group...). */ public static function untouched_field_count( $funnel ) { $n = 0; foreach ( (array) ( self::answers_from_form( $funnel )['custom'] ?? array() ) as $c ) { if ( ! in_array( $c['type'], self::simple_types(), true ) ) { $n++; } } return $n; } // ── Changing a funnel ────────────────────────────────────────────────── /** * Apply new answers to an existing funnel. Owned objects are updated in place; reused ones are never changed. * * The form is MERGED, not replaced: what changed in the wizard (compared with what the form looked like when the * wizard opened) is applied, and everything changed in Form Builder meanwhile stays: extra fields, edited labels, * placeholders, conditions, the form's name, its look. When both sides changed the same thing, the wizard wins, * because it is the later action. * * @param string $mode '' merges (the default); 'overwrite' replaces the form's funnel fields and look with the * funnel's answers; 'keep' changes only the funnel and never touches the form. * @return array|WP_Error { conflict: false } (kept so callers that asked about a conflict keep working) */ public static function update( $funnel_id, array $raw, $mode = '' ) { $funnel = ACQUIS_Funnels::get( $funnel_id ); if ( ! $funnel ) { return new WP_Error( 'missing', __( 'That funnel no longer exists.', 'acquisitionsuite' ) ); } $a = ACQUIS_Funnel_Templates::sanitize( $funnel->template, $raw, true, (int) $funnel->id ); if ( is_wp_error( $a ) ) { return $a; } $tpl = ACQUIS_Funnel_Templates::get( $funnel->template ); $form_obj = ACQUIS_Funnels::object( $funnel->id, 'form' ); $owned = $form_obj && (int) $form_obj->owned; $form = $funnel->form_id ? ACQUIS_P3_DB::get_form( $funnel->form_id ) : null; $before = $owned && $form ? self::answers_from_form( $funnel ) : (array) $funnel->config; // what the wizard showed $config = array_merge( (array) $funnel->config, $a ); if ( $owned && $form && 'keep' !== $mode ) { if ( 'overwrite' === $mode ) { ACQUIS_P3_DB::update_form( $form->id, array( 'title' => $a['name'], 'description' => $a['description'], 'settings' => self::form_settings( $tpl, $config ), ) ); self::sync_fields( $form->id, self::field_definitions( $a ) ); } else { self::merge_into_form( $form, $tpl, $before, $config ); } } $row = array( 'name' => $a['name'], 'config' => $config ); if ( $owned && $form ) { $row['synced_at'] = self::form_stamp( $form->id ); // the form as it is now is the new baseline } if ( ! empty( $tpl['needs_file'] ) ) { $row['file_id'] = (int) $a['file_id']; ACQUIS_Funnels::link( $funnel->id, 'file', (int) $a['file_id'], 0, 'file' ); } ACQUIS_Funnels::update( $funnel->id, $row ); /** * A funnel's answers changed. Pro rebuilds its automation rule from them. * * @param int $funnel_id * @param array $config The full saved config. */ do_action( 'acquis_funnel_updated', $funnel->id, $config ); ACQUIS_Funnels::sync_status( ACQUIS_Funnels::get( $funnel->id ) ); return array( 'conflict' => false ); } /** * Write to the form only what the wizard changed. * * @param object $form The form as it is now. * @param array $tpl The template. * @param array $before The answers as the form was when the wizard opened (answers_from_form()). * @param array $after The answers after the wizard's changes. */ private static function merge_into_form( $form, $tpl, array $before, array $after ) { $row = array(); if ( (string) ( $before['name'] ?? '' ) !== (string) ( $after['name'] ?? '' ) ) { $row['title'] = $after['name']; } if ( (string) ( $before['description'] ?? '' ) !== (string) ( $after['description'] ?? '' ) ) { $row['description'] = $after['description']; } // An older copy of the wizard sends custom fields without their keys: match them to the form's field with the same label. $claimed = array(); foreach ( (array) ( $after['custom'] ?? array() ) as $c ) { if ( ! empty( $c['key'] ) ) { $claimed[] = $c['key']; } } foreach ( (array) ( $after['custom'] ?? array() ) as $i => $c ) { if ( ! empty( $c['key'] ) ) { continue; } foreach ( (array) ( $before['custom'] ?? array() ) as $bc ) { if ( ! in_array( $bc['key'], $claimed, true ) && 0 === strcasecmp( trim( (string) $bc['label'] ), trim( (string) $c['label'] ) ) ) { $after['custom'][ $i ]['key'] = $bc['key']; $claimed[] = $bc['key']; break; } } } $was = self::form_settings( $tpl, $before ); $now = self::form_settings( $tpl, $after ); $set = array(); foreach ( $now as $key => $value ) { if ( ! array_key_exists( $key, $was ) || $was[ $key ] !== $value ) { $set[ $key ] = $value; // update_form() merges these into the form's other settings } } if ( $set ) { $row['settings'] = $set; } if ( $row ) { ACQUIS_P3_DB::update_form( $form->id, $row ); } $existing = array(); foreach ( (array) $form->fields as $f ) { $existing[ $f->field_key ] = $f; } self::merge_fields( $form->id, self::field_definitions( $before ), self::field_definitions( $after, array_keys( $existing ) ), $existing ); } /** * Put a field the wizard added where the funnel's own order puts it: right after the field that comes before it in * the funnel (or before the one that comes after it), without moving anything else. * * @param int $form_id * @param int $new_id The new field's row id. * @param string $key Its key. * @param string[] $order_keys The funnel's field keys, in the funnel's order. * @param int[] $present field_key => row id of the fields already on the form. */ private static function place_field( $form_id, $new_id, $key, array $order_keys, array $present ) { $at = array_search( $key, $order_keys, true ); if ( false === $at ) { return; } $anchor = 0; $before = false; for ( $i = $at - 1; $i >= 0; $i-- ) { if ( isset( $present[ $order_keys[ $i ] ] ) ) { $anchor = $present[ $order_keys[ $i ] ]; break; } } if ( ! $anchor ) { for ( $i = $at + 1; $i < count( $order_keys ); $i++ ) { if ( isset( $present[ $order_keys[ $i ] ] ) ) { $anchor = $present[ $order_keys[ $i ] ]; $before = true; break; } } } if ( ! $anchor ) { return; } $ids = array_map( 'intval', wp_list_pluck( ACQUIS_P3_DB::get_form_fields( $form_id ), 'id' ) ); $ids = array_values( array_diff( $ids, array( (int) $new_id ) ) ); $pos = array_search( (int) $anchor, $ids, true ); if ( false === $pos ) { return; } array_splice( $ids, $before ? $pos : $pos + 1, 0, array( (int) $new_id ) ); ACQUIS_P3_DB::reorder_fields( $form_id, $ids ); } /** * Add the fields the wizard added, remove the ones it removed, and change only the label, type or required flag of * a field the wizard changed. Every other field, and everything else about these ones, is left alone. * * @param int $form_id * @param array[] $was Field definitions as the form was when the wizard opened. * @param array[] $now Field definitions after the wizard's changes. * @param object[] $existing The form's fields now, keyed by field_key. */ private static function merge_fields( $form_id, array $was, array $now, array $existing ) { $by = function ( array $defs ) { $out = array(); foreach ( $defs as $d ) { $out[ $d['field_key'] ] = $d; } return $out; }; $was = $by( $was ); $now = $by( $now ); foreach ( $was as $key => $def ) { if ( ! isset( $now[ $key ] ) && isset( $existing[ $key ] ) ) { ACQUIS_P3_DB::delete_field( (int) $existing[ $key ]->id ); } } $order = 0; foreach ( $existing as $f ) { $order = max( $order, (int) $f->sort_order + 1 ); } $order_keys = array_keys( $now ); $present = array(); // field_key => row id, for fields on the form (including ones added just now) foreach ( $existing as $k => $f ) { $present[ $k ] = (int) $f->id; } foreach ( $now as $key => $def ) { if ( ! isset( $existing[ $key ] ) ) { $def['sort_order'] = $order++; $id = (int) ACQUIS_P3_DB::save_field( $form_id, $def ); if ( $id ) { self::place_field( $form_id, $id, $key, $order_keys, $present ); $present[ $key ] = $id; } continue; } if ( ! isset( $was[ $key ] ) ) { continue; // The form already has a field with this key that the wizard never showed: leave it be. } $change = array(); foreach ( array( 'label', 'field_type', 'required' ) as $col ) { if ( (string) ( $was[ $key ][ $col ] ?? '' ) !== (string) ( $def[ $col ] ?? '' ) ) { if ( 'field_type' === $col && ( ! in_array( $was[ $key ]['field_type'], self::simple_types(), true ) || ! in_array( $def['field_type'], self::simple_types(), true ) ) ) { continue; // a dropdown stays a dropdown: its options live in Form Builder } $change[ $col ] = $def[ $col ]; } } if ( $change ) { ACQUIS_P3_DB::save_field( $form_id, array_merge( (array) $existing[ $key ], $change ) ); } } } /** Make a form's fields match the definitions: keep a field that is still wanted, add new ones, remove the rest. */ private static function sync_fields( $form_id, array $defs ) { $existing = array(); foreach ( ACQUIS_P3_DB::get_form_fields( $form_id ) as $f ) { $existing[ $f->field_key ] = $f; } $keep = array(); foreach ( $defs as $order => $def ) { $def['sort_order'] = $order; if ( isset( $existing[ $def['field_key'] ] ) ) { $def['id'] = (int) $existing[ $def['field_key'] ]->id; } ACQUIS_P3_DB::save_field( $form_id, $def ); $keep[] = $def['field_key']; } foreach ( $existing as $key => $f ) { if ( ! in_array( $key, $keep, true ) ) { ACQUIS_P3_DB::delete_field( (int) $f->id ); } } } /** The form's stored updated_at, exactly as the database has it ('' when there is no such form). */ private static function form_stamp( $form_id ) { global $wpdb; return (string) $wpdb->get_var( $wpdb->prepare( 'SELECT updated_at FROM %i WHERE id = %d', ACQUIS_P3_DB::forms_table(), absint( $form_id ) ) ); } // ── Deleting and repairing ───────────────────────────────────────────── /** * What deleting this funnel would delete, for the confirmation dialog. * * @return array[] { role, type, id, label, owned } */ public static function deletable( $funnel_id ) { $out = array(); foreach ( ACQUIS_Funnels::objects( $funnel_id ) as $o ) { $label = ''; if ( 'form' === $o->object_type ) { $f = ACQUIS_P3_DB::get_form( $o->object_id ); $label = $f ? $f->title : ''; } elseif ( 'file' === $o->object_type || 'file_price' === $o->object_type ) { $f = ACQUIS_File_Manager::get_file_data( (int) $o->object_id ); $label = $f ? $f['title'] : ''; } $out[] = array( 'role' => $o->role, 'type' => $o->object_type, 'id' => (int) $o->object_id, 'owned' => (int) $o->owned, 'label' => $label ); } return (array) apply_filters( 'acquis_funnel_deletable', $out, $funnel_id ); } /** * @param string[] $delete Roles of owned objects to delete as well. Leads and submissions are never deleted. */ public static function delete( $funnel_id, array $delete = array() ) { $funnel = ACQUIS_Funnels::get( $funnel_id ); if ( ! $funnel ) { return false; } foreach ( ACQUIS_Funnels::objects( $funnel->id ) as $o ) { if ( empty( $o->owned ) || ! in_array( $o->role, $delete, true ) ) { continue; } if ( 'form' === $o->object_type ) { ACQUIS_P3_DB::delete_form( (int) $o->object_id ); } else { do_action( 'acquis_funnel_delete_object', array( 'type' => $o->object_type, 'id' => (int) $o->object_id, 'owned' => 1, 'role' => $o->role, 'funnel_id' => (int) $funnel->id ) ); } } ACQUIS_Funnels::delete( $funnel->id ); return true; } /** * Recreate what a funnel lost. Only the form can be rebuilt from the saved answers; a lost file has to be chosen again * in the editor. * * @return array|WP_Error */ public static function repair( $funnel_id ) { $funnel = ACQUIS_Funnels::get( $funnel_id ); if ( ! $funnel ) { return new WP_Error( 'missing', __( 'That funnel no longer exists.', 'acquisitionsuite' ) ); } $problems = ACQUIS_Funnels::problems( $funnel ); if ( in_array( 'form_missing', $problems, true ) ) { $obj = ACQUIS_Funnels::object( $funnel->id, 'form' ); if ( $obj && empty( $obj->owned ) ) { return new WP_Error( 'reused', __( 'The form this funnel uses was deleted. Edit the funnel and choose another form.', 'acquisitionsuite' ) ); } $tpl = ACQUIS_Funnel_Templates::get( $funnel->template ); $a = (array) $funnel->config; $form_id = ACQUIS_P3_DB::create_form( $a['name'] ?? $funnel->name, $a['description'] ?? '', 1, self::form_settings( $tpl, $a ) ); if ( ! $form_id ) { return new WP_Error( 'form', __( 'The form could not be recreated.', 'acquisitionsuite' ) ); } foreach ( self::field_definitions( $a ) as $order => $def ) { $def['sort_order'] = $order; ACQUIS_P3_DB::save_field( $form_id, $def ); } ACQUIS_Funnels::link( $funnel->id, 'form', $form_id, 1, 'form' ); ACQUIS_Funnels::update( $funnel->id, array( 'form_id' => $form_id, 'synced_at' => self::form_stamp( $form_id ) ) ); } do_action( 'acquis_funnel_repair', $funnel->id ); $fresh = ACQUIS_Funnels::sync_status( ACQUIS_Funnels::get( $funnel->id ) ); return array( 'status' => $fresh->status, 'problems' => ACQUIS_Funnels::problems( $fresh ) ); } // ── For the screens ──────────────────────────────────────────────────── /** Everything the success screen and the list need to know about one funnel. */ public static function summary( $funnel_id ) { $f = ACQUIS_Funnels::get( $funnel_id ); if ( ! $f ) { return array(); } $obj = ACQUIS_Funnels::object( $f->id, 'form' ); $a = $obj && (int) $obj->owned ? self::answers_from_form( $f ) : (array) $f->config; // the form's own display, if it was changed in Form Builder $out = array( 'facts' => array(), // short lines an extension adds, such as a product's price 'id' => (int) $f->id, 'name' => $f->name, 'template' => $f->template, 'status' => $f->status, 'form_id' => (int) $f->form_id, 'file_id' => (int) $f->file_id, 'shortcode' => ACQUIS_Funnel_Templates::shortcode( $f->template, $f->form_id, $f->file_id, $a ), 'leads_url' => add_query_arg( array( 'page' => 'acquis-submissions', 'form_filter' => (int) $f->form_id ), admin_url( 'admin.php' ) ), 'report_url' => add_query_arg( array( 'page' => 'acquis-funnels', 'funnel' => (int) $f->id, 'view' => 'report' ), admin_url( 'admin.php' ) ), 'form_url' => add_query_arg( array( 'page' => 'acquis-form-builder', 'edit' => (int) $f->form_id ), admin_url( 'admin.php' ) ), ); /** * Pro adds short facts about a funnel here (a product funnel's price, with its discount). They show on the * success screen and under the funnel's name in the list. * * @param array $out The summary. * @param object $f The funnel. */ return (array) apply_filters( 'acquis_funnel_summary', $out, $f ); } // ── Runtime: how a funnel shapes what happens to a lead ──────────────── /** * What the funnel behind a form says should happen after a submission, or null when the form has no funnel. * * @return array|null { after{type,message,url}, notify{enabled,email}, email{enabled,subject,body}, name, funnel_id } */ public static function runtime( $form_id ) { if ( ! class_exists( 'ACQUIS_Funnels' ) ) { return null; } $f = ACQUIS_Funnels::for_form( $form_id ); if ( ! $f ) { return null; } $c = (array) $f->config; $tags = ACQUIS_Funnel_Templates::tags( $f->template, $c ); $fill = function ( $text ) use ( $tags ) { return $tags ? str_replace( array_keys( $tags ), array_values( $tags ), (string) $text ) : (string) $text; }; $after = (array) ( $c['after'] ?? array() ); $email = (array) ( $c['email'] ?? array() ); if ( isset( $after['message'] ) ) { $after['message'] = $fill( $after['message'] ); } foreach ( array( 'subject', 'body' ) as $k ) { if ( isset( $email[ $k ] ) ) { $email[ $k ] = $fill( $email[ $k ] ); } } return array( 'funnel_id' => (int) $f->id, 'name' => $f->name, 'template' => $f->template, 'after' => $after, 'notify' => (array) ( $c['notify'] ?? array() ), 'email' => $email, ); } /** * A funnel's own delivery email, in the form-settings shape the email engine already understands. The message is * wrapped in the plugin's own email design (logo, header colour, button and footer from Email Settings). * * @return array {} when the funnel has no custom email. */ public static function email_override( $form_id ) { $r = self::runtime( $form_id ); if ( ! $r || empty( $r['email']['enabled'] ) ) { return array(); } $tpl = ACQUIS_Funnel_Templates::get( $r['template'] ); return array( 'enabled' => 1, 'subject' => (string) $r['email']['subject'], 'template' => self::email_html( (string) $r['email']['body'], ! empty( $tpl['needs_file'] ) ), ); } public static function email_html( $body, $with_button ) { $paragraphs = ''; foreach ( preg_split( '/\n{2,}/', trim( str_replace( "\r", '', $body ) ) ) as $p ) { $p = trim( $p ); if ( '' !== $p ) { // Escaped first, then plain addresses become links (a join link, a calendar link). $paragraphs .= '

' . make_clickable( nl2br( esc_html( $p ) ) ) . '

'; } } $button = $with_button ? '

{{button_text}}

' . '

This link expires in {{expiry_minutes}} minutes.

' : ''; return '' . '' . '
' . '' . '' . '' . '' . '
{{logo_block}}
' . $paragraphs . $button . '
' . '

{{footer_text}}

' . '

© {{current_year}} {site_name}

'; } }