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 +365 -119 1.5.4 → 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;
@@ -42,8 +43,53 @@
42 43 *
43 44 * @param array $otherInfo A variant's other_info payload
44 45 * @return string|null Error message, or null when valid
45 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 +
46 92 public static function installmentTimesError($otherInfo)
47 93 {
48 94 if (Arr::get($otherInfo, 'installment', 'no') !== 'yes') {
49 95 return null;
@@ -67,8 +113,46 @@
67 113 return $id;
68 114
69 115 }
70 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 +
71 155 public static function getRestInfo()
72 156 {
73 157 $app = App::getInstance();
74 158
@@ -240,17 +324,15 @@
240 324 }
241 325
242 326 public static function getOrderStatuses()
243 327 {
244 - $statuses = apply_filters_deprecated('fluent-cart/order_statuses', [
245 - [
246 - 'on-hold' => __('On Hold', 'fluent-cart'),
247 - 'processing' => __('Processing', 'fluent-cart'),
248 - 'completed' => __('Completed', 'fluent-cart'),
249 - //'archived' => __('Archived', 'fluent-cart'),
250 - 'cancelled' => __('Cancelled', 'fluent-cart'),
251 - ], []
252 - ], '1.3.16', 'fluent_cart/order_statuses', 'Use fluent_cart/order_statuses instead of fluent-cart/order_statuses. It will be removed in v1.4.3.');
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 + ];
253 335
254 336 return apply_filters('fluent_cart/order_statuses', $statuses, []);
255 337 }
256 338
@@ -255,17 +337,15 @@
255 337 }
256 338
257 339 public static function getEditableOrderStatuses()
258 340 {
259 - $statuses = apply_filters_deprecated('fluent-cart/editable_order_statuses', [
260 - [
261 - 'on-hold' => __('On Hold', 'fluent-cart'),
262 - 'processing' => __('Processing', 'fluent-cart'),
263 - 'completed' => __('Completed', 'fluent-cart'),
264 - // 'archived' => __('Archived', 'fluent-cart'),
265 - 'cancelled' => __('Cancelled', 'fluent-cart')
266 - ], []
267 - ], '1.3.16', 'fluent_cart/editable_order_statuses', 'Use fluent_cart/editable_order_statuses instead of fluent-cart/editable_order_statuses. It will be removed in v1.4.3.');
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 + ];
268 348
269 349 return apply_filters('fluent_cart/editable_order_statuses', $statuses, []);
270 350 }
271 351
@@ -270,14 +350,12 @@
270 350 }
271 351
272 352 public static function getEditableCustomerStatuses()
273 353 {
274 - $statuses = apply_filters_deprecated('fluent-cart/editable_customer_statuses', [
275 - [
276 - 'active' => __('Active', 'fluent-cart'),
277 - 'inactive' => __('Inactive', 'fluent-cart'),
278 - ], []
279 - ], '1.3.16', 'fluent_cart/editable_customer_statuses', 'Use fluent_cart/editable_customer_statuses instead of fluent-cart/editable_customer_statuses. It will be removed in v1.4.3.');
354 + $statuses = [
355 + 'active' => __('Active', 'fluent-cart'),
356 + 'inactive' => __('Inactive', 'fluent-cart'),
357 + ];
280 358
281 359 return apply_filters('fluent_cart/editable_customer_statuses', $statuses, []);
282 360 }
283 361
@@ -282,16 +360,14 @@
282 360 }
283 361
284 362 public static function getShippingStatuses()
285 363 {
286 - $statuses = apply_filters_deprecated('fluent-cart/shipping_statuses', [
287 - [
288 - 'unshipped' => __('Unshipped', 'fluent-cart'),
289 - 'shipped' => __('Shipped', 'fluent-cart'),
290 - 'delivered' => __('Delivered', 'fluent-cart'),
291 - 'unshippable' => __('Unshippable', 'fluent-cart'),
292 - ], []
293 - ], '1.3.16', 'fluent_cart/shipping_statuses', 'Use fluent_cart/shipping_statuses instead of fluent-cart/shipping_statuses. It will be removed in v1.4.3.');
364 + $statuses = [
365 + 'unshipped' => __('Unshipped', 'fluent-cart'),
366 + 'shipped' => __('Shipped', 'fluent-cart'),
367 + 'delivered' => __('Delivered', 'fluent-cart'),
368 + 'unshippable' => __('Unshippable', 'fluent-cart'),
369 + ];
294 370
295 371 return apply_filters('fluent_cart/shipping_statuses', $statuses, []);
296 372 }
297 373
@@ -296,16 +372,14 @@
296 372 }
297 373
298 374 public static function getEditableShippingStatuses()
299 375 {
300 - $statuses = apply_filters_deprecated('fluent-cart/editable_order_statuses', [
301 - [
302 - 'unshipped' => __('Unshipped', 'fluent-cart'),
303 - 'shipped' => __('Shipped', 'fluent-cart'),
304 - 'delivered' => __('Delivered', 'fluent-cart'),
305 - 'unshippable' => __('Unshippable', 'fluent-cart'),
306 - ], []
307 - ], '1.3.16', 'fluent_cart/editable_shipping_statuses', 'Use fluent_cart/editable_shipping_statuses instead of fluent-cart/editable_order_statuses. It will be removed in v1.4.3.');
376 + $statuses = [
377 + 'unshipped' => __('Unshipped', 'fluent-cart'),
378 + 'shipped' => __('Shipped', 'fluent-cart'),
379 + 'delivered' => __('Delivered', 'fluent-cart'),
380 + 'unshippable' => __('Unshippable', 'fluent-cart'),
381 + ];
308 382
309 383 return apply_filters('fluent_cart/editable_shipping_statuses', $statuses, []);
310 384 }
311 385
@@ -335,18 +409,16 @@
335 409 }
336 410
337 411 public static function getTransactionStatuses($withLabel = true)
338 412 {
339 - $statuses = apply_filters_deprecated('fluent-cart/transaction_statuses', [
340 - [
341 - 'pending' => __('Pending', 'fluent-cart'),
342 - 'paid' => __('Paid', 'fluent-cart'),
343 - 'require_capture' => __('Authorized (Require Capture)', 'fluent-cart'),
344 - 'failed' => __('Failed', 'fluent-cart'),
345 - 'refunded' => __('Refunded', 'fluent-cart'),
346 - 'active' => __('Active', 'fluent-cart'),
347 - ], []
348 - ], '1.3.16', 'fluent_cart/transaction_statuses', 'Use fluent_cart/transaction_statuses instead of fluent-cart/transaction_statuses. It will be removed in v1.4.3.');
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 + ];
349 421
350 422 $statuses = apply_filters('fluent_cart/transaction_statuses', $statuses, []);
351 423
352 424 if ($withLabel) {
@@ -357,16 +429,14 @@
357 429 }
358 430
359 431 public static function getEditableTransactionStatuses($withLabel = true)
360 432 {
361 - $statuses = apply_filters_deprecated('fluent-cart/editable_transaction_statuses', [
362 - [
363 - 'pending' => __('Pending', 'fluent-cart'),
364 - 'paid' => __('Paid', 'fluent-cart'),
365 - 'failed' => __('Failed', 'fluent-cart'),
366 - 'refunded' => __('Refunded', 'fluent-cart'),
367 - ], []
368 - ], '1.3.16', 'fluent_cart/editable_transaction_statuses', 'Use fluent_cart/editable_transaction_statuses instead of fluent-cart/editable_transaction_statuses. It will be removed in v1.4.3.');
433 + $statuses = [
434 + 'pending' => __('Pending', 'fluent-cart'),
435 + 'paid' => __('Paid', 'fluent-cart'),
436 + 'failed' => __('Failed', 'fluent-cart'),
437 + 'refunded' => __('Refunded', 'fluent-cart'),
438 + ];
369 439
370 440 $statuses = apply_filters('fluent_cart/editable_transaction_statuses', $statuses, []);
371 441
372 442 if ($withLabel) {
@@ -375,21 +445,8 @@
375 445
376 446 return array_keys($statuses);
377 447 }
378 448
379 - public static function loadSpoutLib()
380 - {
381 - static $loaded;
382 -
383 - if ($loaded) {
384 - return $loaded;
385 - }
386 -
387 - require_once FLUENTCART_PLUGIN_PATH . 'app/Services/Libs/Spout/Autoloader/autoload.php';
388 -
389 - return true;
390 - }
391 -
392 449 public static function productStatuses($withLabel = true): array
393 450 {
394 451 $statues = [
395 452 'publish' => __('Publish', 'fluent-cart'),
@@ -540,8 +597,41 @@
540 597 $amount = (int)round($amount); // Round to nearest integer, then cast
541 598 return $amount;
542 599 }
543 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 +
544 634 public static function toDecimalWithoutComma($amount)
545 635 {
546 636
547 637 if (!is_numeric($amount)) {
@@ -564,10 +654,12 @@
564 654 if (!$user) {
565 655 return null;
566 656 }
567 657
658 + // Identity only — see CustomerResource::getCurrentCustomer() for why an
659 + // email match must never reach a customer row.
568 660 return Customer::query()->where('user_id', $user->ID)
569 - ->orWhere('email', $user->user_email)
661 + ->orderBy('id', 'ASC')
570 662 ->first();
571 663 }
572 664
573 665 /**
@@ -595,27 +687,25 @@
595 687 }
596 688
597 689 public static function getAvailableCurrencyList()
598 690 {
599 - $currencies = apply_filters_deprecated('fluent-cart/available_currencies', [
600 - [
601 - 'BDT' => [
602 - "label" => __('Bangladeshi Taka', 'fluent-cart'),
603 - "value" => 'BDT',
604 - "symbol" => '৳',
605 - ],
606 - 'USD' => [
607 - "label" => __('United State Dollar', 'fluent-cart'),
608 - "value" => 'USD',
609 - "symbol" => '$',
610 - ],
611 - 'GBP' => [
612 - "label" => __('United Kingdom', 'fluent-cart'),
613 - "value" => 'GBP',
614 - "symbol" => '£',
615 - ],
616 - ], []
617 - ], '1.3.16', 'fluent_cart/available_currencies', 'Use fluent_cart/available_currencies instead of fluent-cart/available_currencies. It will be removed in v1.4.3.');
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 + ];
618 708
619 709 return apply_filters('fluent_cart/available_currencies', $currencies, []);
620 710 }
621 711
@@ -962,15 +1052,13 @@
962 1052 }
963 1053
964 1054 public static function getCouponStatuses()
965 1055 {
966 - $statuses = apply_filters_deprecated('fluent-cart/coupon_statuses', [
967 - [
968 - 'active' => __('Active', 'fluent-cart'),
969 - 'expired' => __('Expired', 'fluent-cart'),
970 - 'disabled' => __('Disabled', 'fluent-cart'),
971 - ], []
972 - ], '1.3.16', 'fluent_cart/coupon_statuses', 'Use fluent_cart/coupon_statuses instead of fluent-cart/coupon_statuses. It will be removed in v1.4.3.');
1056 + $statuses = [
1057 + 'active' => __('Active', 'fluent-cart'),
1058 + 'expired' => __('Expired', 'fluent-cart'),
1059 + 'disabled' => __('Disabled', 'fluent-cart'),
1060 + ];
973 1061
974 1062 return apply_filters('fluent_cart/coupon_statuses', $statuses, []);
975 1063 }
976 1064
@@ -1236,8 +1324,150 @@
1236 1324
1237 1325 return !empty($otherInfo) ? $paymentInfo : null;
1238 1326 }
1239 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 +
1240 1470 public static function generateSetupFeeInfo($otherInfo, $asArray = false)
1241 1471 {
1242 1472 // Convert to array if it's an object
1243 1473 if (is_object($otherInfo)) {
@@ -1302,9 +1532,8 @@
1302 1532
1303 1533 public static function getCountryList(): array
1304 1534 {
1305 1535 $options = App::getInstance('localization')->countriesOptions();
1306 - $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. It will be removed in v1.4.3.');
1307 1536
1308 1537 return apply_filters('fluent_cart/util/countries', $options, []);
1309 1538 }
1310 1539
@@ -1744,26 +1973,9 @@
1744 1973 }
1745 1974
1746 1975 public static function subscriptionIntervalInDays($interval)
1747 1976 {
1748 - switch ($interval) {
1749 - case 'daily':
1750 - return 1;
1751 - case 'weekly':
1752 - return 7;
1753 - case 'monthly':
1754 - return 30;
1755 - case 'quarterly':
1756 - return 90;
1757 - case 'half_yearly':
1758 - return 182;
1759 - case 'yearly':
1760 - return 365;
1761 - default:
1762 - return apply_filters('fluent_cart/subscription_interval_in_days', 0, [
1763 - 'interval' => $interval,
1764 - ]);
1765 - }
1977 + return PaymentHelper::getIntervalDays($interval);
1766 1978 }
1767 1979
1768 1980 public static function parseTermIdsForFilter($filters): array
1769 1981 {
@@ -1818,12 +2030,12 @@
1818 2030 {
1819 2031 $allChildVariants = Arr::pluck($variants, 'other_info.bundle_child_ids');
1820 2032
1821 2033 $allChildVariants = array_unique(Arr::flatten($allChildVariants));
1822 - $allChildVariants = Arr::except(
2034 + $allChildVariants = array_values(array_diff(
1823 2035 $allChildVariants,
1824 2036 Arr::pluck($variants, 'id')
1825 - );
2037 + ));
1826 2038 $allChildVariants = array_filter($allChildVariants);
1827 2039 $childVariants = ProductVariation::query()
1828 2040 ->whereIn('id', $allChildVariants)
1829 2041 ->with('product:ID,post_title')
@@ -1944,6 +2156,40 @@
1944 2156 }
1945 2157 return (int) $order->tax_behavior === 2
1946 2158 ? esc_html__('(Included)', 'fluent-cart')
1947 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;
1948 2194 }
1949 2195 }