diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d635786 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Build Release + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get plugin version + id: version + run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Build zip + run: | + plugin_name="fluent-crm-custom-features" + mkdir "$plugin_name" + cp -r classes "$plugin_name/" + cp *.php "$plugin_name/" + zip -r "${plugin_name}.zip" "$plugin_name" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: fluent-crm-custom-features.zip + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 21bca1b..8607472 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Dependencies /vendor/ -/vendor-prefixed/ +/vendor-prefixed/* +!/vendor-prefixed/.gitkeep /node_modules/ # Composer diff --git a/classes/Actions/RandomWaitTimeAction.php b/classes/Actions/RandomWaitTimeAction.php index 5016178..1997daa 100644 --- a/classes/Actions/RandomWaitTimeAction.php +++ b/classes/Actions/RandomWaitTimeAction.php @@ -82,20 +82,6 @@ public function savingAction( $sequence, $funnel ) { return $sequence; } - /** - * Get the action settings. - * - * @param array $sequence The sequence settings. - * @param array $funnel The funnel settings. - * - * @return array - */ - public function gettingAction( $sequence, $funnel ) { - $sequence = parent::gettingAction( $sequence, $funnel ); - - return $sequence; - } - /** * Get the block fields for the action. * @@ -199,20 +185,6 @@ public function setDelayInSeconds( $delay_in_seconds, $settings, $sequence, $fun $wait_times = $wait_times * 60 * 60 * 24 * ( 365 / 12 ); } - if ( $wait_times !== $delay_in_seconds ) { - // Track the random time as an event for debugging. - \FluentCrmApi( 'event_tracker' )->track( [ - 'event_key' => 'random_wait_time', // Required - 'title' => 'Randomized Wait Time', // Required - 'value' => wp_json_encode([ - 'next_sequence' => gmdate( 'Y-m-d H:i:s', time() + $wait_times ), - 'delay' => $wait_times, - ]), - 'email' => 'daniel@code-atlantic.com', - 'provider' => 'debug', // If left empty, 'custom' will be added. - ], false ); - } - return $wait_times; } } diff --git a/classes/Actions/UpdateContactPropertyAction.php b/classes/Actions/UpdateContactPropertyAction.php index 7ae16b5..00f11bd 100644 --- a/classes/Actions/UpdateContactPropertyAction.php +++ b/classes/Actions/UpdateContactPropertyAction.php @@ -309,13 +309,10 @@ public function formatCustomFieldValues( $values, $fields = [] ) { $is_array_type = Arr::get( $fields, $value_key . '.type' ) === 'checkbox' || Arr::get( $fields, $value_key . '.type' ) === 'select-multi'; if ( ! is_array( $value ) && $is_array_type ) { - $item_values = explode( ',', $value ); - $trimmedvalues = []; - foreach ( $item_values as $item_value ) { - $trimmedvalues[] = trim( $item_value ); - } - if ( $item_value ) { - $values[ $value_key ] = $trimmedvalues; + $item_values = explode( ',', $value ); + $trimmed_values = array_filter( array_map( 'trim', $item_values ), 'strlen' ); + if ( $trimmed_values ) { + $values[ $value_key ] = $trimmed_values; } } } diff --git a/classes/Conditions/AutomationConditions.php b/classes/Conditions/AutomationConditions.php new file mode 100644 index 0000000..6be804c --- /dev/null +++ b/classes/Conditions/AutomationConditions.php @@ -0,0 +1,336 @@ + $groups Existing condition groups. + * @param mixed $funnel The current funnel. + * + * @return array + */ + public function addConditionGroups( array $groups, $funnel ): array { + // Add Event Tracking group (evaluation handled by FunnelConditionHelper::assessEventTrackingConditions). + if ( Helper::isExperimentalEnabled( 'event_tracking' ) ) { + $groups['event_tracking'] = [ + 'label' => __( 'Event Tracking', 'fluent-crm-custom-features' ), + 'value' => 'event_tracking', + 'children' => $this->getEventTrackingChildren(), + ]; + } + + // Add Automation Completion group. + $groups['automations'] = [ + 'label' => __( 'Automations', 'fluent-crm-custom-features' ), + 'value' => 'automations', + 'children' => $this->getAutomationChildren(), + ]; + + // Add EDD License group. + if ( function_exists( 'edd_software_licensing' ) ) { + $groups['edd_licenses'] = [ + 'label' => __( 'EDD Licenses', 'fluent-crm-custom-features' ), + 'value' => 'edd_licenses', + 'children' => $this->getEddLicenseChildren(), + ]; + } + + return $groups; + } + + /** + * Get event tracking condition options. + * + * Provides a list of tracked event keys that can be used as conditions. + * + * @return array> + */ + private function getEventTrackingChildren(): array { + $events = fluentCrmDb()->table( 'fc_event_tracking' ) + ->select( 'event_key', 'title' ) + ->groupBy( 'event_key' ) + ->get(); + + $children = []; + foreach ( $events as $event ) { + $children[] = [ + 'label' => $event->title ?: $event->event_key, + 'value' => $event->event_key, + 'type' => 'selections', + 'options' => [ + 'yes' => __( 'Yes - Has performed', 'fluent-crm-custom-features' ), + 'no' => __( 'No - Has not performed', 'fluent-crm-custom-features' ), + ], + 'is_multiple' => false, + 'is_singular_value' => true, + ]; + } + + return $children; + } + + /** + * Get automation completion condition options. + * + * Provides a list of funnels that can be checked for completion. + * + * @return array> + */ + private function getAutomationChildren(): array { + $funnels = fluentCrmDb()->table( 'fc_funnels' ) + ->select( 'id', 'title' ) + ->orderBy( 'title', 'ASC' ) + ->get(); + + $children = []; + foreach ( $funnels as $funnel_item ) { + $children[] = [ + 'label' => $funnel_item->title, + 'value' => 'funnel_completed_' . $funnel_item->id, + 'type' => 'selections', + 'options' => [ + 'yes' => __( 'Yes - Has completed', 'fluent-crm-custom-features' ), + 'no' => __( 'No - Has not completed', 'fluent-crm-custom-features' ), + ], + 'is_multiple' => false, + 'is_singular_value' => true, + ]; + } + + return $children; + } + + /** + * Get EDD license condition options. + * + * Lists all EDD downloads that have at least one license, so the condition + * UI shows "Has active license for [Product Name]" yes/no. + * + * @return array> + */ + private function getEddLicenseChildren(): array { + $products = fluentCrmDb()->table( 'edd_licenses' ) + ->select( 'download_id' ) + ->groupBy( 'download_id' ) + ->get(); + + $children = []; + foreach ( $products as $product ) { + $download = get_post( $product->download_id ); + if ( ! $download ) { + continue; + } + + $children[] = [ + 'label' => $download->post_title, + 'value' => 'edd_license_' . $product->download_id, + 'type' => 'selections', + 'options' => [ + 'valid' => __( 'Valid license (active or inactive)', 'fluent-crm-custom-features' ), + 'active' => __( 'Active (activated on a site)', 'fluent-crm-custom-features' ), + 'inactive' => __( 'Inactive (not activated)', 'fluent-crm-custom-features' ), + 'expired' => __( 'Expired', 'fluent-crm-custom-features' ), + 'disabled' => __( 'Disabled', 'fluent-crm-custom-features' ), + ], + 'is_multiple' => true, + 'is_singular_value' => false, + ]; + } + + return $children; + } + + /** + * Assess EDD license conditions. + * + * Checks if the subscriber has an active (or inactive) license for a specific + * EDD product by querying the edd_licenses table via customer_id. + * + * @param bool $result Current result. + * @param array $conditions Condition rules to evaluate. + * @param object $subscriber The subscriber being evaluated. + * @param object $sequence The current funnel sequence. + * @param int $funnelSubscriberId The funnel subscriber ID. + * + * @return bool + */ + public function assessEddLicenseConditions( $result, $conditions, $subscriber, $sequence, $funnelSubscriberId ): bool { + if ( ! function_exists( 'edd_software_licensing' ) ) { + return false; + } + + // Resolve the EDD customer from the subscriber's email or user_id. + $customer = null; + if ( $subscriber->user_id ) { + $customer = new \EDD_Customer( $subscriber->user_id, true ); + } + if ( ( ! $customer || ! $customer->id ) && $subscriber->email ) { + $customer = new \EDD_Customer( $subscriber->email ); + } + if ( ! $customer || ! $customer->id ) { + // No EDD customer — no license can match. + return false; + } + + foreach ( $conditions as $condition ) { + $prop = $condition['data_key']; + $operator = $condition['operator'] ?? '='; + $value = $condition['data_value'] ?? []; + + if ( strpos( $prop, 'edd_license_' ) !== 0 ) { + continue; + } + + $download_id = (int) str_replace( 'edd_license_', '', $prop ); + if ( ! $download_id ) { + continue; + } + + // Backward compatibility: convert old yes/no values. + if ( $value === 'yes' ) { + $value = [ 'valid' ]; + } elseif ( $value === 'no' ) { + $value = [ 'valid' ]; + $operator = ( $operator === '=' || $operator === 'in' ) ? '!=' : '='; + } + + // Normalize value to an array of selected options. + if ( ! is_array( $value ) ) { + $value = [ $value ]; + } + + // Map selected options to EDD license statuses. + $statuses = $this->mapLicenseOptionToStatuses( $value ); + + if ( empty( $statuses ) ) { + return false; + } + + $has_matching_license = fluentCrmDb()->table( 'edd_licenses' ) + ->where( 'customer_id', $customer->id ) + ->where( 'download_id', $download_id ) + ->whereIn( 'status', $statuses ) + ->exists(); + + if ( $operator === '=' || $operator === 'in' ) { + if ( ! $has_matching_license ) { + return false; + } + } elseif ( $operator === '!=' || $operator === 'not_in' ) { + if ( $has_matching_license ) { + return false; + } + } else { + return false; + } + } + + return $result; + } + + /** + * Map UI option values to EDD license status strings. + * + * @param array $options Selected option values (e.g. ['valid', 'expired']). + * + * @return array EDD license statuses to query. + */ + private function mapLicenseOptionToStatuses( array $options ): array { + $status_map = [ + 'valid' => [ 'active', 'inactive' ], + 'active' => [ 'active' ], + 'inactive' => [ 'inactive' ], + 'expired' => [ 'expired' ], + 'disabled' => [ 'disabled' ], + ]; + + $statuses = []; + foreach ( $options as $option ) { + if ( isset( $status_map[ $option ] ) ) { + $statuses = array_merge( $statuses, $status_map[ $option ] ); + } + } + + return array_unique( $statuses ); + } + + /** + * Assess automation completion conditions. + * + * @param bool $result Current result. + * @param array $conditions Condition rules to evaluate. + * @param object $subscriber The subscriber being evaluated. + * @param object $sequence The current funnel sequence. + * @param int $funnelSubscriberId The funnel subscriber ID. + * + * @return bool + */ + public function assessAutomationConditions( $result, $conditions, $subscriber, $sequence, $funnelSubscriberId ): bool { + foreach ( $conditions as $condition ) { + $prop = $condition['data_key']; + $operator = $condition['operator'] ?? '='; + $value = $condition['data_value'] ?? 'yes'; + + // Extract funnel ID from the property name (funnel_completed_{id}). + if ( strpos( $prop, 'funnel_completed_' ) !== 0 ) { + continue; + } + + $funnel_id = (int) str_replace( 'funnel_completed_', '', $prop ); + if ( ! $funnel_id ) { + continue; + } + + $has_completed = FunnelSubscriber::where( 'subscriber_id', $subscriber->id ) + ->where( 'funnel_id', $funnel_id ) + ->where( 'status', 'completed' ) + ->exists(); + + $expects_completed = ( $value === 'yes' ); + + if ( $operator === '=' || $operator === 'in' ) { + if ( $has_completed !== $expects_completed ) { + return false; + } + } elseif ( $operator === '!=' || $operator === 'not_in' ) { + if ( $has_completed === $expects_completed ) { + return false; + } + } else { + // Unknown operator — fail safe. + return false; + } + } + + return $result; + } +} diff --git a/classes/EddLicenseActivationTracker.php b/classes/EddLicenseActivationTracker.php new file mode 100644 index 0000000..7dc64a5 --- /dev/null +++ b/classes/EddLicenseActivationTracker.php @@ -0,0 +1,54 @@ +get_license( $license_id ); + + if ( ! $license || ! $license->customer_id ) { + return; + } + + $customer = new \EDD_Customer( $license->customer_id ); + + if ( ! $customer->id || ! $customer->email ) { + return; + } + + $download = get_post( $download_id ); + $product_name = $download ? $download->post_title : ''; + + FluentCrmApi( 'tracker' )->track( [ + 'email' => $customer->email, + 'provider' => 'edd', + 'event_key' => 'license_activated', + 'title' => 'Activated license key', + 'value' => $product_name, + ] ); + } +} diff --git a/classes/Integrations/CustomEmailCSS.php b/classes/Integrations/CustomEmailCSS.php new file mode 100644 index 0000000..aa50c63 --- /dev/null +++ b/classes/Integrations/CustomEmailCSS.php @@ -0,0 +1,271 @@ +. + * + * @param string $html The rendered email HTML. + * @param string $email_body The email body content. + * @param array $template_config Template configuration. + * + * @return string + */ + public function inject_css( string $html, $email_body = '', $template_config = [] ): string { + $css = $this->get_css(); + + if ( empty( $css ) ) { + return $html; + } + + $css = $this->add_important( $css ); + + return str_replace( + '', + '' . "\n" . '', + $html + ); + } + + /** + * Add submenu page under FluentCRM. + */ + public function add_menu_page(): void { + add_submenu_page( + 'fluentcrm-admin', + esc_html__( 'Custom Email CSS', 'fluent-crm-custom-features' ), + esc_html__( 'Custom CSS', 'fluent-crm-custom-features' ), + 'manage_options', + self::PAGE_SLUG, + [ $this, 'render_page' ] + ); + } + + /** + * Enqueue CodeMirror assets on our admin page only. + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_scripts( string $hook_suffix ): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) { + return; + } + + $settings = wp_enqueue_code_editor( [ 'type' => 'text/css' ] ); + + if ( false === $settings ) { + return; + } + + wp_add_inline_script( + 'code-editor', + sprintf( + 'jQuery( function() { wp.codeEditor.initialize( "fluentcrm-custom-css", %s ); } );', + wp_json_encode( $settings ) + ) + ); + } + + /** + * Handle the form save. + */ + public function handle_save(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( + esc_html__( 'You do not have permission to access this page.', 'fluent-crm-custom-features' ), + esc_html__( 'Forbidden', 'fluent-crm-custom-features' ), + [ 'response' => 403 ] + ); + } + + check_admin_referer( 'fluentcrm_custom_css_save' ); + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized via sanitize_css() below. + $css = isset( $_POST['custom_css'] ) ? wp_unslash( $_POST['custom_css'] ) : ''; + $css = $this->sanitize_css( $css ); + + fluentcrm_update_option( self::OPTION_KEY, $css ); + + wp_safe_redirect( add_query_arg( + [ + 'page' => self::PAGE_SLUG, + 'updated' => '1', + ], + admin_url( 'admin.php' ) + ) ); + exit; + } + + /** + * Render the admin page. + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( + esc_html__( 'You do not have permission to access this page.', 'fluent-crm-custom-features' ), + esc_html__( 'Forbidden', 'fluent-crm-custom-features' ), + [ 'response' => 403 ] + ); + } + + $css = $this->get_css(); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $updated = isset( $_GET['updated'] ) && '1' === $_GET['updated']; + + ?> +
+

+ + + + + +

+ !important markup */ + esc_html__( 'Add custom CSS that will be injected into all FluentCRM email templates. %s is automatically added to every declaration so your styles override inline styles and template defaults.', 'fluent-crm-custom-features' ), + '!important' + ); + ?> +

