connection ID). Empty means * "use $connection_id". * * @var array */ public $source_connections = array(); /** * Search Console property, e.g. "sc-domain:example.com" or "https://example.com/". * * @var string */ public $gsc_site = ''; /** * Search Console search type: web, image, video or news. * * @var string */ public $search_type = 'web'; /** * GA4 property resource name, e.g. "properties/123456". * * @var string */ public $ga_property = ''; /** * GA4 property display name. * * @var string */ public $ga_property_name = ''; /** * Business Profile location resource name, e.g. "locations/987". * * @var string */ public $gbp_location = ''; /** * Business Profile location display name. * * @var string */ public $gbp_location_name = ''; /** * Enabled report sections (SECTION_* constants). * * @var string[] */ public $sections = array(); /** * What this site calls its conversions, e.g. "Leads", "Bookings" or "Sales". * * @var string */ public $conversion_label = ''; /** * Conversion groups: each has a label and the GA4 event names it counts. * * @var array[] Items of ['label' => string, 'events' => string[]]. */ public $conversion_groups = array(); /** * Which GA4 channel dimension decides "organic". * * @var string */ public $lead_basis = self::BASIS_FIRST_USER; /** * GA4 traffic metrics to show (keys of TrafficMetrics::all()). * * @var string[] */ public $traffic_metrics = array(); /** * Whether traffic metrics cover organic search only or all channels. * * @var string */ public $traffic_scope = self::SCOPE_ORGANIC; /** * Segments (named query or page filters). * * @var array[] See Segments::normalize(). */ public $segments = array(); /** * Client logo attachment ID, shown on the PDF cover and in emails. * * @var int */ public $client_logo_id = 0; /** * Scheduled email report settings. * * @var array See Email\Schedule::normalize(). */ public $email = array(); /** * Delivery options: PDF attachment, secure link, review mode, incomplete reports, insights. * * @var array See Delivery\Options::normalize(). */ public $delivery = array(); /** * Goals and priorities: main goal, important pages, priority GA4 events. * * @var array See Priorities::normalize(). */ public $priorities = array(); /** * Roles allowed to view this site. * * @var string[] */ public $roles = array(); /** * Creation time as a Unix timestamp. * * @var int */ public $created = 0; /** * A new site starts with the same defaults as stored data with fields missing: * the default sections and metrics ticked, and a complete (off) email schedule. */ public function __construct() { $this->sections = self::default_sections(); $this->traffic_metrics = TrafficMetrics::defaults(); $this->email = Schedule::normalize( array() ); $this->delivery = Options::defaults(); $this->source_connections = self::empty_sources(); $this->priorities = Priorities::defaults(); foreach ( ConversionPresets::default_groups() as $group ) { $this->conversion_groups[] = array( 'label' => (string) $group['label'], 'events' => array_values( array_map( 'strval', (array) $group['events'] ) ), ); } } /** * Builds a site from stored data. Missing fields get defaults. * * @param array $data Stored data. * @return self */ public static function from_array( array $data ) { $p = new self(); $str = static function ( $key, $fallback = '' ) use ( $data ) { return isset( $data[ $key ] ) && is_scalar( $data[ $key ] ) ? (string) $data[ $key ] : $fallback; }; $list = static function ( $key, $fallback = array() ) use ( $data ) { return isset( $data[ $key ] ) && is_array( $data[ $key ] ) ? array_values( array_map( 'strval', array_filter( $data[ $key ], 'is_scalar' ) ) ) : $fallback; }; $p->id = $str( 'id' ); $p->name = $str( 'name' ); $p->connection_id = $str( 'connection_id' ); $p->gsc_site = $str( 'gsc_site' ); $p->search_type = in_array( $str( 'search_type' ), self::SEARCH_TYPES, true ) ? $str( 'search_type' ) : 'web'; $p->ga_property = $str( 'ga_property' ); $p->ga_property_name = $str( 'ga_property_name' ); $p->gbp_location = $str( 'gbp_location' ); $p->gbp_location_name = $str( 'gbp_location_name' ); $p->sections = array_values( array_intersect( $list( 'sections', self::default_sections() ), self::all_sections() ) ); $p->conversion_label = $str( 'conversion_label' ); $p->lead_basis = self::BASIS_SESSION === $str( 'lead_basis' ) ? self::BASIS_SESSION : self::BASIS_FIRST_USER; $p->traffic_metrics = array_values( array_intersect( $list( 'traffic_metrics', TrafficMetrics::defaults() ), array_keys( TrafficMetrics::all() ) ) ); $p->traffic_scope = self::SCOPE_ALL === $str( 'traffic_scope' ) ? self::SCOPE_ALL : self::SCOPE_ORGANIC; $p->roles = $list( 'roles' ); $p->created = isset( $data['created'] ) ? (int) $data['created'] : 0; $p->segments = isset( $data['segments'] ) && is_array( $data['segments'] ) ? Segments::normalize( $data['segments'] ) : array(); $p->client_logo_id = isset( $data['client_logo_id'] ) ? max( 0, (int) $data['client_logo_id'] ) : 0; $p->email = Schedule::normalize( isset( $data['email'] ) && is_array( $data['email'] ) ? $data['email'] : array() ); // Missing delivery fields use the same defaults as a newly created site. $p->delivery = isset( $data['delivery'] ) && is_array( $data['delivery'] ) ? Options::normalize( $data['delivery'] ) : Options::defaults(); $p->source_connections = self::empty_sources(); // Existing 1.0 sites have no priorities: retain the usual order until goals are chosen. $p->priorities = Priorities::normalize( isset( $data['priorities'] ) ? $data['priorities'] : array() ); if ( isset( $data['source_connections'] ) && is_array( $data['source_connections'] ) ) { foreach ( array_keys( $p->source_connections ) as $source ) { $value = isset( $data['source_connections'][ $source ] ) && is_scalar( $data['source_connections'][ $source ] ) ? (string) $data['source_connections'][ $source ] : ''; $p->source_connections[ $source ] = preg_match( '/^[a-z0-9]{2,20}$/', $value ) ? $value : ''; } } $groups = isset( $data['conversion_groups'] ) && is_array( $data['conversion_groups'] ) ? $data['conversion_groups'] : ConversionPresets::default_groups(); $p->conversion_groups = array(); foreach ( $groups as $group ) { if ( is_array( $group ) && isset( $group['label'] ) && is_scalar( $group['label'] ) && '' !== (string) $group['label'] ) { $p->conversion_groups[] = array( 'label' => (string) $group['label'], 'events' => isset( $group['events'] ) && is_array( $group['events'] ) ? array_values( array_map( 'strval', array_filter( $group['events'], 'is_scalar' ) ) ) : array(), ); } } return $p; } /** * Data to store. * * @return array */ public function to_array() { return get_object_vars( $this ); } /** * Source keys, in display order. * * @return string[] */ public static function sources() { return array( self::SOURCE_SEARCH, self::SOURCE_ANALYTICS, self::SOURCE_BUSINESS ); } /** * Per-source overrides with nothing set. * * @return array */ public static function empty_sources() { return array_fill_keys( self::sources(), '' ); } /** * The Google source a report section reads from. * * @param string $section SECTION_* constant. * @return string SOURCE_* constant. */ public static function source_for_section( $section ) { switch ( $section ) { case self::SECTION_SEARCH: return self::SOURCE_SEARCH; case self::SECTION_BUSINESS: return self::SOURCE_BUSINESS; default: return self::SOURCE_ANALYTICS; } } /** * Connection that reads a source: its own override, else the site's main connection. * * @param string $source SOURCE_* constant. * @return string Connection ID, or '' when none is chosen. */ public function connection_for( $source ) { $own = isset( $this->source_connections[ $source ] ) ? (string) $this->source_connections[ $source ] : ''; return '' !== $own ? $own : $this->connection_id; } /** * Whether a source reads through a different connection than the main one. * * @param string $source SOURCE_* constant. * @return bool */ public function has_own_connection( $source ) { return '' !== ( isset( $this->source_connections[ $source ] ) ? (string) $this->source_connections[ $source ] : '' ) && $this->source_connections[ $source ] !== $this->connection_id; } /** * Every connection this site reads through. * * @return string[] */ public function connection_ids() { $ids = array( $this->connection_id ); foreach ( self::sources() as $source ) { $ids[] = $this->connection_for( $source ); } return array_values( array_unique( array_filter( $ids ) ) ); } /** * Sources that read through a connection. * * @param string $connection_id Connection ID. * @return string[] SOURCE_* constants. */ public function sources_using( $connection_id ) { $out = array(); foreach ( self::sources() as $source ) { if ( '' !== $connection_id && $this->connection_for( $source ) === $connection_id ) { $out[] = $source; } } return $out; } /** * Every section key. * * @return string[] */ public static function all_sections() { return array( self::SECTION_SEARCH, self::SECTION_CONVERSIONS, self::SECTION_AI, self::SECTION_TRAFFIC, self::SECTION_BUSINESS ); } /** * Sections switched on for new sites. * * @return string[] */ public static function default_sections() { return self::all_sections(); } /** * Whether a section is switched on and its source is configured. * * The REST API refuses data for sections that are off, so turning a * section off really stops it being collected and shown. * * @param string $section SECTION_* constant. * @return bool */ public function wants( $section ) { if ( ! in_array( $section, $this->sections, true ) ) { return false; } switch ( $section ) { case self::SECTION_SEARCH: return '' !== $this->gsc_site; case self::SECTION_BUSINESS: return '' !== $this->gbp_location; default: return '' !== $this->ga_property; } } /** * GA4 events that appear in more than one conversion group. * * The overall total counts each event once, but each group's own number * still includes it, so the groups can add up to more than the total. * * @return array Event name => labels of the groups it is in. */ public function overlapping_events() { $seen = array(); foreach ( $this->conversion_groups as $group ) { foreach ( array_unique( $group['events'] ) as $event ) { $seen[ $event ][] = $group['label']; } } return array_filter( $seen, static function ( $labels ) { return count( $labels ) > 1; } ); } /** * Every GA4 event name counted as a conversion, across all groups. * * @return string[] */ public function all_conversion_events() { $out = array(); foreach ( $this->conversion_groups as $group ) { $out = array_merge( $out, $group['events'] ); } return array_values( array_unique( $out ) ); } /** * Goals and priorities, cleaned. * * @return array{goal:string,custom:string,pages:string[],events:string[]} */ public function priorities() { return Priorities::normalize( $this->priorities ); } /** * Priority GA4 events that are still among this site's conversion events. * * An event removed from the conversion groups stops counting as a priority. * * @return string[] */ public function priority_events() { return array_values( array_intersect( $this->priorities()['events'], $this->all_conversion_events() ) ); } /** * Conversion label, falling back to "Leads". * * When the label is the default "Leads" but some of the GA4 events are * clicks (a phone or email link, a button), the label says so, because a * tap on a phone number is not a lead yet. A label chosen for the site * (Enquiries, Sign-ups ...) is always kept as it is. * * @return string */ public function conversion_label() { $leads = __( 'Leads', 'turtlereport' ); $label = '' !== $this->conversion_label ? $this->conversion_label : $leads; if ( $label !== $leads && 'Leads' !== $label ) { return $label; } $events = $this->all_conversion_events(); $clicks = array_values( array_filter( $events, array( __CLASS__, 'is_click_event' ) ) ); if ( ! $clicks ) { return $label; } $phone = count( $clicks ) === count( array_filter( $clicks, array( __CLASS__, 'is_phone_event' ) ) ); if ( count( $clicks ) === count( $events ) ) { return $phone ? __( 'Phone clicks', 'turtlereport' ) : __( 'Contact clicks', 'turtlereport' ); } return $phone ? __( 'Leads + phone clicks', 'turtlereport' ) : __( 'Leads + clicks', 'turtlereport' ); } /** * Whether a GA4 event records a click or tap rather than a submitted lead. * * @param string $event Event name. * @return bool */ public static function is_click_event( $event ) { return (bool) preg_match( '/click|(^|[\s_\-])tap([\s_\-]|$)|outbound|^(tel|mailto|whatsapp)([\s_:\-]|$)/i', (string) $event ); } /** * Whether a click event is a phone (or WhatsApp) tap. * * @param string $event Event name. * @return bool */ public static function is_phone_event( $event ) { return (bool) preg_match( '/phone|call|(^|[\s_\-])tel([\s_:\-]|$)|whatsapp/i', (string) $event ); } /** * Search Console site as a readable host name. * * @return string */ public function site_label() { $label = preg_replace( '#^(sc-domain:|https?://)#', '', $this->gsc_site ); return rtrim( (string) $label, '/' ); } /** * Name to show in lists. * * @return string */ public function display_name() { if ( '' !== $this->name ) { return $this->name; } if ( '' !== $this->gsc_site ) { return $this->site_label(); } return '' !== $this->ga_property_name ? $this->ga_property_name : __( 'Untitled site', 'turtlereport' ); } }