PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Services / Email / StoreDigestService.php

StoreDigestService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Services/Email/StoreDigestService.php

798 lines 31.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Email;
4
5 use FluentCart\Api\StoreSettings;
6 use FluentCart\App\App;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Services\DateTime\DateFormatter;
10 use FluentCart\App\Services\Report\DefaultReportService;
11 use FluentCart\App\Services\ShortCodeParser\ShortcodeTemplateBuilder;
12 use FluentCart\Framework\Support\Arr;
13
14 /**
15 * Store Digest email — daily / weekly / monthly.
16 *
17 * A system email summarising store activity for a finished period. It does NOT
18 * flow through the order-bound notification mailer; it builds its own body and
19 * renders it through the shared general_template wrapper.
20 *
21 * Numbers mirror the admin Reports dashboard exactly: same aggregate method
22 * (DefaultReportService::getAllGraphMetricsSeparate) and the same paid-status
23 * filter (Status::getReportStatuses()).
24 */
25 class StoreDigestService
26 {
27 const CADENCES = ['daily', 'weekly', 'monthly'];
28
29 const OPTION_KEY = 'fluent_cart_store_digest_settings';
30
31 // Single (non-autoloaded) option holding the per-cadence "last sent" stamps.
32 const LAST_SENT_OPTION = 'fluent_cart_digest_last_sent';
33
34 /**
35 * Per-request settings cache (busted on save).
36 *
37 * @var array|null
38 */
39 private static $settingsCache = null;
40
41 /**
42 * Default settings for this module. The store digest owns its own settings
43 * store — it does NOT live inside the EmailNotifications config.
44 */
45 public static function defaultSettings(): array
46 {
47 return [
48 // DERIVED on save (yes if any cadence enabled); cheap scheduler early-out.
49 // Defaults to 'yes' because the weekly digest is on by default.
50 'enabled' => 'yes',
51 'recipients' => '{{wp.admin_email}}', // comma-separated; shared by all cadences
52 'send_when_empty' => 'no', // skip periods with zero activity (no "$0" emails to inactive stores)
53 'daily' => ['enabled' => 'no', 'send_hour' => 8],
54 'weekly' => ['enabled' => 'yes', 'send_hour' => 8, 'send_dow' => 1], // ON by default — Monday 08:00
55 'monthly' => ['enabled' => 'no', 'send_hour' => 8], // always sent on the 1st
56 ];
57 }
58
59 /**
60 * Read the module's settings, merged over defaults (cadence sub-arrays
61 * deep-merged). Cached per request.
62 *
63 * @param string|null $key dot-path (e.g. 'recipients', 'daily.send_hour')
64 * @return mixed
65 */
66 public static function getSettings($key = null)
67 {
68 if (self::$settingsCache === null) {
69 $defaults = self::defaultSettings();
70 $stored = get_option(self::OPTION_KEY, []);
71 if (!is_array($stored)) {
72 $stored = [];
73 }
74
75 $merged = wp_parse_args($stored, $defaults);
76 foreach (self::CADENCES as $cadence) {
77 $cadenceStored = (isset($stored[$cadence]) && is_array($stored[$cadence])) ? $stored[$cadence] : [];
78 $merged[$cadence] = wp_parse_args($cadenceStored, $defaults[$cadence]);
79 }
80
81 self::$settingsCache = $merged;
82 }
83
84 if (!empty($key)) {
85 return Arr::get(self::$settingsCache, $key);
86 }
87
88 return self::$settingsCache;
89 }
90
91 /**
92 * Sanitize raw input, derive the `enabled` master flag, and persist.
93 *
94 * The module owns its own input handling: callers pass the raw request data
95 * and the service guarantees the stored shape is always clean. `enabled` is
96 * derived server-side (ON when any cadence is enabled) so the hourly scheduler
97 * can short-circuit on a single cheap check.
98 *
99 * @param array $input raw, unsanitized settings (e.g. $request->all())
100 * @return array the stored settings
101 */
102 public static function saveSettings(array $input): array
103 {
104 $clean = self::sanitize($input);
105
106 $clean['enabled'] = (
107 $clean['daily']['enabled'] === 'yes'
108 || $clean['weekly']['enabled'] === 'yes'
109 || $clean['monthly']['enabled'] === 'yes'
110 ) ? 'yes' : 'no';
111
112 update_option(self::OPTION_KEY, $clean, false);
113 self::$settingsCache = null; // bust per-request cache
114
115 return $clean;
116 }
117
118 /**
119 * Coerce raw input into the settings shape. `enabled` is intentionally not
120 * read from input — it is derived in saveSettings().
121 */
122 private static function sanitize(array $input): array
123 {
124 $defaults = self::defaultSettings();
125
126 return [
127 'recipients' => sanitize_text_field(Arr::get($input, 'recipients', $defaults['recipients'])),
128 'send_when_empty' => Arr::get($input, 'send_when_empty') === 'yes' ? 'yes' : 'no',
129 'daily' => [
130 'enabled' => Arr::get($input, 'daily.enabled') === 'yes' ? 'yes' : 'no',
131 'send_hour' => self::clampInt(Arr::get($input, 'daily.send_hour', $defaults['daily']['send_hour']), 0, 23),
132 ],
133 'weekly' => [
134 'enabled' => Arr::get($input, 'weekly.enabled') === 'yes' ? 'yes' : 'no',
135 'send_hour' => self::clampInt(Arr::get($input, 'weekly.send_hour', $defaults['weekly']['send_hour']), 0, 23),
136 'send_dow' => self::clampInt(Arr::get($input, 'weekly.send_dow', $defaults['weekly']['send_dow']), 1, 7),
137 ],
138 'monthly' => [
139 'enabled' => Arr::get($input, 'monthly.enabled') === 'yes' ? 'yes' : 'no',
140 'send_hour' => self::clampInt(Arr::get($input, 'monthly.send_hour', $defaults['monthly']['send_hour']), 0, 23),
141 ],
142 ];
143 }
144
145 private static function clampInt($value, int $min, int $max): int
146 {
147 $value = (int) $value;
148 if ($value < $min) {
149 return $min;
150 }
151 if ($value > $max) {
152 return $max;
153 }
154 return $value;
155 }
156
157 /**
158 * Hooked to fluent_cart/scheduler/hourly_tasks. Evaluates every cadence
159 * against the current site-local time and dispatches the ones that are due.
160 */
161 public static function runDueDigests(): void
162 {
163 $config = self::getSettings();
164
165 // Global master switch (derived on save): one cheap check short-circuits all per-cadence work.
166 if (Arr::get($config, 'enabled') !== 'yes') {
167 return;
168 }
169
170 $hour = (int) current_time('G'); // site-local hour, 0-23
171
172 foreach (self::CADENCES as $frequency) {
173 self::maybeSend($frequency, Arr::get($config, $frequency, []), $hour);
174 }
175 }
176
177 /**
178 * Decide whether a single cadence is due, and send once if so.
179 */
180 private static function maybeSend(string $frequency, $cadenceConfig, int $hour): void
181 {
182 if (Arr::get($cadenceConfig, 'enabled') !== 'yes') {
183 return;
184 }
185
186 // Must be the scheduled day for weekly/monthly.
187 if ($frequency === 'weekly') {
188 // N = ISO-8601 day of week (1=Mon ... 7=Sun)
189 if ((int) current_time('N') !== (int) Arr::get($cadenceConfig, 'send_dow', 1)) {
190 return;
191 }
192 } elseif ($frequency === 'monthly') {
193 // Monthly digest sends on the 1st (reporting the previous calendar month).
194 if ((int) current_time('j') !== 1) {
195 return;
196 }
197 }
198
199 // Send at OR AFTER the configured hour, not only exactly on it. WP-Cron /
200 // Action Scheduler do not guarantee an hourly tick lands inside every clock
201 // hour (low traffic, backlog, drift), so an 08:00 digest must still go out if
202 // the first tick of the day is at 09:xx. A later tick the same scheduled day
203 // catches up; the per-period stamp below still guarantees exactly one send.
204 if ($hour < (int) Arr::get($cadenceConfig, 'send_hour', 8)) {
205 return;
206 }
207
208 $stamp = self::dedupStamp($frequency);
209
210 $lastSent = get_option(self::LAST_SENT_OPTION, []);
211 if (!is_array($lastSent)) {
212 $lastSent = [];
213 }
214
215 if (Arr::get($lastSent, $frequency) === $stamp) {
216 return; // already sent for this period
217 }
218
219 // Mark BEFORE sending so a duplicate cron run cannot double-send.
220 $lastSent[$frequency] = $stamp;
221 update_option(self::LAST_SENT_OPTION, $lastSent, false);
222
223 self::sendDigest($frequency);
224 }
225
226 /**
227 * Per-cadence idempotency key based on the site-local calendar.
228 */
229 private static function dedupStamp(string $frequency): string
230 {
231 if ($frequency === 'weekly') {
232 return 'W-' . current_time('o-W'); // ISO year-week
233 }
234 if ($frequency === 'monthly') {
235 return 'M-' . current_time('Y-m');
236 }
237 return 'D-' . current_time('Y-m-d');
238 }
239
240 /**
241 * Build and send one digest.
242 *
243 * @param string $frequency daily|weekly|monthly
244 * @param string $recipientsOverride optional comma-separated recipients (test send)
245 * @return bool true when an email was dispatched
246 */
247 public static function sendDigest(string $frequency, string $recipientsOverride = ''): bool
248 {
249 if (!in_array($frequency, self::CADENCES, true)) {
250 return false;
251 }
252
253 $config = self::getSettings();
254 $isTest = $recipientsOverride !== '';
255
256 $recipients = self::resolveRecipients(
257 $isTest ? $recipientsOverride : Arr::get($config, 'recipients', ''),
258 $frequency,
259 $config
260 );
261
262 if (empty($recipients)) {
263 fluent_cart_add_log(
264 'Store Digest skipped',
265 __('No valid recipient is configured for the store digest email.', 'fluent-cart'),
266 'error'
267 );
268 return false;
269 }
270
271 $payload = self::buildPayload($frequency);
272
273 // Skip empty periods unless opted in. Test sends always go through.
274 if (!$isTest && Arr::get($payload, 'is_empty') && Arr::get($config, 'send_when_empty', 'no') !== 'yes') {
275 return false;
276 }
277
278 $body = self::renderEmailBody($payload);
279
280 $mailer = Mailer::make()
281 ->to(implode(',', $recipients))
282 ->subject(Arr::get($payload, 'subject', ''))
283 ->body($body);
284
285 return (bool) $mailer->send(true);
286 }
287
288 /**
289 * Assemble the digest payload: current + prior window totals, deltas, top products.
290 */
291 public static function buildPayload(string $frequency): array
292 {
293 list($startDate, $endDate) = self::windowFor($frequency);
294 list($prevStart, $prevEnd) = self::priorWindowFor($frequency);
295
296 $current = self::metricsFor($startDate, $endDate);
297 $previous = self::metricsFor($prevStart, $prevEnd);
298
299 $fluctuations = DefaultReportService::make([])->calculateFluctuations($current, $previous);
300
301 $orderCount = (int) Arr::get($current, 'order_count', 0);
302
303 $payload = [
304 'frequency' => $frequency,
305 'frequency_label' => self::frequencyLabel($frequency),
306 'period_label' => self::periodLabel($frequency, $startDate, $endDate),
307 'store_name' => self::storeName(),
308 'intro' => '',
309 'reports_url' => admin_url('admin.php?page=fluent-cart#/reports'),
310 'settings_url' => admin_url('admin.php?page=fluent-cart#/settings/email_digest_settings'),
311 'is_pro' => App::isProActive(),
312 'pro_url' => apply_filters('fluent_cart/store_digest/pro_url', 'https://fluentcart.com/discount-deal/'),
313 'is_empty' => $orderCount === 0,
314 'subject' => '',
315 'metrics' => [
316 'gross_sale' => self::money(Arr::get($current, 'gross_sale', 0)),
317 'net_revenue' => self::money(Arr::get($current, 'net_revenue', 0)),
318 'refund_amount' => self::money(Arr::get($current, 'total_refunded_amount', 0)),
319 'refund_count' => (int) Arr::get($current, 'total_refunded', 0),
320 'order_count' => $orderCount,
321 'items_sold' => (int) Arr::get($current, 'total_item_count', 0),
322 'onetime_count' => (int) Arr::get($current, 'onetime_count', 0),
323 'onetime_gross' => self::money(Arr::get($current, 'onetime_gross', 0)),
324 'subscription_count' => (int) Arr::get($current, 'subscription_count', 0),
325 'subscription_gross' => self::money(Arr::get($current, 'subscription_gross', 0)),
326 'renewal_count' => (int) Arr::get($current, 'renewal_count', 0),
327 'renewal_gross' => self::money(Arr::get($current, 'renewal_gross', 0)),
328 ],
329 'fluctuations' => [
330 'gross_sale' => self::fluctuation($fluctuations, 'gross_sale'),
331 'net_revenue' => self::fluctuation($fluctuations, 'net_revenue'),
332 'order_count' => self::fluctuation($fluctuations, 'order_count'),
333 'items_sold' => self::fluctuation($fluctuations, 'total_item_count'),
334 'refund_amount' => self::fluctuation($fluctuations, 'total_refunded_amount'),
335 ],
336 'top_products' => self::topProducts($startDate, $endDate),
337 ];
338
339 $payload['subject'] = self::subjectFor($payload);
340 $payload['intro'] = self::introLine($frequency, (string) Arr::get($payload, 'store_name', ''));
341
342 // Pro upsell card: a contextual angle chosen from THIS period's own numbers,
343 // falling back to a deterministic per-store rotation when no signal stands
344 // out. Only built for free installs — Pro hides the card entirely. Built
345 // before the data filter so integrations can override the chosen copy.
346 $payload['pro_promo'] = Arr::get($payload, 'is_pro') ? [] : self::proPromo($payload);
347
348 $payload = apply_filters('fluent_cart/store_digest/data', $payload, [
349 'frequency' => $frequency,
350 'startDate' => $startDate,
351 'endDate' => $endDate,
352 ]);
353
354 return is_array($payload) ? $payload : [];
355 }
356
357 /**
358 * Window totals for a date range — same query + paid-status filter the
359 * Reports dashboard uses, so numbers always agree.
360 */
361 private static function metricsFor(string $startDate, string $endDate): array
362 {
363 $result = DefaultReportService::make([])->getAllGraphMetricsSeparate([
364 'startDate' => $startDate,
365 'endDate' => $endDate,
366 'groupKey' => 'daily', // concrete key: totals are summed regardless of grouping, and avoids defineGroupKey() which needs DateTime objects
367 'currency' => null,
368 'variationIds' => [],
369 'paymentStatus' => Status::getReportStatuses(),
370 ]);
371
372 return (array) Arr::get($result, 'summary', []);
373 }
374
375 /**
376 * Top 5 products by units sold, each carrying units and revenue.
377 */
378 private static function topProducts(string $startDate, string $endDate): array
379 {
380 $result = DefaultReportService::make([])->fetchTopSoldProducts([
381 'startDate' => $startDate,
382 'endDate' => $endDate,
383 'groupKey' => 'daily', // concrete key: totals are summed regardless of grouping, and avoids defineGroupKey() which needs DateTime objects
384 'currency' => null,
385 'variationIds' => [],
386 'paymentStatus' => Status::getReportStatuses(),
387 ]);
388
389 $top = [];
390 foreach (Arr::get($result, 'topSoldProducts', []) as $product) {
391 $top[] = [
392 'name' => Arr::get($product, 'product_name', __('Unknown Product', 'fluent-cart')),
393 'qty' => (int) Arr::get($product, 'quantity_sold', 0),
394 'revenue' => self::money(Arr::get($product, 'total_amount', 0)),
395 ];
396 if (count($top) >= 5) {
397 break;
398 }
399 }
400
401 return $top;
402 }
403
404 /**
405 * Current finished-period window as site-local wall-clock strings.
406 *
407 * created_at is stored in UTC, but — to match the dashboard exactly — we
408 * query with bare local strings (no GMT conversion), built from
409 * current_time() via the same gmdate() idiom the dashboard uses.
410 *
411 * @return array [startDate, endDate]
412 */
413 private static function windowFor(string $frequency): array
414 {
415 $localNow = current_time('timestamp'); // site-local Unix timestamp
416
417 if ($frequency === 'weekly') {
418 return [
419 gmdate('Y-m-d 00:00:00', strtotime('-7 days', $localNow)),
420 gmdate('Y-m-d 23:59:59', strtotime('-1 day', $localNow)),
421 ];
422 }
423
424 if ($frequency === 'monthly') {
425 $firstOfLastMonth = strtotime('first day of last month', $localNow);
426 return [
427 gmdate('Y-m-01 00:00:00', $firstOfLastMonth),
428 gmdate('Y-m-t 23:59:59', $firstOfLastMonth),
429 ];
430 }
431
432 // daily — yesterday
433 $day = gmdate('Y-m-d', strtotime('-1 day', $localNow));
434 return [$day . ' 00:00:00', $day . ' 23:59:59'];
435 }
436
437 /**
438 * The equivalent period immediately before the current window (for deltas).
439 *
440 * @return array [startDate, endDate]
441 */
442 private static function priorWindowFor(string $frequency): array
443 {
444 $localNow = current_time('timestamp');
445
446 if ($frequency === 'weekly') {
447 return [
448 gmdate('Y-m-d 00:00:00', strtotime('-14 days', $localNow)),
449 gmdate('Y-m-d 23:59:59', strtotime('-8 days', $localNow)),
450 ];
451 }
452
453 if ($frequency === 'monthly') {
454 $firstOfLastMonth = strtotime('first day of last month', $localNow);
455 $firstOfPrevMonth = strtotime('first day of previous month', $firstOfLastMonth);
456 return [
457 gmdate('Y-m-01 00:00:00', $firstOfPrevMonth),
458 gmdate('Y-m-t 23:59:59', $firstOfPrevMonth),
459 ];
460 }
461
462 // daily — day before yesterday
463 $day = gmdate('Y-m-d', strtotime('-2 days', $localNow));
464 return [$day . ' 00:00:00', $day . ' 23:59:59'];
465 }
466
467 private static function resolveRecipients($raw, string $frequency, $config): array
468 {
469 $parts = array_filter(array_map('trim', explode(',', (string) $raw)));
470
471 $adminEmail = get_bloginfo('admin_email');
472
473 $emails = [];
474 foreach ($parts as $part) {
475 if (strpos($part, '{{') !== false) {
476 // Expand the admin-email smartcode (whitespace-tolerant) to the real
477 // address, then resolve any other shortcodes via the parser. This
478 // guarantees {{wp.admin_email}} becomes a real email before sending.
479 $part = preg_replace('/\{\{\s*wp\.admin_email\s*\}\}/', $adminEmail, $part);
480 if (strpos($part, '{{') !== false) {
481 $part = ShortcodeTemplateBuilder::make($part, []);
482 }
483 }
484
485 $part = sanitize_email($part);
486 if ($part && is_email($part)) {
487 $emails[] = $part;
488 }
489 }
490
491 $emails = array_values(array_unique($emails));
492
493 $emails = apply_filters('fluent_cart/store_digest/recipients', $emails, [
494 'frequency' => $frequency,
495 'settings' => $config,
496 ]);
497
498 return is_array($emails) ? $emails : [];
499 }
500
501 private static function renderEmailBody(array $payload): string
502 {
503 $body = (string) App::make('view')->make('emails.digest', [
504 'digest' => $payload,
505 ]);
506
507 return (string) App::make('view')->make('emails.general_template', [
508 'emailBody' => $body,
509 'preheader' => self::preheader($payload),
510 'header' => '',
511 'emailFooter' => (new EmailNotificationMailer())->getEmailFooter(),
512 ]);
513 }
514
515 private static function preheader(array $payload): string
516 {
517 /* translators: %1$s: frequency (e.g. daily), %2$s: period label */
518 return sprintf(
519 __('Your %1$s store digest for %2$s', 'fluent-cart'),
520 strtolower((string) Arr::get($payload, 'frequency_label', '')),
521 (string) Arr::get($payload, 'period_label', '')
522 );
523 }
524
525 /**
526 * Friendly opening line, varied per cadence. Uses a numbered placeholder so
527 * the store name slots in (`%1$s`) for translators.
528 */
529 private static function introLine(string $frequency, string $storeName): string
530 {
531 if ($frequency === 'weekly') {
532 /* translators: %1$s: store name */
533 return sprintf(__("Here's how %1\$s did over the last 7 days.", 'fluent-cart'), $storeName);
534 }
535 if ($frequency === 'monthly') {
536 /* translators: %1$s: store name */
537 return sprintf(__("Here's how %1\$s did last month.", 'fluent-cart'), $storeName);
538 }
539 /* translators: %1$s: store name */
540 return sprintf(__("Here's how %1\$s did yesterday.", 'fluent-cart'), $storeName);
541 }
542
543 private static function subjectFor(array $payload): string
544 {
545 $storeName = (string) Arr::get($payload, 'store_name', '');
546 $period = (string) Arr::get($payload, 'period_label', '');
547
548 if (Arr::get($payload, 'frequency') === 'weekly') {
549 /* translators: %1$s: store name, %2$s: period range */
550 return sprintf(__('%1$s weekly digest — %2$s', 'fluent-cart'), $storeName, $period);
551 }
552 if (Arr::get($payload, 'frequency') === 'monthly') {
553 /* translators: %1$s: store name, %2$s: month and year */
554 return sprintf(__('%1$s monthly digest — %2$s', 'fluent-cart'), $storeName, $period);
555 }
556 /* translators: %1$s: store name, %2$s: date */
557 return sprintf(__('%1$s daily digest — %2$s', 'fluent-cart'), $storeName, $period);
558 }
559
560 private static function frequencyLabel(string $frequency): string
561 {
562 if ($frequency === 'weekly') {
563 return __('Weekly', 'fluent-cart');
564 }
565 if ($frequency === 'monthly') {
566 return __('Monthly', 'fluent-cart');
567 }
568 return __('Daily', 'fluent-cart');
569 }
570
571 private static function periodLabel(string $frequency, string $start, string $end): string
572 {
573 $startTs = strtotime($start);
574 $endTs = strtotime($end);
575
576 // date_i18n (not DateFormatter::format) because windowFor() hands us bare
577 // LOCAL wall-clock strings, not GMT -- converting them would shift the label.
578 // Only the pattern comes from the store's settings.
579 if ($frequency === 'monthly') {
580 // A month-and-year label has no WordPress setting of its own, so it is
581 // derived from the store's date format -- a year-first locale keeps its
582 // own field order instead of the English 'month year'.
583 return date_i18n(DateFormatter::monthYearFormat(), $startTs);
584 }
585 if ($frequency === 'weekly') {
586 /* translators: %1$s: start date, %2$s: end date */
587 return sprintf(
588 __('%1$s – %2$s', 'fluent-cart'),
589 date_i18n(DateFormatter::dateFormat(), $startTs),
590 date_i18n(DateFormatter::dateFormat(), $endTs)
591 );
592 }
593 return date_i18n(DateFormatter::dateFormat(), $startTs);
594 }
595
596 private static function storeName(): string
597 {
598 $name = (new StoreSettings())->get('store_name');
599 if (empty($name)) {
600 $name = get_bloginfo('name');
601 }
602 return (string) $name;
603 }
604
605 /**
606 * Report totals are already decimals (SQL divides by 100), but
607 * Helper::toDecimal expects integer cents — convert back before formatting.
608 */
609 private static function money($decimalAmount): string
610 {
611 $cents = (int) round(((float) $decimalAmount) * 100);
612 return Helper::toDecimal($cents);
613 }
614
615 private static function fluctuation($fluctuations, string $key): float
616 {
617 return (float) Arr::get($fluctuations, $key, 0);
618 }
619
620 /**
621 * Choose the Pro upsell angle for this digest and build its copy + tracked URL.
622 *
623 * Two layers:
624 * - Contextual (phase 2): pick the angle from THIS period's own numbers — a
625 * growing store, a quiet/declining one, and a high-volume one each hear a
626 * different, relevant pitch.
627 * - Rotation (phase 1): when no signal stands out, rotate through evergreen
628 * angles deterministically, offset per store, so a recurring digest never
629 * repeats the same line week after week (anti-fatigue) while still splitting
630 * the population across angles at any given moment (so the angles are
631 * measurable, not just decorative).
632 *
633 * Every CTA carries a utm_content of "{frequency}_{variant}" so conversions
634 * are attributable per cadence and per angle.
635 */
636 private static function proPromo(array $payload): array
637 {
638 $metrics = (array) Arr::get($payload, 'metrics', []);
639 $flux = (array) Arr::get($payload, 'fluctuations', []);
640 $frequency = (string) Arr::get($payload, 'frequency', 'daily');
641 $grossDelta = (float) Arr::get($flux, 'gross_sale', 0);
642 $orderCount = (int) Arr::get($metrics, 'order_count', 0);
643 $isEmpty = !empty($payload['is_empty']);
644
645 if ($isEmpty || $grossDelta <= -10.0) {
646 $variant = 'winback'; // quiet or shrinking period → win-back angle
647 } elseif ($grossDelta >= 10.0 && $orderCount > 0) {
648 $variant = 'growth'; // clear upward momentum → scale angle
649 } elseif ($orderCount >= self::busyThreshold($frequency)) {
650 $variant = 'automate'; // high volume → save-time angle
651 } else {
652 // No strong signal — rotate evergreen angles (per-store offset + period).
653 $evergreen = ['insight', 'value', 'social'];
654 $variant = $evergreen[self::rotationSeed($frequency) % count($evergreen)];
655 }
656
657 $copy = self::proPromoCopy($variant, $grossDelta, $orderCount);
658
659 return [
660 'variant' => $variant,
661 'body' => $copy['body'],
662 'cta' => $copy['cta'],
663 'url' => self::proPromoUrl($payload, $variant),
664 ];
665 }
666
667 /**
668 * Benefit-led copy per angle. All anchored to the same true Pro value
669 * (premium payment gateways, integrations, customizations) but led by a
670 * distinct motivational hook. <strong> tags are injected around the escaped
671 * translatable text, so the returned body is safe HTML for the view to echo.
672 *
673 * @return array{body:string,cta:string}
674 */
675 private static function proPromoCopy(string $variant, float $grossDelta, int $orderCount): array
676 {
677 $cta = esc_html__('Explore FluentCart Pro →', 'fluent-cart');
678
679 if ($variant === 'growth') {
680 /* translators: %1$s: percentage increase, %2$s: opening <strong> tag, %3$s: closing </strong> tag */
681 $body = sprintf(
682 esc_html__('Your sales are up %1$s%% this period — and you\'re just getting started. %2$sFluentCart Pro%3$s adds premium payment gateways, integrations, and customizations to help you scale what\'s working.', 'fluent-cart'),
683 esc_html(number_format_i18n(round($grossDelta))),
684 '<strong>',
685 '</strong>'
686 );
687
688 return ['body' => $body, 'cta' => $cta];
689 }
690
691 if ($variant === 'automate') {
692 /* translators: %1$s: number of orders, %2$s: opening <strong> tag, %3$s: closing </strong> tag */
693 $body = sprintf(
694 esc_html__('You handled %1$s orders this period — that\'s a lot of moving parts. %2$sFluentCart Pro%3$s automates the busywork with premium integrations, gateways, and customizations so you can focus on growth.', 'fluent-cart'),
695 esc_html(number_format_i18n($orderCount)),
696 '<strong>',
697 '</strong>'
698 );
699
700 return ['body' => $body, 'cta' => $cta];
701 }
702
703 if ($variant === 'winback') {
704 /* translators: %1$s: opening <strong> tag, %2$s: closing </strong> tag */
705 $body = sprintf(
706 esc_html__('Every store has slower stretches. %1$sFluentCart Pro%2$s brings premium payment gateways, integrations, and customizations to help you win back momentum and turn more visits into sales.', 'fluent-cart'),
707 '<strong>',
708 '</strong>'
709 );
710
711 return ['body' => $body, 'cta' => $cta];
712 }
713
714 if ($variant === 'insight') {
715 /* translators: %1$s: opening <strong> tag, %2$s: closing </strong> tag */
716 $body = sprintf(
717 esc_html__('Enjoying these insights? %1$sFluentCart Pro%2$s adds premium payment gateways, integrations, and customizations to help you act on them and grow your store.', 'fluent-cart'),
718 '<strong>',
719 '</strong>'
720 );
721
722 return ['body' => $body, 'cta' => $cta];
723 }
724
725 if ($variant === 'social') {
726 /* translators: %1$s: opening <strong> tag, %2$s: closing </strong> tag */
727 $body = sprintf(
728 esc_html__('Growing stores run on %1$sFluentCart Pro%2$s — premium payment gateways, integrations, and customizations, all built to help you sell more with less effort.', 'fluent-cart'),
729 '<strong>',
730 '</strong>'
731 );
732
733 return ['body' => $body, 'cta' => $cta];
734 }
735
736 // 'value' — default evergreen.
737 /* translators: %1$s: opening <strong> tag, %2$s: closing </strong> tag */
738 $body = sprintf(
739 esc_html__('Ready for more? %1$sFluentCart Pro%2$s brings premium payment gateways, integrations, and customizations to help your store grow.', 'fluent-cart'),
740 '<strong>',
741 '</strong>'
742 );
743
744 return ['body' => $body, 'cta' => $cta];
745 }
746
747 /**
748 * Append campaign tracking so conversions are attributable per cadence and
749 * per angle. Starts from the (already-filtered) pro_url carried in the payload.
750 */
751 private static function proPromoUrl(array $payload, string $variant): string
752 {
753 $base = (string) Arr::get($payload, 'pro_url', 'https://fluentcart.com/discount-deal/');
754
755 return add_query_arg([
756 'utm_source' => 'fluent-cart',
757 'utm_medium' => 'email',
758 'utm_campaign' => 'store_digest',
759 'utm_content' => (string) Arr::get($payload, 'frequency', 'daily') . '_' . $variant,
760 ], $base);
761 }
762
763 /**
764 * Deterministic rotation seed = per-store offset + period index. The offset
765 * (hash of the site URL) puts each store on a different phase, so at any given
766 * moment the population is split across angles; the period index advances each
767 * cadence, so a single store cycles through angles over time.
768 */
769 private static function rotationSeed(string $frequency): int
770 {
771 $storeOffset = abs((int) crc32(home_url()));
772
773 if ($frequency === 'weekly') {
774 $periodIndex = (int) current_time('W'); // ISO-8601 week number
775 } elseif ($frequency === 'monthly') {
776 $periodIndex = (int) current_time('n'); // month, 1-12
777 } else {
778 $periodIndex = (int) current_time('z'); // day of year
779 }
780
781 return $storeOffset + $periodIndex;
782 }
783
784 /**
785 * Order volume that counts as "busy" for the save-time angle, scaled by cadence.
786 */
787 private static function busyThreshold(string $frequency): int
788 {
789 if ($frequency === 'monthly') {
790 return 200;
791 }
792 if ($frequency === 'weekly') {
793 return 50;
794 }
795 return 10; // daily
796 }
797 }
798