name ) { return (string) $lead->name; } if ( '' !== (string) $lead->email ) { return (string) $lead->email; } /* translators: %s: order number */ return 'order' === ( $lead->form_plugin ?? '' ) && '' !== (string) ( $lead->form_id ?? '' ) ? sprintf( __( 'Order %s', 'dealroot' ), $lead->form_id ) : __( '(no name)', 'dealroot' ); } /** * Page output. * * @param int $lead_id Lead ID. */ public function render( $lead_id ) { $lead = Leads::get( $lead_id ); $back = array( 'url' => Shell::url( Admin::LEADS_PAGE ), 'label' => __( 'All leads', 'dealroot' ), ); if ( ! $lead ) { Shell::open( 'leads', __( 'Lead not found', 'dealroot' ), array( 'back' => $back ) ); echo '

' . esc_html__( 'This lead was deleted, or the link is not complete.', 'dealroot' ) . '

'; Shell::close(); return; } $title = self::title( $lead ); Shell::open( 'leads', $title, array( 'description' => $this->summary( $lead ), 'actions' => $this->contact_actions( $lead ), 'back' => $back, 'media' => Shell::avatar( $title, 'dealroot-avatar--large' ), 'class' => 'dealroot-lead', ) ); ?>

stage( $lead ); ?>
privacy( $lead ); $this->reply( $lead ); \Dealroot\Messages::panel( $lead ); \Dealroot\Messages::seen( $lead ); ?>

fields( $lead ); ?>

' . esc_html( $signals ) . '

'; } $this->timeline( $lead ); $this->recording( $lead ); ?>
outcome_form( $lead ); $this->similar( $lead ); /** * Fires in the sidebar of a lead, below the deal. * * @param object $lead Lead. */ do_action( 'dealroot_lead_sidebar', $lead ); $this->related( $lead ); $this->details_form( $lead ); ?>
privacy_request ) { return; } $status = Privacy_Requests::status( $lead ); $statuses = array( 'pending' => __( 'Started. Waiting until the person confirms the request by email.', 'dealroot' ), 'confirmed' => __( 'Confirmed by the person. Finish it under Tools with one click.', 'dealroot' ), 'completed' => __( 'Done.', 'dealroot' ), 'failed' => __( 'The request failed. Look at it under Tools.', 'dealroot' ), ); ?>

privacy_request ? __( 'Request to delete personal data', 'dealroot' ) : __( 'Request for a copy of personal data', 'dealroot' ) ); ?>

__( 'For enquiries that did not come through a form, such as phone calls, visits or recommendations.', 'dealroot' ), 'back' => array( 'url' => Shell::url( Admin::LEADS_PAGE ), 'label' => __( 'All leads', 'dealroot' ), ), 'class' => 'dealroot-lead', ) ); ?>

details_fields( array( 'name' => '', 'email' => '', 'phone' => '', 'channel' => 'phone_call', 'source' => '', 'campaign' => '', ) ); ?>

deal_fields( null, Settings::get( 'currency' ), '' ); ?>
created_at . ' UTC' ) ); if ( 'manual' === $lead->form_plugin ) { /* translators: %s: date and time */ return sprintf( __( 'Added by hand on %s.', 'dealroot' ), $when ); } if ( '' !== $lead->form_name ) { /* translators: 1: name of the form, 2: date and time */ return sprintf( __( 'Sent the form “%1$s” on %2$s.', 'dealroot' ), $lead->form_name, $when ); } /* translators: %s: date and time */ return sprintf( __( 'Received on %s.', 'dealroot' ), $when ); } /** * Where the lead stands, as steps that change the status in one click. * * @param object $lead Lead. */ private function stage( $lead ) { $labels = Leads::status_labels(); $index = array_search( $lead->status, self::STAGES, true ); $currency = '' !== (string) $lead->currency ? $lead->currency : Settings::get( 'currency' ); ?>
id ); ?>
    $stage ) : ?>
status, array( 'lost', 'spam' ), true ) ) : ?> status ] ); ?> status ) : ?> status ) : ?>

