PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 trunk 1.2.0 All 47 releases
fluent-cart / app / Services / Email / StoreDigestService.php

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

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