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 );
?>
' . 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',
)
);
?>
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 ) : ?>
id ); ?>
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' ),
);
?>
$label ) : ?>
>
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 ) {
/* 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 .= '' . $icons[ $device['type'] ] . ' ' . 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 ] ?? '' );
}
}