customer) { return $data; } return self::attach($data, $order->customer, self::wantsFull($context)); } private static function wantsFull($context) { $include = isset($context['include']) ? (array) $context['include'] : []; return in_array(self::SECTION, $include, true); } /** * @param array $data * @param Customer $customer * @param bool $full * @return array */ private static function attach($data, $customer, $full) { // Silence over a permission error: the contact block is supplementary, // and a role without CRM access asking for an order should still get the // order. Only say something when the section was explicitly requested. if (!\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_read_contacts')) { if ($full) { $data[self::SECTION . '_omitted'] = __('FluentCRM contact data requires the fcrm_read_contacts capability.', 'fluent-cart'); } return $data; } $contact = self::resolve($customer); if (!$contact) { // Explicit null rather than an absent key: "this buyer is not in the // CRM" is an answer, and an agent that sees nothing cannot tell it // apart from "FluentCRM is not installed". $data[self::SECTION] = null; return $data; } $block = [ 'contact_id' => (int) $contact->id, 'status' => $contact->status, 'contact_type' => $contact->contact_type, 'profile_url' => admin_url('admin.php?page=fluentcrm-admin#/subscribers/' . (int) $contact->id), ]; if ($full) { $contact->load('lists', 'tags'); $block['name'] = $contact->full_name; $block['source'] = $contact->source; $block['created_at'] = self::utcDate($contact->created_at); $block['last_activity'] = self::utcDate($contact->last_activity); $block['lists'] = self::terms($contact->lists); $block['tags'] = self::terms($contact->tags); $block['engagement'] = self::engagement($contact); } $data[self::SECTION] = $block; return $data; } /** * Match a FluentCart customer to a CRM contact. * * Email first, so this can never disagree with the admin widget (which * matches on email alone). user_id is a fallback for the case the widget * misses: a buyer whose CRM contact was created under a different email but * the same WordPress account. * * @param Customer $customer * @return object|null */ private static function resolve($customer) { $subscriber = '\FluentCrm\App\Models\Subscriber'; if (!empty($customer->email)) { $contact = $subscriber::where('email', $customer->email)->first(); if ($contact) { return $contact; } } if (!empty($customer->user_id)) { return $subscriber::where('user_id', (int) $customer->user_id)->first(); } return null; } /** * CRM timestamps, shifted to UTC. * * FluentCRM writes its timestamps with current_time('mysql') — WordPress * SITE time — and its ORM hydrates them carrying the site offset. Every other * date in an MCP payload is UTC (FluentCart stores GMT), and the store * context tells agents dates are ISO-8601 UTC. Passing these through * unconverted would put a contact's last_activity and an order's created_at * on different clocks in the same response — off by the site's offset, with * nothing in the payload to reveal it. * * @param mixed $value * @return string|null */ private static function utcDate($value) { if (!$value) { return null; } if ($value instanceof \DateTimeInterface) { $dt = new \DateTime($value->format('Y-m-d H:i:s'), $value->getTimezone()); return $dt->setTimezone(new \DateTimeZone('UTC'))->format('c'); } // A plain string has no offset attached, so read it as site time — the // timezone it was written in — before shifting. if (is_string($value) && strpos($value, '0000-00-00') !== 0) { try { $dt = new \DateTime($value, wp_timezone()); return $dt->setTimezone(new \DateTimeZone('UTC'))->format('c'); } catch (\Exception $e) { return null; } } return null; } /** Lists/tags as {id, title} — the shape the admin widget renders as chips. */ private static function terms($collection) { $out = []; if (!$collection) { return $out; } foreach ($collection as $term) { $out[] = ['id' => (int) $term->id, 'title' => $term->title]; } return $out; } /** * Email engagement, with the two rates the widget computes in the browser * done here instead — an agent comparing "opens" across contacts with * different send volumes needs the rate, not the raw count. * * @param object $contact * @return array|null null when the CRM cannot produce stats for this contact */ private static function engagement($contact) { $stats = []; try { $stats = (array) $contact->stats(); } catch (\Throwable $e) { return null; } $sent = isset($stats['emails']) ? (int) $stats['emails'] : 0; $opens = isset($stats['opens']) ? (int) $stats['opens'] : 0; $clicks = isset($stats['clicks']) ? (int) $stats['clicks'] : 0; return [ 'emails_sent' => $sent, 'opens' => $opens, 'clicks' => $clicks, // null, not 0, when nothing was sent: a 0% open rate reads as // "never opens our email", which is a different claim. 'open_rate_percent' => $sent > 0 ? round($opens / $sent * 100, 2) : null, 'click_rate_percent' => $sent > 0 ? round($clicks / $sent * 100, 2) : null, ]; } }