PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 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 All 50 releases
← All changes | app/Helpers/Helper.php +402 -124 1.4.2 → 1.7.0 View file →
@@ -11,8 +11,9 @@
11 11 use FluentCart\App\Models\Customer;
12 12 use FluentCart\App\Models\ProductVariation;
13 13 use FluentCart\App\Models\User;
14 14 use FluentCart\App\Services\DateTime\DateTime;
15 +use FluentCart\App\Services\Payments\PaymentHelper;
15 16 use FluentCart\App\Services\Localization\LocalizationManager;
16 17 use FluentCart\App\Services\Translations\TransStrings;
17 18 use FluentCart\App\Services\URL;
18 19 use FluentCart\Framework\Http\URL as BaseUrl;
@@ -32,8 +33,78 @@
32 33 const ROLE_CAPABILITY_PREFIX = 'fluent_cart/permissions/';
33 34
34 35 const USER_ROLE = 'fluent_cart_customer';
35 36
37 + const MIN_INSTALLMENT_TIMES = 2;
38 +
39 + /**
40 + * An installment plan must bill at least twice. times = 0 means unlimited
41 + * (a plain recurring subscription) and times = 1 collects a single payment,
42 + * which is a one-time purchase, not an installment plan.
43 + *
44 + * @param array $otherInfo A variant's other_info payload
45 + * @return string|null Error message, or null when valid
46 + */
47 + /**
48 + * One resolver for every `photo` a FluentCart record appends: the
49 + * uploaded customer photo for the user when one is set, otherwise the
50 + * WordPress avatar by user id or email. Gravatar is asked with
51 + * default=404, so an address with no avatar of its own fails to load
52 + * and the UI falls back to its placeholder icon instead of Gravatar's
53 + * stock image. esc_url_raw throughout — the value travels through
54 + * JSON, where esc_url's entity-encoded ampersands would break the
55 + * query string.
56 + *
57 + * @param int|string|null $userId user id owning a possible uploaded photo
58 + * @param string|null $email fallback identity when there is no user
59 + * @return string the photo URL, or '' when avatars are off or the
60 + * record carries no identity to resolve one from
61 + */
62 + public static function getUserAvatarUrl($userId, $email, int $size = 100): string
63 + {
64 + if (!get_option('show_avatars')) {
65 + return '';
66 + }
67 +
68 + if ($userId) {
69 + $customPhotoUrl = esc_url_raw(
70 + (string) get_user_meta((int) $userId, 'fc_customer_photo_url', true)
71 + );
72 +
73 + if ($customPhotoUrl) {
74 + return $customPhotoUrl;
75 + }
76 + }
77 +
78 + $userIdOrEmail = ((int) $userId) ?: (string) $email;
79 +
80 + if (!$userIdOrEmail) {
81 + return '';
82 + }
83 +
84 + $url = get_avatar_url($userIdOrEmail, [
85 + 'size' => $size,
86 + 'default' => '404',
87 + ]);
88 +
89 + return $url ? esc_url_raw($url) : '';
90 + }
91 +
92 + public static function installmentTimesError($otherInfo)
93 + {
94 + if (Arr::get($otherInfo, 'installment', 'no') !== 'yes') {
95 + return null;
96 + }
97 +
98 + $times = Arr::get($otherInfo, 'times', 0);
99 +
100 + if (!is_numeric($times) || (int)$times < static::MIN_INSTALLMENT_TIMES) {
101 + return __('Installment count must be 2 or more. A single installment is just a one-time payment — set the payment type to one-time instead.', 'fluent-cart');
102 + }
103 +
104 + return null;
105 + }
106 +
36 107 public static function getUidSerial()
37 108 {
38 109 static $id = 0;
39 110
@@ -42,8 +113,46 @@
42 113 return $id;
43 114
44 115 }
45 116
117 + /**
118 + * Build a spec-compliant "Upgrade to Pro" URL.
119 + *
120 + * Follows the shared Fluent* UTM spec:
121 + * utm_source = fluent-cart (fixed vocabulary, never the wp.org slug)
122 + * utm_medium = free_plugin | pro_plugin (acquisition vs cross-sell)
123 + * utm_campaign= upgrade_pro (override for xsell_<target> / license_*)
124 + * utm_content = the exact placement, e.g. feature_lock_advanced_inventory
125 + * utm_term = plugin version that generated the link
126 + * utm_id = promo id, blank normally (omit unless passed)
127 + *
128 + * @param string $content The utm_content placement.
129 + * @param array $overrides Override any utm_* param (e.g. utm_campaign for cross-sell).
130 + * @return string
131 + */
132 + public static function getUpgradeUrl($content = 'upgrade_page', $overrides = []): string
133 + {
134 + $baseUrl = (string) apply_filters(
135 + 'fluent_cart/pro_upgrade_base_url',
136 + 'https://fluentcart.com/discount-deal/'
137 + );
138 +
139 + $params = wp_parse_args($overrides, [
140 + 'utm_source' => 'fluent-cart',
141 + 'utm_medium' => App::isProActive() ? 'pro_plugin' : 'free_plugin',
142 + 'utm_campaign' => 'upgrade_pro',
143 + 'utm_content' => $content,
144 + 'utm_term' => defined('FLUENTCART_VERSION') ? FLUENTCART_VERSION : '',
145 + ]);
146 +
147 + // Drop any blank params (e.g. an unset utm_id) so they never hit the URL.
148 + $params = array_filter($params, function ($value) {
149 + return $value !== '' && $value !== null;
150 + });
151 +
152 + return add_query_arg($params, $baseUrl);
153 + }
154 +
46 155 public static function getRestInfo()
47 156 {
48 157 $app = App::getInstance();
49 158
@@ -215,17 +324,15 @@
215 324 }
216 325
217 326 public static function getOrderStatuses()
218 327 {
219 - $statuses = apply_filters_deprecated('fluent-cart/order_statuses', [
220 - [
221 - 'on-hold' => __('On Hold', 'fluent-cart'),
222 - 'processing' => __('Processing', 'fluent-cart'),
223 - 'completed' => __('Completed', 'fluent-cart'),
224 - //'archived' => __('Archived', 'fluent-cart'),
225 - 'cancelled' => __('Cancelled', 'fluent-cart'),
226 - ], []
227 - ], '1.3.16', 'fluent_cart/order_statuses', 'Use fluent_cart/order_statuses instead of fluent-cart/order_statuses.');
328 + $statuses = [
329 + 'on-hold' => __('On Hold', 'fluent-cart'),
330 + 'processing' => __('Processing', 'fluent-cart'),
331 + 'completed' => __('Completed', 'fluent-cart'),
332 + //'archived' => __('Archived', 'fluent-cart'),
333 + 'cancelled' => __('Cancelled', 'fluent-cart'),
334 + ];
228 335
229 336 return apply_filters('fluent_cart/order_statuses', $statuses, []);
230 337 }
231 338
@@ -230,17 +337,15 @@
230 337 }
231 338
232 339 public static function getEditableOrderStatuses()
233 340 {
234 - $statuses = apply_filters_deprecated('fluent-cart/editable_order_statuses', [
235 - [
236 - 'on-hold' => __('On Hold', 'fluent-cart'),
237 - 'processing' => __('Processing', 'fluent-cart'),
238 - 'completed' => __('Completed', 'fluent-cart'),
239 - // 'archived' => __('Archived', 'fluent-cart'),
240 - 'cancelled' => __('Cancelled', 'fluent-cart')
241 - ], []
242 - ], '1.3.16', 'fluent_cart/editable_order_statuses', 'Use fluent_cart/editable_order_statuses instead of fluent-cart/editable_order_statuses.');
341 + $statuses = [
342 + 'on-hold' => __('On Hold', 'fluent-cart'),
343 + 'processing' => __('Processing', 'fluent-cart'),
344 + 'completed' => __('Completed', 'fluent-cart'),
345 + // 'archived' => __('Archived', 'fluent-cart'),
346 + 'cancelled' => __('Cancelled', 'fluent-cart')
347 + ];
243 348
244 349 return apply_filters('fluent_cart/editable_order_statuses', $statuses, []);
245 350 }
246 351
@@ -245,14 +350,12 @@
245 350 }
246 351
247 352 public static function getEditableCustomerStatuses()
248 353 {
249 - $statuses = apply_filters_deprecated('fluent-cart/editable_customer_statuses', [
250 - [
251 - 'active' => __('Active', 'fluent-cart'),
252 - 'inactive' => __('Inactive', 'fluent-cart'),
253 - ], []
254 - ], '1.3.16', 'fluent_cart/editable_customer_statuses', 'Use fluent_cart/editable_customer_statuses instead of fluent-cart/editable_customer_statuses.');
354 + $statuses = [
355 + 'active' => __('Active', 'fluent-cart'),
356 + 'inactive' => __('Inactive', 'fluent-cart'),
357 + ];
255 358
256 359 return apply_filters('fluent_cart/editable_customer_statuses', $statuses, []);
257 360 }
258 361
@@ -257,16 +360,14 @@
257 360 }
258 361
259 362 public static function getShippingStatuses()
260 363 {
261 - $statuses = apply_filters_deprecated('fluent-cart/shipping_statuses', [
262 - [
263 - 'unshipped' => __('Unshipped', 'fluent-cart'),
264 - 'shipped' => __('Shipped', 'fluent-cart'),
265 - 'delivered' => __('Delivered', 'fluent-cart'),
266 - 'unshippable' => __('Unshippable', 'fluent-cart'),
267 - ], []
268 - ], '1.3.16', 'fluent_cart/shipping_statuses', 'Use fluent_cart/shipping_statuses instead of fluent-cart/shipping_statuses.');
364 + $statuses = [
365 + 'unshipped' => __('Unshipped', 'fluent-cart'),
366 + 'shipped' => __('Shipped', 'fluent-cart'),
367 + 'delivered' => __('Delivered', 'fluent-cart'),
368 + 'unshippable' => __('Unshippable', 'fluent-cart'),
369 + ];
269 370
270 371 return apply_filters('fluent_cart/shipping_statuses', $statuses, []);
271 372 }
272 373
@@ -271,16 +372,14 @@
271 372 }
272 373
273 374 public static function getEditableShippingStatuses()
274 375 {
275 - $statuses = apply_filters_deprecated('fluent-cart/editable_order_statuses', [
276 - [
277 - 'unshipped' => __('Unshipped', 'fluent-cart'),
278 - 'shipped' => __('Shipped', 'fluent-cart'),
279 - 'delivered' => __('Delivered', 'fluent-cart'),
280 - 'unshippable' => __('Unshippable', 'fluent-cart'),
281 - ], []
282 - ], '1.3.16', 'fluent_cart/editable_shipping_statuses', 'Use fluent_cart/editable_shipping_statuses instead of fluent-cart/editable_order_statuses.');
376 + $statuses = [
377 + 'unshipped' => __('Unshipped', 'fluent-cart'),
378 + 'shipped' => __('Shipped', 'fluent-cart'),
379 + 'delivered' => __('Delivered', 'fluent-cart'),
380 + 'unshippable' => __('Unshippable', 'fluent-cart'),
381 + ];
283 382
284 383 return apply_filters('fluent_cart/editable_shipping_statuses', $statuses, []);
285 384 }
286 385
@@ -310,18 +409,16 @@
310 409 }
311 410
312 411 public static function getTransactionStatuses($withLabel = true)
313 412 {
314 - $statuses = apply_filters_deprecated('fluent-cart/transaction_statuses', [
315 - [
316 - 'pending' => __('Pending', 'fluent-cart'),
317 - 'paid' => __('Paid', 'fluent-cart'),
318 - 'require_capture' => __('Authorized (Require Capture)', 'fluent-cart'),
319 - 'failed' => __('Failed', 'fluent-cart'),
320 - 'refunded' => __('Refunded', 'fluent-cart'),
321 - 'active' => __('Active', 'fluent-cart'),
322 - ], []
323 - ], '1.3.16', 'fluent_cart/transaction_statuses', 'Use fluent_cart/transaction_statuses instead of fluent-cart/transaction_statuses.');
413 + $statuses = [
414 + 'pending' => __('Pending', 'fluent-cart'),
415 + 'paid' => __('Paid', 'fluent-cart'),
416 + 'require_capture' => __('Authorized (Require Capture)', 'fluent-cart'),
417 + 'failed' => __('Failed', 'fluent-cart'),
418 + 'refunded' => __('Refunded', 'fluent-cart'),
419 + 'active' => __('Active', 'fluent-cart'),
420 + ];
324 421
325 422 $statuses = apply_filters('fluent_cart/transaction_statuses', $statuses, []);
326 423
327 424 if ($withLabel) {
@@ -332,16 +429,14 @@
332 429 }
333 430
334 431 public static function getEditableTransactionStatuses($withLabel = true)
335 432 {
336 - $statuses = apply_filters_deprecated('fluent-cart/editable_transaction_statuses', [
337 - [
338 - 'pending' => __('Pending', 'fluent-cart'),
339 - 'paid' => __('Paid', 'fluent-cart'),
340 - 'failed' => __('Failed', 'fluent-cart'),
341 - 'refunded' => __('Refunded', 'fluent-cart'),
342 - ], []
343 - ], '1.3.16', 'fluent_cart/editable_transaction_statuses', 'Use fluent_cart/editable_transaction_statuses instead of fluent-cart/editable_transaction_statuses.');
433 + $statuses = [
434 + 'pending' => __('Pending', 'fluent-cart'),
435 + 'paid' => __('Paid', 'fluent-cart'),
436 + 'failed' => __('Failed', 'fluent-cart'),
437 + 'refunded' => __('Refunded', 'fluent-cart'),
438 + ];
344 439
345 440 $statuses = apply_filters('fluent_cart/editable_transaction_statuses', $statuses, []);
346 441
347 442 if ($withLabel) {
@@ -350,21 +445,8 @@
350 445
351 446 return array_keys($statuses);
352 447 }
353 448
354 - public static function loadSpoutLib()
355 - {
356 - static $loaded;
357 -
358 - if ($loaded) {
359 - return $loaded;
360 - }
361 -
362 - require_once FLUENTCART_PLUGIN_PATH . 'app/Services/Libs/Spout/Autoloader/autoload.php';
363 -
364 - return true;
365 - }
366 -
367 449 public static function productStatuses($withLabel = true): array
368 450 {
369 451 $statues = [
370 452 'publish' => __('Publish', 'fluent-cart'),
@@ -515,8 +597,41 @@
515 597 $amount = (int)round($amount); // Round to nearest integer, then cast
516 598 return $amount;
517 599 }
518 600
601 + /**
602 + * Normalize a value that is ALREADY in cents to a whole-cent int.
603 + *
604 + * Unlike toCent(), this does not scale — use it when the incoming value is a
605 + * cents amount that may have arrived as a float. Clients computing cents in
606 + * JavaScript send artifacts like 1998.9999999999998 for 1999, and a bare
607 + * (int) cast truncates those, silently undercharging by a cent. Non-numeric
608 + * input (including the null the request pipeline injects for omitted keys)
609 + * normalizes to 0.
610 + *
611 + * Magnitudes beyond float's exact-integer range (2^53) are REJECTED, not
612 + * coerced: is_numeric() accepts exponential notation like 1e19, and casting
613 + * that float to int wraps to -8446744073709551616 — a VALID signed BIGINT —
614 + * so the corrupt value would persist silently where an un-normalized float
615 + * used to fail loudly at MySQL. No real cents amount approaches this bound.
616 + */
617 + public static function roundCent($amount): int
618 + {
619 + if (!is_numeric($amount)) {
620 + return 0;
621 + }
622 +
623 + $amount = (float) $amount;
624 +
625 + if (!is_finite($amount) || $amount > 9.0e15 || $amount < -9.0e15) {
626 + throw new \InvalidArgumentException(
627 + 'Value is out of the representable cents range: ' . var_export($amount, true)
628 + );
629 + }
630 +
631 + return (int) round($amount);
632 + }
633 +
519 634 public static function toDecimalWithoutComma($amount)
520 635 {
521 636
522 637 if (!is_numeric($amount)) {
@@ -539,10 +654,12 @@
539 654 if (!$user) {
540 655 return null;
541 656 }
542 657
658 + // Identity only — see CustomerResource::getCurrentCustomer() for why an
659 + // email match must never reach a customer row.
543 660 return Customer::query()->where('user_id', $user->ID)
544 - ->orWhere('email', $user->user_email)
661 + ->orderBy('id', 'ASC')
545 662 ->first();
546 663 }
547 664
548 665 /**
@@ -570,27 +687,25 @@
570 687 }
571 688
572 689 public static function getAvailableCurrencyList()
573 690 {
574 - $currencies = apply_filters_deprecated('fluent-cart/available_currencies', [
575 - [
576 - 'BDT' => [
577 - "label" => __('Bangladeshi Taka', 'fluent-cart'),
578 - "value" => 'BDT',
579 - "symbol" => '৳',
580 - ],
581 - 'USD' => [
582 - "label" => __('United State Dollar', 'fluent-cart'),
583 - "value" => 'USD',
584 - "symbol" => '$',
585 - ],
586 - 'GBP' => [
587 - "label" => __('United Kingdom', 'fluent-cart'),
588 - "value" => 'GBP',
589 - "symbol" => '£',
590 - ],
591 - ], []
592 - ], '1.3.16', 'fluent_cart/available_currencies', 'Use fluent_cart/available_currencies instead of fluent-cart/available_currencies.');
691 + $currencies = [
692 + 'BDT' => [
693 + "label" => __('Bangladeshi Taka', 'fluent-cart'),
694 + "value" => 'BDT',
695 + "symbol" => '৳',
696 + ],
697 + 'USD' => [
698 + "label" => __('United State Dollar', 'fluent-cart'),
699 + "value" => 'USD',
700 + "symbol" => '$',
701 + ],
702 + 'GBP' => [
703 + "label" => __('United Kingdom', 'fluent-cart'),
704 + "value" => 'GBP',
705 + "symbol" => '£',
706 + ],
707 + ];
593 708
594 709 return apply_filters('fluent_cart/available_currencies', $currencies, []);
595 710 }
596 711
@@ -707,18 +822,25 @@
707 822 }
708 823
709 824 public static function getVariationTypes($withLabel = true)
710 825 {
711 - $statues = [
712 - 'simple' => __('Simple', 'fluent-cart'),
713 - 'simple_variations' => __('Simple Variation', 'fluent-cart'),
826 + // advanced_variations is advertised in free too so the variation-type
827 + // dropdown can offer it (shown Pro-locked with a crown and an upgrade
828 + // modal while Pro is inactive). Pro re-registers the same key via the
829 + // filter below when active.
830 + $types = [
831 + 'simple' => __('Simple', 'fluent-cart'),
832 + 'simple_variations' => __('Simple Variations', 'fluent-cart'),
833 + 'advanced_variations' => __('Advanced Variations', 'fluent-cart'),
714 834 ];
715 835
836 + $types = apply_filters('fluent_cart/variation_types', $types);
837 +
716 838 if ($withLabel) {
717 - return $statues;
839 + return $types;
718 840 }
719 841
720 - return array_keys($statues);
842 + return array_keys($types);
721 843 }
722 844
723 845 public static function isValueEncrypted($raw_value)
724 846 {
@@ -930,15 +1052,13 @@
930 1052 }
931 1053
932 1054 public static function getCouponStatuses()
933 1055 {
934 - $statuses = apply_filters_deprecated('fluent-cart/coupon_statuses', [
935 - [
936 - 'active' => __('Active', 'fluent-cart'),
937 - 'expired' => __('Expired', 'fluent-cart'),
938 - 'disabled' => __('Disabled', 'fluent-cart'),
939 - ], []
940 - ], '1.3.16', 'fluent_cart/coupon_statuses', 'Use fluent_cart/coupon_statuses instead of fluent-cart/coupon_statuses.');
1056 + $statuses = [
1057 + 'active' => __('Active', 'fluent-cart'),
1058 + 'expired' => __('Expired', 'fluent-cart'),
1059 + 'disabled' => __('Disabled', 'fluent-cart'),
1060 + ];
941 1061
942 1062 return apply_filters('fluent_cart/coupon_statuses', $statuses, []);
943 1063 }
944 1064
@@ -1204,8 +1324,150 @@
1204 1324
1205 1325 return !empty($otherInfo) ? $paymentInfo : null;
1206 1326 }
1207 1327
1328 + /**
1329 + * Billing-cycle text for a subscription with a config-defined schedule
1330 + * (see SubscriptionHelper::getBillingSchedule()) — cadences the
1331 + * billing_interval enum cannot express, e.g. "$100 per 3 years on Aug 19".
1332 + * Subscriptions without a schedule keep generateSubscriptionInfo().
1333 + */
1334 + public static function generateScheduleSubscriptionInfo(array $schedule, $otherInfo, $itemPrice, $currencyCode = null): ?string
1335 + {
1336 + if (is_object($otherInfo)) {
1337 + $otherInfo = json_decode(json_encode($otherInfo), true);
1338 + }
1339 +
1340 + $count = max(1, (int) Arr::get($schedule, 'interval', 1));
1341 +
1342 + switch (Arr::get($schedule, 'period')) {
1343 + case 'day':
1344 + $unitLabel = _n('day', 'days', $count, 'fluent-cart');
1345 + break;
1346 + case 'week':
1347 + $unitLabel = _n('week', 'weeks', $count, 'fluent-cart');
1348 + break;
1349 + case 'month':
1350 + $unitLabel = _n('month', 'months', $count, 'fluent-cart');
1351 + break;
1352 + case 'year':
1353 + $unitLabel = _n('year', 'years', $count, 'fluent-cart');
1354 + break;
1355 + default:
1356 + return self::generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode);
1357 + }
1358 +
1359 + $price = self::toDecimal($itemPrice, true, $currencyCode);
1360 + $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1361 +
1362 + if ($recurringDiscountAmount) {
1363 + $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1364 + $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1365 + }
1366 +
1367 + $interval = $count === 1
1368 + ? sprintf(
1369 + /* translators: %s is the interval unit (e.g., day, week, month, year) */
1370 + __('per %s', 'fluent-cart'),
1371 + $unitLabel
1372 + )
1373 + : sprintf(
1374 + /* translators: %1$d is the count number, %2$s is the plural unit name (e.g., days, months, years) */
1375 + __('per %1$d %2$s', 'fluent-cart'),
1376 + $count,
1377 + $unitLabel
1378 + );
1379 +
1380 + if ($anchorText = self::getScheduleAnchorText($schedule['period'], Arr::get($schedule, 'anchor', []))) {
1381 + $interval .= ' ' . $anchorText;
1382 + }
1383 +
1384 + $occurrence = (int) Arr::get($otherInfo, 'times', 0);
1385 +
1386 + if (empty($occurrence)) {
1387 + return sprintf(
1388 + /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text */
1389 + __('%1$s %2$s %3$s', 'fluent-cart'),
1390 + $price,
1391 + $interval,
1392 + __('until cancel', 'fluent-cart')
1393 + );
1394 + }
1395 +
1396 + return sprintf(
1397 + /* translators: %1$s is the price, %2$s is the interval, %3$s is the occurrence count, %4$s is "cycle(s)" */
1398 + __('%1$s %2$s, for %3$s %4$s', 'fluent-cart'),
1399 + $price,
1400 + $interval,
1401 + $occurrence,
1402 + _n('cycle', 'cycles', $occurrence, 'fluent-cart')
1403 + );
1404 + }
1405 +
1406 + /**
1407 + * Human-readable billing anchor, e.g. "on Friday", "on the 10th",
1408 + * "on the last day", "on Aug 19". Anchor day 31 encodes "last day of
1409 + * the month" (see SubscriptionHelper::getBillingSchedule()).
1410 + */
1411 + private static function getScheduleAnchorText(string $period, $anchor): string
1412 + {
1413 + if (!is_array($anchor) || !$anchor) {
1414 + return '';
1415 + }
1416 +
1417 + if ($period === 'week' && !empty($anchor['weekday'])) {
1418 + $weekdays = [
1419 + 1 => __('Monday', 'fluent-cart'),
1420 + 2 => __('Tuesday', 'fluent-cart'),
1421 + 3 => __('Wednesday', 'fluent-cart'),
1422 + 4 => __('Thursday', 'fluent-cart'),
1423 + 5 => __('Friday', 'fluent-cart'),
1424 + 6 => __('Saturday', 'fluent-cart'),
1425 + 7 => __('Sunday', 'fluent-cart'),
1426 + ];
1427 +
1428 + if (isset($weekdays[$anchor['weekday']])) {
1429 + /* translators: %s is a weekday name, e.g. "on Friday" */
1430 + return sprintf(__('on %s', 'fluent-cart'), $weekdays[$anchor['weekday']]);
1431 + }
1432 +
1433 + return '';
1434 + }
1435 +
1436 + if ($period === 'month' && !empty($anchor['day'])) {
1437 + $day = (int) $anchor['day'];
1438 +
1439 + if ($day === 31) {
1440 + return __('on the last day', 'fluent-cart');
1441 + }
1442 +
1443 + /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1444 + return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1445 + }
1446 +
1447 + if ($period === 'year' && (!empty($anchor['day']) || !empty($anchor['month']))) {
1448 + $day = (int) Arr::get($anchor, 'day', 0);
1449 + $month = (int) Arr::get($anchor, 'month', 0);
1450 +
1451 + if ($month && $day) {
1452 + /* translators: %s is a date, e.g. "on Aug 19" */
1453 + // wp_date(), not gmdate(): gmdate() has no locale, so this month
1454 + // name stayed English inside an otherwise translated sentence.
1455 + return sprintf(__('on %s', 'fluent-cart'), wp_date('M', gmmktime(12, 0, 0, $month, 1, 2001), new \DateTimeZone('UTC')) . ' ' . $day);
1456 + }
1457 +
1458 + if ($month) {
1459 + /* translators: %s is a month name, e.g. "in August" */
1460 + return sprintf(__('in %s', 'fluent-cart'), wp_date('F', gmmktime(12, 0, 0, $month, 1, 2001), new \DateTimeZone('UTC')));
1461 + }
1462 +
1463 + /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1464 + return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1465 + }
1466 +
1467 + return '';
1468 + }
1469 +
1208 1470 public static function generateSetupFeeInfo($otherInfo, $asArray = false)
1209 1471 {
1210 1472 // Convert to array if it's an object
1211 1473 if (is_object($otherInfo)) {
@@ -1270,9 +1532,8 @@
1270 1532
1271 1533 public static function getCountryList(): array
1272 1534 {
1273 1535 $options = App::getInstance('localization')->countriesOptions();
1274 - $options = apply_filters_deprecated('fluent-cart/util/countries', [$options, []], '1.3.16', 'fluent_cart/util/countries', 'Use fluent_cart/util/countries instead of fluent-cart/util/countries.');
1275 1536
1276 1537 return apply_filters('fluent_cart/util/countries', $options, []);
1277 1538 }
1278 1539
@@ -1712,26 +1973,9 @@
1712 1973 }
1713 1974
1714 1975 public static function subscriptionIntervalInDays($interval)
1715 1976 {
1716 - switch ($interval) {
1717 - case 'daily':
1718 - return 1;
1719 - case 'weekly':
1720 - return 7;
1721 - case 'monthly':
1722 - return 30;
1723 - case 'quarterly':
1724 - return 90;
1725 - case 'half_yearly':
1726 - return 182;
1727 - case 'yearly':
1728 - return 365;
1729 - default:
1730 - return apply_filters('fluent_cart/subscription_interval_in_days', 0, [
1731 - 'interval' => $interval,
1732 - ]);
1733 - }
1977 + return PaymentHelper::getIntervalDays($interval);
1734 1978 }
1735 1979
1736 1980 public static function parseTermIdsForFilter($filters): array
1737 1981 {
@@ -1786,12 +2030,12 @@
1786 2030 {
1787 2031 $allChildVariants = Arr::pluck($variants, 'other_info.bundle_child_ids');
1788 2032
1789 2033 $allChildVariants = array_unique(Arr::flatten($allChildVariants));
1790 - $allChildVariants = Arr::except(
2034 + $allChildVariants = array_values(array_diff(
1791 2035 $allChildVariants,
1792 2036 Arr::pluck($variants, 'id')
1793 - );
2037 + ));
1794 2038 $allChildVariants = array_filter($allChildVariants);
1795 2039 $childVariants = ProductVariation::query()
1796 2040 ->whereIn('id', $allChildVariants)
1797 2041 ->with('product:ID,post_title')
@@ -1912,6 +2156,40 @@
1912 2156 }
1913 2157 return (int) $order->tax_behavior === 2
1914 2158 ? esc_html__('(Included)', 'fluent-cart')
1915 2159 : esc_html__('(Excluded)', 'fluent-cart');
2160 + }
2161 +
2162 + /**
2163 + * Product placeholder illustration (an open box holding a picture) shown
2164 + * wherever a product has no image. The web copy is a 1200px WebP (sharp on 2x screens); email
2165 + * clients (Outlook) and PDF renderers get a 300px PNG of the same art.
2166 + */
2167 + const PRODUCT_PLACEHOLDER_IMAGE = 'images/product-placeholder.webp';
2168 + const PRODUCT_PLACEHOLDER_EMAIL_IMAGE = 'images/product-placeholder.png';
2169 +
2170 + public static function getProductPlaceholderUrl(): string
2171 + {
2172 + return \FluentCart\App\Vite::getAssetUrl(static::PRODUCT_PLACEHOLDER_IMAGE);
2173 + }
2174 +
2175 + public static function getProductPlaceholderEmailUrl(): string
2176 + {
2177 + return \FluentCart\App\Vite::getAssetUrl(static::PRODUCT_PLACEHOLDER_EMAIL_IMAGE);
2178 + }
2179 +
2180 + /**
2181 + * A product image URL, or the placeholder when there is none. Carts store
2182 + * the thumbnail URL in cart_data, so carts built before the illustration
2183 + * still carry the old generic icon (images/placeholder.svg); map that too.
2184 + */
2185 + public static function productImageOrPlaceholder($url): string
2186 + {
2187 + $url = is_string($url) ? $url : '';
2188 +
2189 + if ($url === '' || preg_match('#/images/placeholder\.svg(\?.*)?$#', $url)) {
2190 + return static::getProductPlaceholderUrl();
2191 + }
2192 +
2193 + return $url;
1916 2194 }
1917 2195 }