channel ) ); ?>
campaign ) : ?>
campaign ); ?>
value ) : ?> — value, $currency ) ); ?> status ) : ?>
created_at . ' UTC' ) ) ) ) ); ?>
id ] ) ) { $list = array(); $keyword = self::keyword( $lead ); if ( '' !== $keyword ) { $list['keyword'] = array( 'label' => __( 'Keyword', 'dealroot' ), 'value' => $keyword, 'after' => 'campaign', ); } /** * Filters the facts at the top of a lead. * * @param array $list Facts by key, each with label, value, an optional note and * after: "campaign" or "value". * @param object $lead Lead. * @param string $currency Currency of the site. */ $facts[ $lead->id ] = (array) apply_filters( 'dealroot_lead_facts', $list, $lead, $currency ); } return array_filter( $facts[ $lead->id ], static function ( $fact ) use ( $after ) { return is_array( $fact ) && '' !== (string) ( $fact['value'] ?? '' ) && ( $fact['after'] ?? 'value' ) === $after; } ); } /** * One fact at the top of a lead. * * @param array $fact Label, value, note and tone (good or bad). */ private static function fact( array $fact ) { $tone = in_array( $fact['tone'] ?? '', array( 'good', 'bad' ), true ) ? ' dealroot-fact--' . $fact['tone'] : ''; ?>
attribution, true ); foreach ( array( 'last', 'first' ) as $touch ) { $term = trim( (string) ( $attribution[ $touch ]['utm_term'] ?? '' ) ); // A placeholder that was not filled in, e.g. "{keyword}", says nothing. if ( '' !== $term && ! preg_match( '/[{}]/', $term ) ) { return mb_substr( sanitize_text_field( rawurldecode( str_replace( '+', ' ', $term ) ) ), 0, 80 ); } } return ''; } /** * Other enquiries from the same person. * * @param object $lead Lead. */ private function related( $lead ) { $related = Leads::related( $lead ); if ( ! $related ) { return; } $statuses = Leads::status_labels(); $customer = Lead_Insights::customer( $lead ); ?>

= 2 && $slow >= 2 && $fast_won / $fast > $slow_won / $slow ) { $speed = sprintf( /* translators: 1: won, 2: answered within an hour, 3: won, 4: answered later */ __( 'Answered within an hour: %1$s of %2$s won. Later: %3$s of %4$s.', 'dealroot' ), number_format_i18n( $fast_won ), number_format_i18n( $fast ), number_format_i18n( $slow_won ), number_format_i18n( $slow ) ); } ?>

id ); $this->details_fields( (array) $lead ); ?>
array( __( 'Name', 'dealroot' ), 'text' ), 'email' => array( __( 'Email', 'dealroot' ), 'email' ), 'phone' => array( __( 'Phone', 'dealroot' ), 'tel' ), 'source' => array( __( 'Source', 'dealroot' ), 'text' ), 'campaign' => array( __( 'Campaign', 'dealroot' ), 'text' ), ); ?>

phone, '+' ) ? (string) preg_replace( '/[^\d+]/', '', $lead->phone ) : Phone::to_e164( $lead->phone ); $links = array(); if ( '' !== $lead->email ) { $links[] = '' . esc_html__( 'Email', 'dealroot' ) . ''; } if ( '' !== $phone ) { $links[] = '' . esc_html__( 'Call', 'dealroot' ) . ''; $links[] = 'WhatsApp'; } return $links ? '

' . implode( '', $links ) . '

