PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.2
MailPoet – Newsletters, Email Marketing, and Automation v5.34.2
5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / EmailEditor / Integrations / MailPoet / ProductCollection / OrderProductCollectionProcessor.php
mailpoet / lib / EmailEditor / Integrations / MailPoet / ProductCollection Last commit date
OrderProductCollectionProcessor.php 1 month ago ProductCollectionEmailRendererRegistrar.php 1 month ago index.php 1 month ago
OrderProductCollectionProcessor.php
353 lines
1 <?php declare(strict_types = 1);
2
3 namespace MailPoet\EmailEditor\Integrations\MailPoet\ProductCollection;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\AutomaticEmails\WooCommerce\Events\AbandonedCart;
9 use MailPoet\Entities\SendingQueueEntity;
10
11 /**
12 * Fills product collection blocks from MailPoet automation context.
13 *
14 * Order-aware collections use products derived from the order that triggered
15 * the email. Abandoned-cart emails expose the queued cart product snapshot to
16 * WooCommerce's cart-contents collection during rendering.
17 *
18 * The collection slug stored in the block's `collection` attribute is the
19 * marker — it is a declared block attribute, so it survives editing and
20 * re-saving the email in the block editor. The same slugs are registered in
21 * the editor (see assets/js/src/mailpoet-email-editor-integration/order-product-collections.ts)
22 * so merchants see what the block does.
23 *
24 * When a source yields no products, related products of the purchased items
25 * are used instead. When even those are missing, the block is left untouched
26 * and renders the generic store query authored in the pattern.
27 */
28 class OrderProductCollectionProcessor {
29 public const COLLECTION_ORDER_CROSS_SELLS = 'mailpoet/product-collection/order-cross-sells';
30 public const COLLECTION_ORDER_SAME_TAG = 'mailpoet/product-collection/order-same-tag';
31 public const COLLECTION_ORDER_SAME_CATEGORY = 'mailpoet/product-collection/order-same-category';
32
33 public const ORDER_COLLECTIONS = [
34 self::COLLECTION_ORDER_CROSS_SELLS,
35 self::COLLECTION_ORDER_SAME_TAG,
36 self::COLLECTION_ORDER_SAME_CATEGORY,
37 ];
38
39 private const PRODUCT_COLLECTION_BLOCK = 'woocommerce/product-collection';
40 private const PERSISTENT_CART_META_KEY_PREFIX = '_woocommerce_persistent_cart_';
41 private const MAX_PRODUCTS = 24;
42 private const RELATED_PRODUCTS_PER_ITEM = 8;
43
44 /**
45 * Build a `woocommerce_email_blocks_renderer_parsed_blocks` filter bound to the
46 * order in the render context. Returns null when there is no order, leaving
47 * marked blocks to render their authored fallback query.
48 *
49 * @param array<string, mixed> $renderContext
50 */
51 public function createBlocksFilter(array $renderContext): ?callable {
52 $order = $renderContext['order'] ?? null;
53 if (!$order instanceof \WC_Order) {
54 return null;
55 }
56
57 return function (array $blocks) use ($order): array {
58 return $this->fillOrderCollections($blocks, $order);
59 };
60 }
61
62 /**
63 * WooCommerce's cart-contents collection reads products from persistent cart
64 * user meta during email rendering. MailPoet already stores the abandoned cart
65 * snapshot on the queue, so expose that snapshot only while rendering the queue.
66 *
67 * @param array<string, mixed> $renderContext
68 */
69 public function createAbandonedCartPersistentCartFilter(
70 array $renderContext,
71 ?SendingQueueEntity $sendingQueue
72 ): ?callable {
73 $userId = isset($renderContext['user_id']) && is_numeric($renderContext['user_id'])
74 ? (int)$renderContext['user_id']
75 : 0;
76 if (!$userId || !$sendingQueue) {
77 return null;
78 }
79
80 $meta = $sendingQueue->getMeta() ?: [];
81 $productIds = $this->normalizeProductIds($meta[AbandonedCart::TASK_META_NAME] ?? []);
82 if (!$productIds) {
83 return null;
84 }
85
86 $persistentCart = $this->buildPersistentCart($productIds);
87 // Return a single-element value list. get_metadata_raw() unwraps it to
88 // $persistentCart for $single calls (its $check[0]) and hands it back as an
89 // array of values otherwise. WooCommerce reads the persistent cart with
90 // $single = true, so returning the bare cart here would make WP read index 0
91 // of an associative array and yield null.
92 return function($value, $objectId, $metaKey) use ($userId, $persistentCart) {
93 if (
94 (int)$objectId !== $userId
95 || !is_string($metaKey)
96 || strpos($metaKey, self::PERSISTENT_CART_META_KEY_PREFIX) !== 0
97 ) {
98 return $value;
99 }
100
101 return [$persistentCart];
102 };
103 }
104
105 /**
106 * Set hand-picked products on every product collection block using one of the
107 * order-aware collections, descending into inner blocks. Each collection is
108 * resolved at most once per call.
109 *
110 * @param array<array-key, mixed> $blocks
111 * @return array<array-key, mixed>
112 */
113 public function fillOrderCollections(array $blocks, \WC_Order $order): array {
114 $resolvedIds = [];
115 return $this->walkBlocks($blocks, $order, $resolvedIds);
116 }
117
118 /**
119 * @param mixed $productIds
120 * @return int[]
121 */
122 private function normalizeProductIds($productIds): array {
123 if (!is_array($productIds)) {
124 return [];
125 }
126
127 $normalized = [];
128 foreach ($productIds as $productId) {
129 if (!is_numeric($productId)) {
130 continue;
131 }
132 $productId = (int)$productId;
133 if ($productId > 0) {
134 $normalized[] = $productId;
135 }
136 }
137
138 return array_values(array_unique($normalized));
139 }
140
141 /**
142 * @param int[] $productIds
143 * @return array{cart: array<string, array{product_id: int}>}
144 */
145 private function buildPersistentCart(array $productIds): array {
146 $cart = [];
147 foreach ($productIds as $index => $productId) {
148 $cart['mailpoet_abandoned_cart_' . $index] = ['product_id' => $productId];
149 }
150 return ['cart' => $cart];
151 }
152
153 /**
154 * Product IDs for an order-aware collection, excluding the purchased products
155 * themselves and products that are not displayable (unpublished, out of stock).
156 * Falls back to related products of the purchased items when the primary
157 * source yields nothing.
158 *
159 * @return int[]
160 */
161 public function resolveProductIds(string $collection, \WC_Order $order): array {
162 $purchasedProducts = $this->getPurchasedProducts($order);
163 if (!$purchasedProducts) {
164 return [];
165 }
166 $purchasedIds = array_keys($purchasedProducts);
167
168 switch ($collection) {
169 case self::COLLECTION_ORDER_CROSS_SELLS:
170 $ids = $this->getCrossSellIds($purchasedProducts);
171 break;
172 case self::COLLECTION_ORDER_SAME_TAG:
173 $ids = $this->getSameTermProductIds($purchasedProducts, 'product_tag_id');
174 break;
175 case self::COLLECTION_ORDER_SAME_CATEGORY:
176 $ids = $this->getSameTermProductIds($purchasedProducts, 'product_category_id');
177 break;
178 default:
179 return [];
180 }
181
182 $ids = $this->filterDisplayable($ids, $purchasedIds);
183 if (!$ids) {
184 $ids = $this->filterDisplayable($this->getRelatedIds($purchasedIds), $purchasedIds);
185 }
186
187 return array_slice($ids, 0, self::MAX_PRODUCTS);
188 }
189
190 /**
191 * @param array<array-key, mixed> $blocks
192 * @param array<string, int[]> $resolvedIds
193 * @return array<array-key, mixed>
194 */
195 private function walkBlocks(array $blocks, \WC_Order $order, array &$resolvedIds): array {
196 foreach ($blocks as $index => $block) {
197 if (!is_array($block)) {
198 continue;
199 }
200
201 $collection = $this->getOrderCollectionSlug($block);
202 if ($collection !== null) {
203 if (!array_key_exists($collection, $resolvedIds)) {
204 $resolvedIds[$collection] = $this->resolveProductIds($collection, $order);
205 }
206 if ($resolvedIds[$collection]) {
207 $block = $this->setHandPickedProducts($block, $resolvedIds[$collection]);
208 }
209 }
210
211 if (isset($block['innerBlocks']) && is_array($block['innerBlocks'])) {
212 $block['innerBlocks'] = $this->walkBlocks($block['innerBlocks'], $order, $resolvedIds);
213 }
214
215 $blocks[$index] = $block;
216 }
217
218 return $blocks;
219 }
220
221 /**
222 * @param array<array-key, mixed> $block
223 */
224 private function getOrderCollectionSlug(array $block): ?string {
225 if (($block['blockName'] ?? null) !== self::PRODUCT_COLLECTION_BLOCK) {
226 return null;
227 }
228
229 $attrs = $block['attrs'] ?? null;
230 $collection = is_array($attrs) ? ($attrs['collection'] ?? null) : null;
231 return is_string($collection) && in_array($collection, self::ORDER_COLLECTIONS, true) ? $collection : null;
232 }
233
234 /**
235 * @param array<array-key, mixed> $block
236 * @param int[] $productIds
237 * @return array<array-key, mixed>
238 */
239 private function setHandPickedProducts(array $block, array $productIds): array {
240 $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
241 $query = is_array($attrs['query'] ?? null) ? $attrs['query'] : [];
242 $query['woocommerceHandPickedProducts'] = $productIds;
243 $attrs['query'] = $query;
244 $block['attrs'] = $attrs;
245 return $block;
246 }
247
248 /**
249 * Purchased products keyed by product ID. Uses the parent product for
250 * variations because cross-sells and taxonomy terms live on the parent.
251 *
252 * @return array<int, \WC_Product>
253 */
254 private function getPurchasedProducts(\WC_Order $order): array {
255 $products = [];
256 foreach ($order->get_items() as $item) {
257 if (!$item instanceof \WC_Order_Item_Product) {
258 continue;
259 }
260 $productId = $item->get_product_id();
261 if (!$productId || isset($products[$productId])) {
262 continue;
263 }
264 $product = wc_get_product($productId);
265 if ($product instanceof \WC_Product) {
266 $products[$productId] = $product;
267 }
268 }
269 return $products;
270 }
271
272 /**
273 * @param array<int, \WC_Product> $purchasedProducts
274 * @return int[]
275 */
276 private function getCrossSellIds(array $purchasedProducts): array {
277 $ids = [];
278 foreach ($purchasedProducts as $product) {
279 foreach ($product->get_cross_sell_ids() as $crossSellId) {
280 $ids[] = (int)$crossSellId;
281 }
282 }
283 return $ids;
284 }
285
286 /**
287 * Products sharing taxonomy terms with the purchased products.
288 *
289 * @param array<int, \WC_Product> $purchasedProducts
290 * @param string $termQueryArg WC_Product_Query taxonomy argument ('product_tag_id' or 'product_category_id').
291 * @return int[]
292 */
293 private function getSameTermProductIds(array $purchasedProducts, string $termQueryArg): array {
294 $termIds = [];
295 foreach ($purchasedProducts as $product) {
296 $productTermIds = $termQueryArg === 'product_tag_id' ? $product->get_tag_ids() : $product->get_category_ids();
297 foreach ($productTermIds as $termId) {
298 $termIds[] = (int)$termId;
299 }
300 }
301 $termIds = array_values(array_unique($termIds));
302 if (!$termIds) {
303 return [];
304 }
305
306 // Purchased products are not excluded here (the exclude/post__not_in query
307 // parameter scales poorly); filterDisplayable() removes them afterwards.
308 $ids = wc_get_products([
309 'status' => 'publish',
310 'limit' => self::MAX_PRODUCTS + count($purchasedProducts),
311 'orderby' => 'date',
312 'order' => 'DESC',
313 $termQueryArg => $termIds,
314 'return' => 'ids',
315 ]);
316 return is_array($ids) ? array_map('intval', $ids) : [];
317 }
318
319 /**
320 * @param int[] $purchasedIds
321 * @return int[]
322 */
323 private function getRelatedIds(array $purchasedIds): array {
324 $ids = [];
325 foreach ($purchasedIds as $purchasedId) {
326 foreach (wc_get_related_products($purchasedId, self::RELATED_PRODUCTS_PER_ITEM) as $relatedId) {
327 $ids[] = (int)$relatedId;
328 }
329 }
330 return $ids;
331 }
332
333 /**
334 * Deduplicates, removes purchased products, and keeps only products a
335 * customer can still see and buy.
336 *
337 * @param int[] $ids
338 * @param int[] $excludeIds
339 * @return int[]
340 */
341 private function filterDisplayable(array $ids, array $excludeIds): array {
342 $ids = array_values(array_unique(array_map('intval', $ids)));
343 $ids = array_values(array_diff($ids, $excludeIds));
344
345 return array_values(array_filter($ids, function (int $id): bool {
346 $product = wc_get_product($id);
347 return $product instanceof \WC_Product
348 && $product->get_status() === 'publish'
349 && $product->get_stock_status() !== 'outofstock';
350 }));
351 }
352 }
353