+ +
+ + + + + + + +
+
+ where( 'event_key', $event_key_var ) - ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE '%{$escaped_value}%'" ); + ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE ?", [ '%' . $escaped_value . '%' ] ); } ); } elseif ( 'not_contains' === $operator ) { @@ -285,10 +286,9 @@ public static function apply_event_tracking_filter( $query, $filters ) { $q ->where( 'event_key', $event_key_var ) - ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE '%{$escaped_value}%'" ); + ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE ?", [ '%' . $escaped_value . '%' ] ); } ); - break; } } } diff --git a/classes/Migrations/FixDripMergeTags.php b/classes/Migrations/FixDripMergeTags.php new file mode 100644 index 0000000..f1c5b16 --- /dev/null +++ b/classes/Migrations/FixDripMergeTags.php @@ -0,0 +1,210 @@ + + */ + private static array $replacements = [ + // Footer links (full HTML versions). + '{{ manage_subscriptions_link }}' => '{{crm.manage_subscription_html|Manage Preferences}}', + '{{ unsubscribe_link }}' => '{{crm.unsubscribe_html|Unsubscribe}}', + + // Footer links (URL-only versions). + '{{ manage_subscriptions_url }}' => '##crm.manage_subscription_url##', + '{{ unsubscribe_url }}' => '##crm.unsubscribe_url##', + + // Postal address and account. + '{{ inline_postal_address }}' => '{{crm.business_address}}', + '{{ account.name }}' => '{{crm.business_name}}', + + // Subscriber fields. + '{{ subscriber.first_name }}' => '{{contact.first_name}}', + '{{ subscriber.last_name }}' => '{{contact.last_name}}', + '{{ subscriber.email }}' => '{{contact.email}}', + ]; + + /** + * Regex-based replacements for patterns with variable content. + * + * @var array + */ + private static array $regex_replacements = [ + '/\{\{\s*manage_subscriptions_link\s*\}\}/i' => '{{crm.manage_subscription_html|Manage Preferences}}', + '/\{\{\s*unsubscribe_link\s*\}\}/i' => '{{crm.unsubscribe_html|Unsubscribe}}', + '/\{\{\s*manage_subscriptions_url\s*\}\}/i' => '##crm.manage_subscription_url##', + '/\{\{\s*unsubscribe_url\s*\}\}/i' => '##crm.unsubscribe_url##', + '/\{\{\s*inline_postal_address\s*\}\}/i' => '{{crm.business_address}}', + '/\{\{\s*account\.name\s*\}\}/i' => '{{crm.business_name}}', + '/\{\{\s*subscriber\.first_name\s*\}\}/i' => '{{contact.first_name}}', + '/\{\{\s*subscriber\.last_name\s*\}\}/i' => '{{contact.last_name}}', + '/\{\{\s*subscriber\.email\s*\}\}/i' => '{{contact.email}}', + '/\{\{\s*subscriber\.custom_fields\.(\w+)\s*\}\}/i' => '{{contact.custom.$1}}', + '/\{\{\s*subscriber\.(\w+)\s*\}\}/i' => '{{contact.$1}}', + ]; + + /** + * Run the migration. + * + * @param bool $dry_run If true, only report what would change without modifying data. + * + * @return array{updated: int, skipped: int, errors: int} + */ + public static function run( bool $dry_run = false ): array { + $db = fluentCrmDb(); + + // Find all campaigns with Drip merge tags in email_body. + $campaigns = $db->table( 'fc_campaigns' ) + ->where( 'email_body', 'LIKE', '%{{ %' ) + ->where(function ( $q ) { + $q->where( 'email_body', 'LIKE', '%subscriber.%' ) + ->orWhere( 'email_body', 'LIKE', '%unsubscribe_link%' ) + ->orWhere( 'email_body', 'LIKE', '%manage_subscriptions%' ) + ->orWhere( 'email_body', 'LIKE', '%inline_postal_address%' ) + ->orWhere( 'email_body', 'LIKE', '%account.name%' ); + } ) + ->get(); + + $stats = [ + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + ]; + + foreach ( $campaigns as $campaign ) { + $original = $campaign->email_body; + $updated = self::convertMergeTags( $original ); + + if ( $updated === $original ) { + ++$stats['skipped']; + continue; + } + + if ( $dry_run ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( + '[FixDripMergeTags] DRY RUN — Would update campaign #%d (%s)', + $campaign->id, + $campaign->title ?? 'untitled' + ) ); + ++$stats['updated']; + continue; + } + + $result = $db->table( 'fc_campaigns' ) + ->where( 'id', $campaign->id ) + ->update( [ 'email_body' => $updated ] ); + + if ( $result !== false ) { + ++$stats['updated']; + } else { + ++$stats['errors']; + } + } + + // Also fix campaign_emails (individual sent emails) if they exist. + $emails = $db->table( 'fc_campaign_emails' ) + ->where( 'email_body', 'LIKE', '%{{ %' ) + ->where(function ( $q ) { + $q->where( 'email_body', 'LIKE', '%subscriber.%' ) + ->orWhere( 'email_body', 'LIKE', '%unsubscribe_link%' ) + ->orWhere( 'email_body', 'LIKE', '%manage_subscriptions%' ) + ->orWhere( 'email_body', 'LIKE', '%inline_postal_address%' ) + ->orWhere( 'email_body', 'LIKE', '%account.name%' ); + } ) + ->get(); + + foreach ( $emails as $email ) { + $original = $email->email_body; + $updated = self::convertMergeTags( $original ); + + if ( $updated === $original ) { + ++$stats['skipped']; + continue; + } + + if ( $dry_run ) { + ++$stats['updated']; + continue; + } + + $result = $db->table( 'fc_campaign_emails' ) + ->where( 'id', $email->id ) + ->update( [ 'email_body' => $updated ] ); + + if ( $result !== false ) { + ++$stats['updated']; + } else { + ++$stats['errors']; + } + } + + // Also fix funnel sequence settings (automation email bodies). + $sequences = $db->table( 'fc_funnel_sequences' ) + ->where( 'action_name', 'send_custom_email' ) + ->where( 'settings', 'LIKE', '%{{ %' ) + ->get(); + + foreach ( $sequences as $seq ) { + $settings = maybe_unserialize( $seq->settings ); + if ( ! is_array( $settings ) || empty( $settings['email_body'] ) ) { + ++$stats['skipped']; + continue; + } + + $original = $settings['email_body']; + $updated = self::convertMergeTags( $original ); + + if ( $updated === $original ) { + ++$stats['skipped']; + continue; + } + + if ( ! $dry_run ) { + $settings['email_body'] = $updated; + $result = $db->table( 'fc_funnel_sequences' ) + ->where( 'id', $seq->id ) + ->update( [ 'settings' => maybe_serialize( $settings ) ] ); + + if ( $result !== false ) { + ++$stats['updated']; + } else { + ++$stats['errors']; + } + } else { + ++$stats['updated']; + } + } + + return $stats; + } + + /** + * Convert Drip merge tags to FluentCRM merge tags. + * + * @param string $text Email content with Drip merge tags. + * + * @return string Content with FluentCRM merge tags. + */ + public static function convertMergeTags( string $text ): string { + foreach ( self::$regex_replacements as $pattern => $replacement ) { + $result = preg_replace( $pattern, $replacement, $text ); + if ( $result !== null ) { + $text = $result; + } + } + + return $text; + } +} diff --git a/classes/SmartLinkHandler.php b/classes/SmartLinkHandler.php index 4d9fa9f..4aab644 100644 --- a/classes/SmartLinkHandler.php +++ b/classes/SmartLinkHandler.php @@ -91,10 +91,13 @@ public function handleClick( $slug, $contact = null ) { * @return string The target URL with query parameters preserved. */ public function getTargetUrl( $smart_link, $contact ) { - $ignored_params = [ 'fluentcrm', 'route', 'slug' ]; // Define the parameters to ignore. + $ignored_params = [ 'fluentcrm', 'route', 'slug' ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading query params for smart link redirect, nonce not applicable. - $query_params = array_diff_key( $_GET, array_flip( $ignored_params ) ); // Filter out ignored parameters. - $query_string = http_build_query( $query_params ); // Build the query string from remaining parameters. + $query_params = array_diff_key( $_GET, array_flip( $ignored_params ) ); + + // Sanitize all forwarded query parameters (handles nested arrays). + $query_params = map_deep( $query_params, 'sanitize_text_field' ); + $query_string = http_build_query( $query_params ); $target_url = $smart_link->target_url; @@ -104,13 +107,13 @@ public function getTargetUrl( $smart_link, $contact ) { $target_url = esc_url_raw( $target_url ); } - if ( false === strpos( $target_url, '?' ) ) { - $target_url .= '?'; - } else { - $target_url .= '&'; + // Only append separator and query string if there are params to forward. + if ( $query_string ) { + $separator = ( false === strpos( $target_url, '?' ) ) ? '?' : '&'; + $target_url = $target_url . $separator . $query_string; } - return $target_url . $query_string; + return $target_url; } /** diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 74d52bb..5e4e8db 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -1,24 +1,35 @@ register(); + + // Track EDD license activations as FluentCRM events. + ( new \CustomCRM\EddLicenseActivationTracker() )->register(); + // Remove the default smart link handler. remove_all_actions( 'fluentcrm_smartlink_clicked' ); remove_all_actions( 'fluentcrm_smartlink_clicked_direct' ); @@ -46,120 +63,94 @@ function () { add_action( 'fluentcrm_smartlink_clicked', [ $fix_smart_link_redirects, 'handleClick' ], 9, 1 ); add_action( 'fluentcrm_smartlink_clicked_direct', [ $fix_smart_link_redirects, 'handleClick' ], 9, 2 ); + + // Custom CSS editor for FluentCRM email templates. + ( new \CustomCRM\Integrations\CustomEmailCSS() )->register(); }, 99 ); -// Hook to add custom dashboard metrics -// add_action( 'fluent_crm/dashboard_stats', 'add_custom_dashboard_metrics' ); - -/** - * Add custom dashboard metrics. - * - * @param array $data The dashboard data. - * - * @return array - */ -function add_custom_dashboard_metrics( $data ) { - // Example: Adding a new metric for total subscribers. - $total_subscribers = \FluentCrm\App\Models\Subscriber::count(); - - $data['total_subscribers_metric'] = [ - 'title' => __( 'Total Subscribers', 'fluent-crm-custom-features' ), - 'count' => $total_subscribers, - 'route' => [ - 'name' => 'subscribers', +// Hook to register custom REST API endpoints. +add_action( 'rest_api_init', function () { + register_rest_route( 'fluent-crm/v1', '/list-growth', [ + 'methods' => 'GET', + 'callback' => 'customcrm_get_list_growth', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'from' => [ + 'required' => false, + 'validate_callback' => 'customcrm_validate_date_param', + ], + 'to' => [ + 'required' => false, + 'validate_callback' => 'customcrm_validate_date_param', + ], ], - ]; - - // Add more metrics as needed - return $data; -} - -// Hook to register a custom report -add_action( 'fluent_crm/reporting/reports', 'register_custom_report' ); + ] ); +} ); /** - * Register a custom report. + * Validate a date parameter for the REST API. * - * @param array $reports The reports array. + * @param string $value The parameter value. * - * @return array + * @return bool */ -function register_custom_report( $reports ) { - $reports['custom_report'] = [ - 'title' => __( 'Custom Report', 'fluent-crm-custom-features' ), - 'callback' => 'render_custom_report', - ]; - - return $reports; -} +function customcrm_validate_date_param( $value ) { + // Allow empty values (defaults will be used). + if ( empty( $value ) ) { + return true; + } -/** - * Render the custom report. - * - * @return void - */ -function render_custom_report() { - // Logic to render your custom report - $subscribers = \FluentCrm\App\Models\Subscriber::all(); - // Output your report data here - echo '

' . esc_html__( 'Custom Report', 'fluent-crm-custom-features' ) . '

'; - echo '
    '; - foreach ( $subscribers as $subscriber ) { - echo '
  • ' . esc_html( $subscriber->email ) . '
  • '; + // Must match YYYY-MM-DD format and be a valid date. + if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches ) ) { + return false; } - echo '
'; -} -// Hook to register a custom REST API endpoint -add_action('rest_api_init', function () { - register_rest_route('fluent-crm/v1', '/list-growth', [ - 'methods' => 'GET', - 'callback' => 'get_list_growth', - 'permission_callback' => '__return_true', - ]); -}); + return checkdate( (int) $matches[2], (int) $matches[3], (int) $matches[1] ); +} /** - * Get List Growth metrics + * Get List Growth metrics. * * @param WP_REST_Request $request The REST request object. + * * @return WP_REST_Response */ -function get_list_growth( WP_REST_Request $request ) { +function customcrm_get_list_growth( WP_REST_Request $request ) { $from = $request->get_param( 'from' ); $to = $request->get_param( 'to' ); - // Convert dates to Carbon instances - $from_date = \Carbon\Carbon::parse( $from ); - $to_date = \Carbon\Carbon::parse( $to ); + // Default to current month if not provided. + $from = ! empty( $from ) ? sanitize_text_field( $from ) : gmdate( 'Y-m-01' ); + $to = ! empty( $to ) ? sanitize_text_field( $to ) : gmdate( 'Y-m-t' ); - // Count new subscribers + // Count new subscribers. $new_subscribers = fluentCrmDb()->table( 'fc_subscribers' ) - ->whereBetween( 'created_at', [ $from_date->format( 'Y-m-d' ), $to_date->format( 'Y-m-d' ) ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'status', 'subscribed' ) ->count(); - // Count unsubscribed + // Count unsubscribed. $unsubscribed = fluentCrmDb()->table( 'fc_subscriber_meta' ) - ->whereBetween( 'created_at', [ $from_date->format( 'Y-m-d' ), $to_date->format( 'Y-m-d' ) ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'key', 'unsubscribe_reason' ) ->count(); - // Calculate net growth + // Calculate net growth. $net_growth = $new_subscribers - $unsubscribed; - return new WP_REST_Response([ + return new WP_REST_Response( [ 'new_subscribers' => $new_subscribers, 'unsubscribed' => $unsubscribed, 'net_growth' => $net_growth, - ], 200); + ], 200 ); } - // Hook to add custom metrics to the dashboard. -add_filter( 'fluent_crm/dashboard_data', 'add_custom_dashboard_metrics_for_list_growth' ); +add_filter( 'fluent_crm/dashboard_data', 'customcrm_add_dashboard_list_growth_metrics' ); /** * Add custom dashboard metrics for list growth. @@ -168,7 +159,7 @@ function get_list_growth( WP_REST_Request $request ) { * * @return array */ -function add_custom_dashboard_metrics_for_list_growth( $data ) { +function customcrm_add_dashboard_list_growth_metrics( $data ) { // Get the date range from the request or set default values. // phpcs:disable WordPress.Security.NonceVerification.Recommended $from = isset( $_GET['from'] ) ? sanitize_text_field( wp_unslash( $_GET['from'] ) ) : gmdate( 'Y-m-01' ); @@ -177,12 +168,12 @@ function add_custom_dashboard_metrics_for_list_growth( $data ) { // Calculate new subscribers and unsubscribes. $new_subscribers = fluentCrmDb()->table( 'fc_subscribers' ) - ->whereBetween( 'created_at', [ $from, $to ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'status', 'subscribed' ) ->count(); $unsubscribed = fluentCrmDb()->table( 'fc_subscriber_meta' ) - ->whereBetween( 'created_at', [ $from, $to ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'key', 'unsubscribe_reason' ) ->count(); diff --git a/package.json b/package.json index 964d7de..de6006e 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,7 @@ }, "files": [ "classes/**/*", - "vendor-prefixed/**/*", - "*.php", - ".phpcs.xml.dist" + "*.php" ], "scripts": { "release": "node bin/build-release.js" diff --git a/vendor-prefixed/.gitkeep b/vendor-prefixed/.gitkeep new file mode 100644 index 0000000..e69de29