' : ''; } /** * Quick replies: a text with the name filled in, sent by email or WhatsApp. * * @param object $lead Lead. */ private function reply( $lead ) { $phone = 0 === strpos( (string) $lead->phone, '+' ) ? (string) preg_replace( '/[^\d+]/', '', $lead->phone ) : Phone::to_e164( $lead->phone ); $replies = Replies::all( $lead ); if ( 'spam' === $lead->status || ! $replies || ( '' === $lead->email && '' === $phone ) ) { return; } $filled = array(); // An answer the assistant wrote, e.g. when asked on the overview, comes first. if ( '' !== (string) ( $lead->ai_draft ?? '' ) && strtotime( (string) $lead->ai_draft_at . ' UTC' ) > time() - 3 * DAY_IN_SECONDS ) { $filled[] = array( 'title' => __( 'Written by the assistant', 'dealroot' ), 'subject' => Replies::fill( (string) ( $replies[0]['subject'] ?? '' ), $lead ), 'body' => (string) $lead->ai_draft, ); } foreach ( $replies as $reply ) { $filled[] = array( 'title' => '' !== $reply['title'] ? $reply['title'] : wp_html_excerpt( $reply['body'], 30, '…' ), 'subject' => Replies::fill( $reply['subject'], $lead ), 'body' => Replies::fill( $reply['body'], $lead ), ); } ?>

$reply ) : ?>
email ) : ?>

email && current_user_can( 'upload_files' ) ) : ?>
    email ) : ?>

    email ) { /* translators: %s: email address */ echo esc_html( sprintf( __( 'Your site sends it to %s, with the files, and keeps it in the conversation below.', 'dealroot' ), $lead->email ) ); } if ( '' !== $phone ) { echo ' ' . esc_html__( 'WhatsApp opens with the text ready; send it there, then say here that it went out, and it is kept in the conversation.', 'dealroot' ); } ?> email ) : ?>

    fields, true ); ?> ' . esc_html( $value ) . ''; } elseif ( in_array( $field['type'] ?? '', array( 'tel', 'phone' ), true ) ) { $value_html = '' . esc_html( $value ) . ''; } else { $value_html = nl2br( esc_html( $value ) ); } ?>
    array( 'href' => array() ), 'br' => array(), ) ); ?>
    form_name . ', ' . $this->form_plugin_label( $lead->form_plugin ), ', ' ) ); ?>
    attribution, true ); $created = strtotime( $lead->created_at . ' UTC' ); $entries = array(); if ( 'manual' === $lead->form_plugin || ( empty( $attribution['first'] ) && '' !== $lead->channel ) ) { $rows = array_filter( array( __( 'Source', 'dealroot' ) => $lead->source, __( 'Campaign', 'dealroot' ) => $lead->campaign, ), 'strlen' ); $entries[] = array( 'time' => $created, 'title' => 'manual' === $lead->form_plugin ? __( 'Added by hand', 'dealroot' ) : __( 'Source entered by hand', 'dealroot' ), 'body' => $this->channel_line( $lead->channel ) . $this->rows( $rows ), 'tone' => 'enquiry', ); } elseif ( empty( $attribution['first'] ) ) { $entries[] = array( 'time' => null, 'title' => __( 'No tracking data', 'dealroot' ), 'text' => __( 'The visitor may have declined cookies, or the form was submitted before tracking was enabled.', 'dealroot' ), 'tone' => 'quiet', ); } else { $journey = ! empty( $attribution['journey'] ) ? (array) $attribution['journey'] : array(); $visits = array( $attribution['first'] ); $first = $this->touch_entry( __( 'First visit', 'dealroot' ), $attribution['first'], $lead ); if ( $journey && (int) ( $journey['n'] ?? 1 ) > 1 ) { $first['text'] = self::visits_text( $journey, $created ); } $entries[] = $first; if ( ! empty( $attribution['last'] ) && $attribution['last'] !== $attribution['first'] ) { $entries[] = $this->touch_entry( __( 'Most recent visit', 'dealroot' ), $attribution['last'], $lead ); $visits[] = $attribution['last']; } $entries = array_merge( $entries, $this->page_entries( $journey, $visits ) ); } if ( 'manual' !== $lead->form_plugin ) { $entries[] = array( 'time' => $created, 'title' => '' !== $lead->form_name /* translators: %s: name of the form */ ? sprintf( __( 'Sent “%s”', 'dealroot' ), $lead->form_name ) : __( 'Enquiry received', 'dealroot' ), 'text' => $this->form_plugin_label( $lead->form_plugin ), 'body' => self::enquiry_details( is_array( $attribution ) ? $attribution : array(), $created, $lead ), 'tone' => 'enquiry', ); } $contacted = \Dealroot\Reports::first_answer( $lead ); if ( $contacted ) { $entries[] = array( 'time' => $contacted, 'title' => __( 'First response', 'dealroot' ), /* translators: %s: duration such as "2 h 15 min". */ 'text' => sprintf( __( '%s after the enquiry', 'dealroot' ), Format::duration( max( 0, $contacted - $created ) ) ), 'tone' => 'contacted', ); } if ( 'won' === $lead->status && ! empty( $lead->won_at ) ) { $entries[] = array( 'time' => strtotime( $lead->won_at . ' UTC' ), 'title' => __( 'Won', 'dealroot' ), 'text' => null === $lead->value ? __( 'No amount yet.', 'dealroot' ) : Format::money( (float) $lead->value, $lead->currency ), 'tone' => 'won', ); } /** * Filters the entries in the timeline of a lead, e.g. to show when it was reported to an ad platform. * * @param array[] $entries Each with time (Unix timestamp or null), title, text and tone * (enquiry, contacted, qualified, won, lost, alert or quiet). * @param object $lead Lead. */ $entries = (array) apply_filters( 'dealroot_lead_timeline', $entries, $lead ); // Entries keep their order when they have no time or the same time. foreach ( $entries as $position => $entry ) { $entries[ $position ]['position'] = $position; } usort( $entries, static function ( $a, $b ) { if ( empty( $a['time'] ) || empty( $b['time'] ) || (int) $a['time'] === (int) $b['time'] ) { return $a['position'] <=> $b['position']; } return (int) $a['time'] <=> (int) $b['time']; } ); ?>
      timeline_entry( (array) $entry ); ?>
  • array( 'class' => array() ), 'span' => array( 'class' => array(), 'aria-hidden' => array(), 'data-dealroot-icon' => array(), ), 'svg' => array( 'width' => array(), 'height' => array(), 'viewbox' => array(), 'aria-hidden' => array(), 'fill' => array(), 'stroke' => array(), 'stroke-width' => array(), 'stroke-linecap' => array(), 'stroke-linejoin' => array(), ), 'path' => array( 'd' => array(), 'fill' => array(), 'stroke' => array(), 'stroke-width' => array(), 'stroke-linecap' => array(), 'stroke-linejoin' => array(), ), 'rect' => array( 'x' => array(), 'y' => array(), 'width' => array(), 'height' => array(), 'rx' => array(), ), 'div' => array( 'class' => array() ), 'dl' => array(), 'dt' => array(), 'dd' => array(), 'ol' => array( 'class' => array() ), 'li' => array(), 'details' => array(), 'summary' => array(), 'a' => array( 'class' => array(), 'href' => array(), 'target' => array(), 'rel' => array(), ), ); } /** * Channel of a visit as a line of the timeline. * * @param string $channel Channel key. * @return string HTML. */ private function channel_line( $channel ) { return '

    ' . esc_html( Attribution::channel_label( $channel ) ) . '

    '; } /** * Label and value pairs. * * @param array $rows Label => value. * @return string HTML. */ private function rows( array $rows ) { if ( ! $rows ) { return ''; } $html = '
    '; foreach ( $rows as $label => $value ) { $html .= '
    ' . esc_html( (string) $label ) . '
    ' . esc_html( (string) $value ) . '
    '; } return $html . '
    '; } /** * A visit with its channel and campaign details. * * @param string $title Title. * @param array $touch Stored visit. * @param object $lead Lead. * @return array Entry. */ private function touch_entry( $title, array $touch, $lead ) { $page = ! empty( $touch['lp'] ) ? self::page_line( (string) $touch['lp'] ) : ''; /** * Filters the details of a visit in the timeline of a lead, e.g. to add what the ad * click cost. * * @param array $rows Label => value. * @param array $touch Stored visit: utm_*, click IDs, ref, lp and ts. * @param object $lead Lead. */ $rows = (array) apply_filters( 'dealroot_visit_rows', $this->touch_rows( $touch ), $touch, $lead ); return array( 'time' => ! empty( $touch['ts'] ) ? (int) $touch['ts'] : null, 'title' => $title, 'body' => self::source_line( $touch ) . $page . $this->rows( $rows ), 'tone' => 'visit', ); } /** * Where a visit came from: the icon and name of the source and the channel. * * @param array $touch Stored visit. * @return string HTML. */ private static function source_line( array $touch ) { $channel = Attribution::channel( $touch ); $key = Sources::key( Sources::paid_key( Attribution::source( $touch ), $channel ) ); $html = '

    ' . Shell::source_icon( $key, true ); if ( '(direct)' !== $key ) { $html .= '' . esc_html( Sources::label( $key ) ) . ''; } return $html . '' . esc_html( Attribution::channel_label( $channel ) ) . '

    '; } /** * The device and place of the enquiry and how long the visitor had been on the site. * * @param array $attribution Stored attribution. * @param int $created Time of the enquiry. * @param object $lead Lead. * @return string HTML. */ private static function enquiry_details( array $attribution, $created, $lead ) { $html = ''; $device = Devices::sanitize( $attribution['device'] ?? null ); if ( $device ) { $icons = array( 'mobile' => '', 'tablet' => '', 'desktop' => '', ); $parts = array_filter( array( Devices::type_label( $device['type'] ), $device['os'], Devices::browser_label( $device['browser'] ) ) ); $html .= '

    ' . esc_html( implode( ', ', $parts ) ) . '

    '; } $place = Geo::place( $lead->country ?? '', $lead->city ?? '' ); if ( '' !== $place && '' !== (string) ( $lead->city ?? '' ) ) { $place .= ' – ' . ( 'address' === ( $lead->geo ?? '' ) ? __( 'from the address in the enquiry', 'dealroot' ) : __( 'about, from the internet connection', 'dealroot' ) ); } if ( '' !== $place ) { $html .= '

    ' . esc_html( $place ) . '

    '; } // Within one visit, the time from the first page to the enquiry. $journey = (array) ( $attribution['journey'] ?? array() ); $arrived = (int) ( $attribution['first']['ts'] ?? 0 ); if ( $arrived && $created > $arrived && 1 === (int) ( $journey['n'] ?? 1 ) && $created - $arrived < DAY_IN_SECONDS ) { $html .= '

    ' . esc_html( sprintf( /* translators: %s: duration such as "7 min" */ __( '%s after arriving on the site', 'dealroot' ), Format::duration( $created - $arrived ) ) ) . '

    '; } return $html; } /** * The pages the visitor opened, one entry each, without the first page of a visit, which * that visit already shows. * * @param array $journey Journey data. * @param array[] $visits Visits shown in the timeline. * @return array[] Entries. */ private function page_entries( array $journey, array $visits ) { $entries = array(); foreach ( (array) ( $journey['p'] ?? array() ) as $page ) { $path = (string) ( $page[0] ?? '' ); $time = (int) ( $page[1] ?? 0 ); if ( '' === $path ) { continue; } foreach ( $visits as $visit ) { if ( ! empty( $visit['lp'] ) && strtok( (string) $visit['lp'], '?' ) === $path && abs( $time - (int) ( $visit['ts'] ?? 0 ) ) < 60 ) { continue 2; } } $title = Pages::title( $path ); $entries[] = array( 'time' => $time ? $time : null, 'title' => '' !== $title ? $title : __( 'Opened a page', 'dealroot' ), 'body' => self::page_line( $path, false ), 'tone' => 'quiet', ); } return $entries; } /** * How often the visitor came, e.g. "3 visits in 5 days". * * @param array $journey Journey data. * @param int $submitted Submission timestamp. * @return string */ private static function visits_text( array $journey, $submitted ) { $visits = (int) ( $journey['n'] ?? 1 ); $days = (int) floor( max( 0, $submitted - (int) ( $journey['f'] ?? $submitted ) ) / DAY_IN_SECONDS ); if ( $days > 0 ) { /* translators: 1: number of visits, 2: number of days */ return sprintf( _n( '%1$s visit in %2$s days', '%1$s visits in %2$s days', $visits, 'dealroot' ), number_format_i18n( $visits ), number_format_i18n( $days + 1 ) ); } /* translators: %s: number of visits */ return sprintf( _n( '%s visit on the day of the enquiry', '%s visits on the day of the enquiry', $visits, 'dealroot' ), number_format_i18n( $visits ) ); } /** * Full address of a page of the site as a link, with its title when it is known. * * @param string $path Path as recorded. * @param bool $with_title Add the title after the address. * @return string HTML. */ private static function page_line( $path, $with_title = true ) { $path = (string) strtok( $path, '?#' ); $origin = (string) preg_replace( '#^(https?://[^/]+).*$#', '$1', home_url() ); $url = $origin . $path; $title = $with_title ? Pages::title( $path ) : ''; return '

    ' . esc_html( rawurldecode( $url ) ) . '' . ( '' !== $title ? ' ' . esc_html( $title ) . '' : '' ) . '

    '; } /** * Link to the Microsoft Clarity recording of the visit. * * @param object $lead Lead. */ private function recording( $lead ) { if ( ! Clarity::recorded( $lead ) ) { return; } $url = Clarity::url( $lead ); ?> __( 'Source', 'dealroot' ), 'utm_medium' => __( 'Medium', 'dealroot' ), 'utm_campaign' => __( 'Campaign', 'dealroot' ), 'utm_term' => __( 'Keyword', 'dealroot' ), 'utm_content' => __( 'Ad content', 'dealroot' ), 'ref' => __( 'Referrer', 'dealroot' ), ); $rows = array(); foreach ( $labels as $key => $label ) { if ( ! empty( $touch[ $key ] ) ) { $rows[ $label ] = $touch[ $key ]; } } // The service's name next to the value in the link, e.g. Instagram (ig). if ( ! empty( $touch['utm_source'] ) ) { $name = Sources::label( Sources::key( $touch['utm_source'] ) ); if ( mb_strtolower( $name ) !== mb_strtolower( $touch['utm_source'] ) ) { $rows[ $labels['utm_source'] ] = $name . ' (' . $touch['utm_source'] . ')'; } } foreach ( array( 'gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid', 'ttclid', 'li_fat_id' ) as $click_id ) { if ( ! empty( $touch[ $click_id ] ) ) { $rows[ $click_id ] = $touch[ $click_id ]; } } return $rows; } /** * Deal value and note. * * @param object $lead Lead. */ private function outcome_form( $lead ) { $currency = '' !== $lead->currency ? $lead->currency : Settings::get( 'currency' ); $profit = Reports::profit( $lead ); $facts = array(); if ( null !== $profit ) { $facts[] = sprintf( /* translators: 1: amount, 2: margin such as 32% */ __( 'Gross profit: %1$s (%2$s margin)', 'dealroot' ), Format::money( $profit, $currency ), Format::margin( array( $currency => $profit ), array( $currency => (float) $lead->value ) ) ); } ?>

    id ); ?> deal_fields( $lead->value, $currency, $lead->note, null !== $lead->value && null !== ( $lead->ai_budget ?? null ) && 'won' !== $lead->status && abs( (float) $lead->value - (float) $lead->ai_budget ) < 0.005 ); ?>
    gclid || '' !== $lead->gbraid || '' !== $lead->wbraid ) : ?>

    __( 'Website form', 'dealroot' ), 'custom' => __( 'Custom form', 'dealroot' ), 'order' => __( 'Purchase event of the shop', 'dealroot' ), ); return $labels[ $key ] ?? ( Registry::EMBEDS[ $key ] ?? '' ); } }