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
fluent-cart / app / Helpers / Helper.php

Helper.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.0, at app/Helpers/Helper.php

2,196 lines 72.3 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\Helpers;
4
5 use FluentCart\Api\Confirmation;
6 use FluentCart\Api\CurrencySettings;
7 use FluentCart\Api\StoreSettings;
8 use FluentCart\Api\Taxonomy;
9 use FluentCart\App\App;
10 use FluentCart\App\CPT\FluentProducts;
11 use FluentCart\App\Models\Customer;
12 use FluentCart\App\Models\ProductVariation;
13 use FluentCart\App\Models\User;
14 use FluentCart\App\Services\DateTime\DateTime;
15 use FluentCart\App\Services\Payments\PaymentHelper;
16 use FluentCart\App\Services\Localization\LocalizationManager;
17 use FluentCart\App\Services\Translations\TransStrings;
18 use FluentCart\App\Services\URL;
19 use FluentCart\Framework\Http\URL as BaseUrl;
20 use FluentCart\Framework\Support\Arr;
21
22 class Helper
23 {
24 const PRODUCT_TYPE_SIMPLE = 'simple';
25 const PRODUCT_TYPE_SIMPLE_VARIATION = 'simple_variations';
26 const PRODUCT_TYPE_ADVANCE_VARIATION = 'advanced_variations';
27
28 const INSTANT_CHECKOUT_URL_PARAM = 'fct_cart_hash';
29
30 const IN_STOCK = 'in-stock';
31 const OUT_OF_STOCK = 'out-of-stock';
32
33 const ROLE_CAPABILITY_PREFIX = 'fluent_cart/permissions/';
34
35 const USER_ROLE = 'fluent_cart_customer';
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
107 public static function getUidSerial()
108 {
109 static $id = 0;
110
111 $id = $id + 1;
112
113 return $id;
114
115 }
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
155 public static function getRestInfo()
156 {
157 $app = App::getInstance();
158
159 $ns = $app->config->get('app.rest_namespace');
160 $ver = $app->config->get('app.rest_version');
161
162 return [
163 'base_url' => self::getBaseRestUrl(),
164 'url' => self::getFullRestUrl($ns, $ver),
165 'nonce' => wp_create_nonce('wp_rest'),
166 'namespace' => $ns,
167 'version' => $ver,
168 ];
169 }
170
171
172 /**
173 * Get base rest url by examining the permalink.
174 *
175 * @see https://wordpress.stackexchange.com/questions/273144/can-i-use-rest-api-on-plain-permalink-format
176 *
177 * @return string
178 */
179 protected static function getBaseRestUrl()
180 {
181 if (get_option('permalink_structure')) {
182 return esc_url_raw(rest_url());
183 }
184
185 return esc_url_raw(
186 rtrim(get_site_url(), '/') . "/?rest_route=/"
187 );
188 }
189
190 /**
191 * Get the full rest url by examining the permalink
192 * (full means, including the namespace/version).
193 *
194 * @see https://wordpress.stackexchange.com/questions/273144/can-i-use-rest-api-on-plain-permalink-format
195 *
196 * @return string
197 */
198 protected static function getFullRestUrl($ns, $ver)
199 {
200 if (get_option('permalink_structure')) {
201 return esc_url_raw(rest_url($ns . '/' . $ver));
202 }
203
204 return esc_url_raw(
205 rtrim(get_site_url(), '/') . "/?rest_route=/{$ns}/{$ver}"
206 );
207 }
208
209
210 public static function shopConfig($key = false)
211 {
212 /**
213 * todo - need to review for more improvement : AR
214 */
215 $currencySettings = (fluentCart(CurrencySettings::class))->get();
216 $storeSettings = (new StoreSettings())->get([
217 'store_name', 'store_logo', 'weight_unit', 'dimension_unit'
218 ]);
219 $storeSettings['shipping_packages'] = self::getShippingPackages();
220
221 $settings = array_merge($currencySettings, $storeSettings);
222
223
224 if (!$key) {
225 return $settings;
226 }
227
228 if (is_array($key)) {
229 return Arr::only($settings, $key);
230 } else {
231 return Arr::get($settings, $key);
232 }
233
234 }
235
236 /**
237 * Convert weight between units using grams as base.
238 *
239 * @param float $value
240 * @param string $fromUnit
241 * @param string $toUnit
242 * @return float
243 */
244 public static function convertWeight($value, $fromUnit, $toUnit)
245 {
246 if ($fromUnit === $toUnit || !$value) {
247 return (float)$value;
248 }
249
250 $toGrams = [
251 'g' => 1,
252 'kg' => 1000,
253 'lbs' => 453.592,
254 'oz' => 28.3495,
255 ];
256
257 $fromFactor = isset($toGrams[$fromUnit]) ? $toGrams[$fromUnit] : 1;
258 $toFactor = isset($toGrams[$toUnit]) ? $toGrams[$toUnit] : 1;
259
260 $grams = $value * $fromFactor;
261 return round($grams / $toFactor, 6);
262 }
263
264 /**
265 * Get all shipping packages from store settings.
266 *
267 * @return array
268 */
269 public static function getShippingPackages()
270 {
271 static $packages = null;
272 if ($packages === null) {
273 $packages = fluent_cart_get_option('shipping_packages', []);
274 }
275 return $packages ?: [];
276 }
277
278 /**
279 * Get the default shipping package.
280 *
281 * @return array|null
282 */
283 public static function getDefaultPackage()
284 {
285 $packages = self::getShippingPackages();
286 foreach ($packages as $package) {
287 if (!empty($package['is_default'])) {
288 return $package;
289 }
290 }
291 return null;
292 }
293
294 /**
295 * Find a shipping package by slug.
296 *
297 * @param string $slug
298 * @return array|null
299 */
300 public static function getPackageBySlug($slug)
301 {
302 if (!$slug) {
303 return self::getDefaultPackage();
304 }
305 $packages = self::getShippingPackages();
306 foreach ($packages as $package) {
307 if (isset($package['slug']) && $package['slug'] === $slug) {
308 return $package;
309 }
310 }
311 return self::getDefaultPackage();
312 }
313
314 public static function invoiceSettings($key = false)
315 {
316 $settings = [
317 'invoice_prefix' => 'AS-',
318 ];
319 if (!$key) {
320 return $settings;
321 }
322
323 return Arr::get($settings, $key);
324 }
325
326 public static function getOrderStatuses()
327 {
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 ];
335
336 return apply_filters('fluent_cart/order_statuses', $statuses, []);
337 }
338
339 public static function getEditableOrderStatuses()
340 {
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 ];
348
349 return apply_filters('fluent_cart/editable_order_statuses', $statuses, []);
350 }
351
352 public static function getEditableCustomerStatuses()
353 {
354 $statuses = [
355 'active' => __('Active', 'fluent-cart'),
356 'inactive' => __('Inactive', 'fluent-cart'),
357 ];
358
359 return apply_filters('fluent_cart/editable_customer_statuses', $statuses, []);
360 }
361
362 public static function getShippingStatuses()
363 {
364 $statuses = [
365 'unshipped' => __('Unshipped', 'fluent-cart'),
366 'shipped' => __('Shipped', 'fluent-cart'),
367 'delivered' => __('Delivered', 'fluent-cart'),
368 'unshippable' => __('Unshippable', 'fluent-cart'),
369 ];
370
371 return apply_filters('fluent_cart/shipping_statuses', $statuses, []);
372 }
373
374 public static function getEditableShippingStatuses()
375 {
376 $statuses = [
377 'unshipped' => __('Unshipped', 'fluent-cart'),
378 'shipped' => __('Shipped', 'fluent-cart'),
379 'delivered' => __('Delivered', 'fluent-cart'),
380 'unshippable' => __('Unshippable', 'fluent-cart'),
381 ];
382
383 return apply_filters('fluent_cart/editable_shipping_statuses', $statuses, []);
384 }
385
386 public static function getOrderSuccessStatuses()
387 {
388 return [
389 'completed',
390 // 'archived',
391 'processing',
392 ];
393 }
394
395 public static function getOrderFailedStatuses()
396 {
397 return [
398 'failed',
399 //'refunded',
400 'cancelled',
401 ];
402 }
403
404 public static function getTransactionSuccessStatuses()
405 {
406 return [
407 'paid',
408 ];
409 }
410
411 public static function getTransactionStatuses($withLabel = true)
412 {
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 ];
421
422 $statuses = apply_filters('fluent_cart/transaction_statuses', $statuses, []);
423
424 if ($withLabel) {
425 return $statuses;
426 }
427
428 return array_keys($statuses);
429 }
430
431 public static function getEditableTransactionStatuses($withLabel = true)
432 {
433 $statuses = [
434 'pending' => __('Pending', 'fluent-cart'),
435 'paid' => __('Paid', 'fluent-cart'),
436 'failed' => __('Failed', 'fluent-cart'),
437 'refunded' => __('Refunded', 'fluent-cart'),
438 ];
439
440 $statuses = apply_filters('fluent_cart/editable_transaction_statuses', $statuses, []);
441
442 if ($withLabel) {
443 return $statuses;
444 }
445
446 return array_keys($statuses);
447 }
448
449 public static function productStatuses($withLabel = true): array
450 {
451 $statues = [
452 'publish' => __('Publish', 'fluent-cart'),
453 'draft' => __('Draft', 'fluent-cart'),
454 'future' => __('Scheduled', 'fluent-cart'),
455 'private' => __('Private', 'fluent-cart'),
456 'trash' => __('Trashed', 'fluent-cart'),
457 ];
458
459 if ($withLabel) {
460 return $statues;
461 }
462
463 return array_keys($statues);
464 }
465
466 public static function productAdminAllStatuses()
467 {
468 $statuses = self::productStatuses();
469 unset($statuses['trash']);
470 return array_keys($statuses);
471 }
472
473 public static function getCartDriver()
474 {
475 return 'db';
476 }
477
478 /**
479 * Convert an amount to a formatted decimal string.
480 *
481 * @param float $amount The amount to convert. (required)
482 * @param bool $withCurrency Whether to include the currency symbol. (optional, default: true)
483 * @param string|null $currencyCode The currency code to use. (optional, default: null)
484 * @param bool $formatted Whether to format the amount. (optional, default: true)
485 * @param bool $showDecimals Whether to show decimal places. (optional, default: true)
486 * @param bool $thousand_separator Whether to include thousand separators. (optional, default: true)
487 *
488 * @return string The formatted amount.
489 *
490 * @dev Note: If you don't want the thousand separator to be applied,
491 * you need to set $formatted to true and $thousand_separator to false.
492 *
493 * @hook fluent_cart/hide_unnecessary_decimals - Filter to control whether unnecessary decimals (like .00) should be hidden.
494 * Usage: add_filter('fluent_cart/hide_unnecessary_decimals', '__return_true'); // This will show 10 instead of 10.00
495 */
496 public static function toDecimal($amount, $withCurrency = true, $currencyCode = null, $formatted = true, $showDecimals = true, $thousand_separator = true, $withTranslatedDigit = true)
497 {
498 if (!is_numeric($amount)) {
499 return $amount;
500 }
501
502 // Set default decimal places to 2
503 $decimal = 2;
504
505 // Check if the shop is using a zero-decimal currency
506 if (self::shopConfig('is_zero_decimal')) {
507 $decimal = 0;
508 }
509
510 // Use provided or default currency code
511 if (!$currencyCode) {
512 $currencyCode = self::shopConfig('currency');
513 }
514
515 // Get currency sign
516 $sign = CurrenciesHelper::getCurrencySign($currencyCode);
517
518 $amount = floatVal($amount / 100);
519
520 // If $showDecimals is false, we override decimal places to 0
521 if (!$showDecimals) {
522 $decimal = 0;
523 }
524
525 // Format the amount based on the decimal configuration
526 if ($formatted) {
527 $decimal_separator = self::shopConfig('decimal_separator') === 'comma' ? ',' : '.';
528
529 $thousand_separator = $decimal_separator === ',' ? '.' : ',';
530
531 // Check if we should hide unnecessary decimal places (e.g., 10.00 -> 10)
532 $hideUnnecessaryDecimals = apply_filters('fluent_cart/hide_unnecessary_decimals', false, [
533 'amount' => $amount,
534 'decimal' => $decimal
535 ]);
536
537 $amount = number_format(
538 $amount,
539 $decimal,
540 $decimal_separator,
541 $thousand_separator
542 );
543
544 if ($hideUnnecessaryDecimals && $decimal > 0) {
545 // Remove trailing zeros only from the decimal portion
546 $parts = explode($decimal_separator, $amount);
547 if (count($parts) === 2) {
548 $parts[1] = rtrim($parts[1], '0');
549 if ($parts[1] === '') {
550 $amount = $parts[0];
551 } else {
552 $amount = $parts[0] . $decimal_separator . $parts[1];
553 }
554 }
555 }
556
557 if ($withTranslatedDigit) {
558 $amount = self::translateNumber($amount);
559 }
560 }
561
562 // If $withCurrency is false, just return the formatted amount
563 if (!$withCurrency) {
564 return $amount;
565 }
566
567 // Get currency position and return formatted amount with currency
568 $position = self::shopConfig('currency_position');
569
570 switch ($position) {
571 case 'before':
572 return $sign . $amount;
573 case 'after':
574 return $amount . $sign;
575 case 'iso_before':
576 return $currencyCode . ' ' . $amount;
577 case 'iso_after':
578 return $amount . ' ' . $currencyCode;
579 case 'symbool_before_iso':
580 return $sign . $amount . ' ' . $currencyCode;
581 case 'symbool_after_iso':
582 return $currencyCode . ' ' . $amount . $sign;
583 case 'symbool_and_iso':
584 return $currencyCode . ' ' . $sign . $amount;
585 default:
586 return $sign . $amount;
587 }
588 }
589
590 public static function toCent($amount): int
591 {
592 if (!is_numeric($amount)) {
593 return 0;
594 }
595
596 $amount = floatval($amount) * 100; // Convert to float and multiply
597 $amount = (int)round($amount); // Round to nearest integer, then cast
598 return $amount;
599 }
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
634 public static function toDecimalWithoutComma($amount)
635 {
636
637 if (!is_numeric($amount)) {
638 return 0;
639 }
640
641 // Convert to float and divide by 100
642 $result = floatval($amount) / 100;
643
644 // Ensure exactly two decimal places
645 return round($result, 2);
646 }
647
648 public static function getCustomerByUser($user)
649 {
650 if (is_numeric($user)) {
651 $user = get_user_by('ID', $user);
652 }
653
654 if (!$user) {
655 return null;
656 }
657
658 // Identity only — see CustomerResource::getCurrentCustomer() for why an
659 // email match must never reach a customer row.
660 return Customer::query()->where('user_id', $user->ID)
661 ->orderBy('id', 'ASC')
662 ->first();
663 }
664
665 /**
666 * @param array $order_data
667 *
668 * @return array return ['billing_address','shipping_address','others'];
669 */
670
671 /**
672 *
673 * @return string
674 */
675 public static function getProductImageBaseUri(): string
676 {
677 $uploads = wp_upload_dir();
678
679 return $uploads['baseurl'] . '/' . FLUENTCART_UPLOAD_DIR . '/product_image/';
680 }
681
682 public static function getProductImageBaseDir()
683 {
684 $uploads = wp_upload_dir();
685
686 return $uploads['basedir'] . '/' . FLUENTCART_UPLOAD_DIR . '/product_image/';
687 }
688
689 public static function getAvailableCurrencyList()
690 {
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 ];
708
709 return apply_filters('fluent_cart/available_currencies', $currencies, []);
710 }
711
712 public static function getSymbolForCurrency($currency = 'BDT')
713 {
714
715 $symbol = '৳';
716 $list = self::getAvailableCurrencyList();
717
718 return $list[$currency]['symbol'] ?? $symbol;
719 }
720
721 public function getConfirmationSettings()
722 {
723 return (new Confirmation())->get();
724 }
725
726
727 /**
728 *
729 * @param $remove
730 * @return string
731 */
732 public static function getCheckoutPageLinkAfterRemovingGetParams($remove = [])
733 {
734
735 global $fct_store;
736
737 $link = Arr::get($fct_store, 'checkout_link');
738
739 $link .= '?';
740
741 if (!empty($remove)) {
742 $params = App::request()->all();
743
744 foreach ($params as $key => $val) {
745
746 if (!in_array($key, $remove)) {
747
748 $link .= $key . '=' . $val . '&';
749 }
750 }
751 }
752
753 return rtrim($link, '&');
754 }
755
756
757 /**
758 *
759 * @return bool
760 */
761 public static function isSingleProductPage(): bool
762 {
763 return is_singular([FluentProducts::CPT_NAME]);
764 }
765
766
767 public static function isTrue($array, $key)
768 {
769 $value = $array[$key] ?? false;
770 if (is_bool($value)) {
771 return $value;
772 }
773 if ($value === 'false' || !$value) {
774 return false;
775 }
776 return true;
777 }
778
779 public static function is_valid_json($string): bool
780 {
781 if (!is_string($string)) {
782 return false;
783 }
784
785 $trimmed = trim($string);
786
787 // Basic check: must start with { or [ and end with } or ]
788 if (!preg_match('/^(\{.*\}|\[.*\])$/s', $trimmed)) {
789 return false;
790 }
791
792 json_decode($trimmed);
793 return json_last_error() === JSON_ERROR_NONE;
794 }
795
796 public static function getStockStatuses($withLabel = true)
797 {
798 $statues = [
799 'in-stock' => __('In Stock', 'fluent-cart'),
800 'out-of-stock' => __('Out Of Stock', 'fluent-cart'),
801 ];
802
803 if ($withLabel) {
804 return $statues;
805 }
806
807 return array_keys($statues);
808 }
809
810 public static function getFulfilmentTypes($withLabel = true)
811 {
812 $statues = [
813 'physical' => __('Physical', 'fluent-cart'),
814 'digital' => __('Digital', 'fluent-cart'),
815 ];
816
817 if ($withLabel) {
818 return $statues;
819 }
820
821 return array_keys($statues);
822 }
823
824 public static function getVariationTypes($withLabel = true)
825 {
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'),
834 ];
835
836 $types = apply_filters('fluent_cart/variation_types', $types);
837
838 if ($withLabel) {
839 return $types;
840 }
841
842 return array_keys($types);
843 }
844
845 public static function isValueEncrypted($raw_value)
846 {
847 if (!$raw_value || !is_string($raw_value) || !extension_loaded('openssl')) {
848 return false;
849 }
850
851 // Check if input is valid base64
852 if (!preg_match('/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $raw_value)) {
853 return false;
854 }
855
856 $decoded = base64_decode($raw_value, true);
857 if ($decoded === false) {
858 return false;
859 }
860
861 // Check if decoded string is long enough for IV
862 $method = 'aes-256-ctr';
863 $ivlen = openssl_cipher_iv_length($method);
864 if (strlen($decoded) < $ivlen) {
865 return false;
866 }
867
868 $iv = substr($decoded, 0, $ivlen);
869 $ciphertext = substr($decoded, $ivlen);
870
871 $key = (defined('FLUENT_CART_ENCRYPTION_KEY'))
872 ? FLUENT_CART_ENCRYPTION_KEY
873 : ((defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY)
874 ? LOGGED_IN_KEY
875 : 'this-is-a-fallback-key-but-not-secure');
876 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT)
877 ? LOGGED_IN_SALT
878 : 'this-is-a-fallback-salt-but-not-secure';
879
880 $value = openssl_decrypt($ciphertext, $method, $key, 0, $iv);
881 if ($value === false) {
882 return false;
883 }
884
885
886 return substr($value, -strlen($salt)) === $salt;
887 }
888
889 public static function encryptKey($value)
890 {
891 if (!$value) {
892 return $value;
893 }
894
895 if (!extension_loaded('openssl')) {
896 return $value;
897 }
898
899 if (self::isValueEncrypted($value)) {
900 return $value;
901 }
902
903 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
904
905 if (defined('FLUENT_CART_ENCRYPTION_KEY')) {
906 $key = FLUENT_CART_ENCRYPTION_KEY;
907 } else {
908 $key = (defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure';
909 }
910
911 $method = 'aes-256-ctr';
912 $ivlen = openssl_cipher_iv_length($method);
913 $iv = openssl_random_pseudo_bytes($ivlen);
914
915 $raw_value = openssl_encrypt($value . $salt, $method, $key, 0, $iv);
916 if (!$raw_value) {
917 return false;
918 }
919
920 return base64_encode($iv . $raw_value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
921 }
922
923 public static function decryptKey($raw_value)
924 {
925
926 if (!$raw_value) {
927 return $raw_value;
928 }
929
930 if (!extension_loaded('openssl')) {
931 return $raw_value;
932 }
933
934 $decoded = base64_decode($raw_value, true); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
935
936 if ($decoded === false) {
937 return $raw_value;
938 }
939
940 $method = 'aes-256-ctr';
941 $ivlen = openssl_cipher_iv_length($method);
942
943 if (strlen($decoded) <= $ivlen) {
944 return $raw_value;
945 }
946
947 $iv = substr($decoded, 0, $ivlen);
948
949 $ciphertext = substr($decoded, $ivlen);
950
951 if (defined('FLUENT_CART_ENCRYPTION_KEY')) {
952 $key = FLUENT_CART_ENCRYPTION_KEY;
953 } else {
954 $key = (defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure';
955 }
956
957 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
958
959 $decrypted = openssl_decrypt($ciphertext, $method, $key, 0, $iv);
960 if (!$decrypted || substr($decrypted, -strlen($salt)) !== $salt) {
961 return false;
962 }
963
964 return substr($decrypted, 0, -strlen($salt));
965 }
966
967 /**
968 * @return array
969 * For kses_post to allow some html tags
970 */
971 public static function allowedHTMLForCheckout(): array
972 {
973
974 $allowedTags = [
975 'svg' => [
976 'class' => true,
977 'aria-hidden' => true,
978 'aria-labelledby' => true,
979 'role' => true,
980 'xmlns' => true,
981 'width' => true,
982 'height' => true,
983 'viewbox' => true,
984 'fill' => true
985 ],
986 'g' => [
987 'fill' => true
988 ],
989 'title' => ['title' => true],
990 'path' => [
991 'd' => true,
992 'fill' => true,
993 'stroke' => true,
994 'fill-rule' => true,
995 'clip-rule' => true,
996 ],
997 ];
998
999 foreach (['input', 'label', 'div', 'span', 'p', 'select', 'option', 'textarea', 'button', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $tag) {
1000 $allowedTags[$tag] = [
1001 'selected',
1002 'type' => [],
1003 "selected='selected'" => [],
1004 'name' => [],
1005 'value' => [],
1006 'autocomplete' => [],
1007 'placeholder' => [],
1008 'data-required' => [],
1009 'data-type' => [],
1010 'id' => [],
1011 'class' => [],
1012 'required' => [],
1013 'disabled' => [],
1014 'for' => [],
1015 'style' => [],
1016 'checked' => [],
1017 'maxlength' => [],
1018 'data-id' => [],
1019 'data-country' => [],
1020 'data-fluent-cart-checkout-page-form-address-modal-open-button' => [],
1021 'data-fluent-cart-checkout-page-form-address-modal-wrapper' => [],
1022 'data-fluent-cart-checkout-page-form-input-wrapper' => [],
1023 'data-fluent_cart_checkout_error' => [],
1024 'data-fluent-cart-checkout-page-form-address-modal-body' => [],
1025 'data-fluent-cart-checkout-page-form-address-modal-address-selector-button' => [],
1026 'data-fluent-cart-checkout-page-form-address-input' => [],
1027 'data-fluent-cart-checkout-page-form-address-select-wrapper' => [],
1028 'data-fluent-cart-checkout-page-form-address-modal-close-button' => [],
1029 'data-fluent-cart-checkout-page-form-address-modal-address-selector-button-wrapper' => [],
1030 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-button' => [],
1031 'data-fluent-cart-checkout-page-form-address-modal-apply-button' => [],
1032 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-form-wrapper' => [],
1033 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-submit-button' => [],
1034 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-cancel-button' => [],
1035 'data-fluent-cart-address-type' => [],
1036 'data-fluent-cart-checkout-page-form-address-info-wrapper' => [],
1037 'data-fluent-cart-checkout-page-form-error' => [],
1038 'data-fluent-cart-checkout-page-form-section' => [],
1039 'data-fluent-cart-checkout-page-discount-container' => [],
1040 'data-fluent-cart-checkout-page-final-amount-container' => [],
1041 'data-fluent-cart-checkout-page-new-total-amount' => [],
1042 'data-fluent-cart-checkout-page-final-amount' => [],
1043 'data-fluent-cart-checkout-page-coupon-validate' => [],
1044 'data-fluent-cart-checkout-coupon-items-toggle' => [],
1045 'data-fluent-cart-checkout-coupon-items-wrapper' => []
1046
1047
1048 ];
1049 }
1050
1051 return $allowedTags;
1052 }
1053
1054 public static function getCouponStatuses()
1055 {
1056 $statuses = [
1057 'active' => __('Active', 'fluent-cart'),
1058 'expired' => __('Expired', 'fluent-cart'),
1059 'disabled' => __('Disabled', 'fluent-cart'),
1060 ];
1061
1062 return apply_filters('fluent_cart/coupon_statuses', $statuses, []);
1063 }
1064
1065 public static function getCouponSuccessStatuses()
1066 {
1067 return [
1068 'active'
1069 ];
1070 }
1071
1072
1073 /**
1074 * Compact subscription terms as text
1075 *
1076 * Expected $data keys:
1077 * - trial_days (int)
1078 * - interval ('daily'|'weekly'|'monthly'|'yearly')
1079 * - interval_count (int, default 1)
1080 * - times (int; 0 = open-ended, >0 = finite number of payments/cycles)
1081 * - price (string; formatted with currency)
1082 * - signup_fee (string; 0 if none)
1083 * - compare_price (string; 0 if none)
1084 *
1085 * Output examples:
1086 * - "30 days free then $100.00 per year"
1087 * - "$100 per year + $10 one-time signup fee"
1088 * - "$100/month for 4 months"
1089 * - "30 days free then $100/month for 4 months"
1090 * - "$99 per month"
1091 */
1092 public static function getSubscriptionTermText(array $data, $asHtml = false): string
1093 {
1094 // Fixed interval options
1095 $intervalOptions = static::getAvailableSubscriptionIntervalMaps();
1096
1097 // Normalize / defaults
1098 $trialDays = $data['trial_days'] ?? 0;
1099 $interval = (string)($data['interval'] ? $data['interval'] : 'monthly');
1100
1101 $unit = '';
1102 if (isset($intervalOptions[$interval])) {
1103 $unit = $intervalOptions[$interval];
1104 } else if ($interval) {
1105 $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1106 foreach ($intervalOptions as $option) {
1107 if ($option['value'] === $interval) {
1108 $unit = strtolower($option['label']);
1109 break;
1110 }
1111 }
1112
1113 if (!$unit) {
1114 $unit = strtolower(str_replace(['_', '-'], ' ', $interval));
1115 }
1116 }
1117
1118 if (!$unit) {
1119 $unit = 'year';
1120 }
1121
1122 $count = max(1, (int)($data['interval_count'] ?? 1));
1123 $times = (int)($data['times'] ?? 0);
1124 $price = (string)($data['price'] ?? '');
1125 $signupFee = $data['signup_fee'] ?? '';
1126 $compare = $data['compare_price'] ?? '';
1127
1128 $signupFeeLabel = $data['signup_fee_label'] ?? __('signup fee', 'fluent-cart');
1129
1130 // helpers
1131 $pluralUnit = static function (string $unit, int $n): string {
1132 switch ($unit) {
1133 case 'day':
1134 return _n('day', 'days', $n, 'fluent-cart');
1135 case 'week':
1136 return _n('week', 'weeks', $n, 'fluent-cart');
1137 case 'quarter':
1138 return _n('quarter', 'quarters', $n, 'fluent-cart');
1139 case 'half_year':
1140 return _n('six month', 'six months', $n, 'fluent-cart');
1141 case 'year':
1142 return _n('year', 'years', $n, 'fluent-cart');
1143 case 'month':
1144 return _n('month', 'months', $n, 'fluent-cart');
1145 default:
1146 // For custom intervals, add 's' for plural
1147 return ($n > 1) ? $unit . 's' : $unit;
1148 }
1149 };
1150
1151 // Build the “per …” phrase (e.g., "per month", "per 3 months")
1152 $perPhrase = ($count === 1)
1153 ? sprintf(
1154 /* translators: %s is the singular unit name (e.g., day, month, year) */
1155 __('per %s', 'fluent-cart'),
1156 $pluralUnit($unit, 1)
1157 )
1158 : sprintf(
1159 /* translators: %1$d is the count number, %2$s is the plural unit name (e.g., days, months, years) */
1160 __('per %1$d %2$s', 'fluent-cart'),
1161 $count,
1162 $pluralUnit($unit, $count)
1163 );
1164
1165 // Main price phrase:
1166 // - If monthly installments (times > 0 and unit == month and count == 1): "$X/month for N months"
1167 // - Else if times > 0: "$X per {count unit(s)} for N {cycle(s)}"
1168 // - Else: "$X per {count unit(s)}"
1169 if ($times > 0) {
1170 if ($unit === 'month' && $count === 1) {
1171 $installmentTail = sprintf(
1172 /* translators: %s: pluralized 'month(s)' */
1173 __('for %1$d %2$s', 'fluent-cart'),
1174 $times,
1175 $pluralUnit('month', $times)
1176 );
1177 $main = sprintf(
1178 /* translators: Compact monthly installment, e.g. "$100/month for 4 months" */
1179 __('%1$s/%2$s %3$s', 'fluent-cart'),
1180 $price,
1181 __('month', 'fluent-cart'),
1182 $installmentTail
1183 );
1184 } else {
1185 // Generic installment wording: "for N cycle(s)"
1186 $cyclesTail = sprintf(
1187 /* translators: %1$d is the number of cycles, %2$s is "cycle" or "cycles" */
1188 __('for %1$d %2$s', 'fluent-cart'),
1189 $times,
1190 _n('cycle', 'cycles', $times, 'fluent-cart')
1191 );
1192 $main = sprintf(
1193 /* translators: e.g. "$100 per 3 months for 4 cycles" */
1194 __('%1$s %2$s %3$s', 'fluent-cart'),
1195 $price,
1196 $perPhrase,
1197 $cyclesTail
1198 );
1199 }
1200 } else {
1201 // Open-ended subscription
1202 $main = sprintf(
1203 /* translators: e.g. "$99 per month" */
1204 __('%1$s %2$s', 'fluent-cart'),
1205 $price,
1206 $perPhrase
1207 );
1208 }
1209
1210 // Prefix trial if present: "N days free then …"
1211 if ($trialDays > 0) {
1212 $trialFrag = sprintf(
1213 /* translators: "30 days free then" */
1214 __('%1$d %2$s free then', 'fluent-cart'),
1215 $trialDays,
1216 $pluralUnit('day', $trialDays)
1217 );
1218 $main = $trialFrag . ' ' . $main;
1219 }
1220
1221 // Append signup fee if any: " + $10 one-time signup fee"
1222 if ($signupFee) {
1223 $main .= ' ' . sprintf(
1224 /* translators: e.g. "+ $10 one-time signup fee" */
1225 __('+ %1$s one-time %2$s', 'fluent-cart'),
1226 $signupFee,
1227 $signupFeeLabel
1228 );
1229 }
1230
1231 // No trailing period (matches your examples)
1232 return $main;
1233 }
1234
1235 public static function getTranslatedIntervalUnit(string $unit): string
1236 {
1237 switch ($unit) {
1238 case 'day':
1239 return __('day', 'fluent-cart');
1240 case 'week':
1241 return __('week', 'fluent-cart');
1242 case 'month':
1243 return __('month', 'fluent-cart');
1244 case 'quarter':
1245 return __('quarter', 'fluent-cart');
1246 case 'half_year':
1247 return __('six month', 'fluent-cart');
1248 case 'year':
1249 return __('year', 'fluent-cart');
1250 default:
1251 return $unit;
1252 }
1253 }
1254
1255 public static function generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode = null): ?string
1256 {
1257 // Convert to array only if it's an object
1258 if (is_object($otherInfo)) {
1259 $otherInfo = json_decode(json_encode($otherInfo), true);
1260 }
1261
1262 $price = self::toDecimal($itemPrice, true, $currencyCode);
1263 $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1264
1265 if ($recurringDiscountAmount) {
1266 $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1267 $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1268 }
1269
1270 $repeatInterval = Arr::get($otherInfo, 'repeat_interval', '');
1271 $occurrence = (int)Arr::get($otherInfo, 'times', 0);
1272
1273 $intervalOptions = static::getAvailableSubscriptionIntervalMaps();
1274
1275 $intervalUnit = '';
1276 if (isset($intervalOptions[$repeatInterval])) {
1277 $intervalUnit = $intervalOptions[$repeatInterval];
1278 } else if ($repeatInterval) {
1279 $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1280 foreach ($intervalOptions as $option) {
1281 if ($option['value'] === $repeatInterval) {
1282 $intervalUnit = strtolower($option['label']);
1283 break;
1284 }
1285 }
1286
1287 if (!$intervalUnit) {
1288 $intervalUnit = ucwords(str_replace(['_', '-'], ' ', $repeatInterval));
1289 }
1290 }
1291
1292 $intervalLabel = Helper::getTranslatedIntervalUnit($intervalUnit);
1293
1294 $interval = $intervalUnit
1295 ? sprintf(
1296 /* translators: %s is the interval (e.g., day, week, month, quarter, half_year, year) */
1297 __('per %s', 'fluent-cart'),
1298 $intervalLabel
1299 )
1300 : '';
1301
1302 $time = $intervalUnit
1303 ? $intervalLabel
1304 : '';
1305
1306 $paymentInfo = sprintf(
1307 /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text, %4$s is the occurrence count, %5$s is the time period */
1308 __('%1$s %2$s, for %3$s %4$s', 'fluent-cart'),
1309 $price,
1310 $interval,
1311 $occurrence,
1312 $time
1313 );
1314
1315 if (empty($occurrence)) {
1316 $paymentInfo = sprintf(
1317 /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text */
1318 __('%1$s %2$s %3$s', 'fluent-cart'),
1319 $price,
1320 $interval,
1321 __('until cancel', 'fluent-cart')
1322 );
1323 }
1324
1325 return !empty($otherInfo) ? $paymentInfo : null;
1326 }
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
1470 public static function generateSetupFeeInfo($otherInfo, $asArray = false)
1471 {
1472 // Convert to array if it's an object
1473 if (is_object($otherInfo)) {
1474 $otherInfo = json_decode(json_encode($otherInfo), true);
1475 }
1476
1477 $signupFeeName = Arr::get($otherInfo, 'signup_fee_name', __('Setup Fee', 'fluent-cart'));
1478 $fee = Arr::get($otherInfo, 'signup_fee', 0);
1479 $manageSetupFee = Arr::get($otherInfo, 'manage_setup_fee', 'no');
1480
1481 if ($manageSetupFee !== 'yes' || !$fee) {
1482 return '';
1483 }
1484
1485
1486 if ($originalSetupFee = Arr::get($otherInfo, 'original_signup_fee', 0)) {
1487 if ($fee != $originalSetupFee) {
1488 $title = __('Adjusted setup fee', 'fluent-cart');
1489 $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1490
1491 if ($asArray) {
1492 return [
1493 'signup_fee_name' => $title,
1494 'signup_fee' => $fee,
1495 'signup_fee_formatted' => $formattedAmount,
1496 ];
1497 }
1498 return $title . $formattedAmount;
1499 }
1500 }
1501
1502 $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1503 if ($asArray) {
1504 return [
1505 'signup_fee_name' => $signupFeeName,
1506 'signup_fee' => $fee,
1507 'signup_fee_formatted' => $formattedAmount,
1508 ];
1509 }
1510
1511
1512 return $signupFeeName . ' ' . $formattedAmount;
1513 }
1514
1515 public static function generateTrialInfo($otherInfo)
1516 {
1517 $trialInfo = '';
1518
1519 $trialDays = Arr::get($otherInfo, 'trial_days', 0);
1520
1521 if ($trialDays && Arr::get($otherInfo, 'is_trial_days_simulated', 'no') !== 'yes') {
1522 $trialInfo = sprintf(
1523 /* translators: %d is the number of trial days */
1524 __('Free Trial: %d days', 'fluent-cart'),
1525 $trialDays
1526 );
1527 }
1528
1529 return apply_filters('fluent_cart/trial_info', $trialInfo, $otherInfo);
1530 }
1531
1532
1533 public static function getCountryList(): array
1534 {
1535 $options = App::getInstance('localization')->countriesOptions();
1536
1537 return apply_filters('fluent_cart/util/countries', $options, []);
1538 }
1539
1540 public static function getCountyIsoLists(): array
1541 {
1542 return App::getInstance('localization')->getCountyIsoLists();
1543 }
1544
1545 public static function getCountryCode($country_name)
1546 {
1547 $countries = self::getCountryList();
1548 foreach ($countries as $country) {
1549 if ($country['name'] === $country_name) {
1550 return $country['value'];
1551 }
1552 }
1553 return '';
1554 }
1555
1556 /**
1557 * Get the country's name with country code,
1558 *
1559 * @param $code
1560 * @return string
1561 */
1562 public static function getCountryName($code): string
1563 {
1564 if (!$code || !is_string($code)) {
1565 return '';
1566 }
1567
1568 $countries = self::getCountyIsoLists();
1569
1570 return $countries[$code] ?? $code;
1571 }
1572
1573 public static function languageCodes(): array
1574 {
1575 return $langCodes = [
1576 'AF' => 'fa-AF',
1577 'AL' => 'sq-AL',
1578 'DZ' => 'ar-DZ',
1579 'AS' => 'sm-AS',
1580 'AD' => 'ca-AD',
1581 'AO' => 'pt-AO',
1582 'AR' => 'es-AR',
1583 'AM' => 'hy-AM',
1584 'AU' => 'en-AU',
1585 'AT' => 'de-AT',
1586 'AZ' => 'az-AZ',
1587 'BH' => 'ar-BH',
1588 'BD' => 'bn-BD',
1589 'BY' => 'be-BY',
1590 'BE' => 'nl-BE',
1591 'BZ' => 'en-BZ',
1592 'BJ' => 'fr-BJ',
1593 'BT' => 'dz-BT',
1594 'BO' => 'es-BO',
1595 'BA' => 'bs-BA',
1596 'BW' => 'en-BW',
1597 'BR' => 'pt-BR',
1598 'BN' => 'ms-BN',
1599 'BG' => 'bg-BG',
1600 'BF' => 'fr-BF',
1601 'BI' => 'fr-BI',
1602 'KH' => 'km-KH',
1603 'CM' => 'en-CM',
1604 'CA' => 'en-CA',
1605 'CV' => 'pt-CV',
1606 'CF' => 'fr-CF',
1607 'TD' => 'fr-TD',
1608 'CL' => 'es-CL',
1609 'CN' => 'zh-CN',
1610 'CO' => 'es-CO',
1611 'KM' => 'ar-KM',
1612 'CD' => 'fr-CD',
1613 'CG' => 'fr-CG',
1614 'CR' => 'es-CR',
1615 'CI' => 'fr-CI',
1616 'HR' => 'hr-HR',
1617 'CU' => 'es-CU',
1618 'CY' => 'el-CY',
1619 'CZ' => 'cs-CZ',
1620 'DK' => 'da-DK',
1621 'DJ' => 'fr-DJ',
1622 'DM' => 'en-DM',
1623 'DO' => 'es-DO',
1624 'EC' => 'es-EC',
1625 'EG' => 'ar-EG',
1626 'SV' => 'es-SV',
1627 'GQ' => 'es-GQ',
1628 'ER' => 'ti-ER',
1629 'EE' => 'et-EE',
1630 'ET' => 'am-ET',
1631 'FJ' => 'en-FJ',
1632 'FI' => 'fi-FI',
1633 'FR' => 'fr-FR',
1634 'GA' => 'fr-GA',
1635 'GM' => 'en-GM',
1636 'GE' => 'ka-GE',
1637 'DE' => 'de-DE',
1638 'GH' => 'en-GH',
1639 'GR' => 'el-GR',
1640 'GD' => 'en-GD',
1641 'GT' => 'es-GT',
1642 'GN' => 'fr-GN',
1643 'GW' => 'pt-GW',
1644 'GY' => 'en-GY',
1645 'HT' => 'fr-HT',
1646 'HN' => 'es-HN',
1647 'HU' => 'hu-HU',
1648 'IS' => 'is-IS',
1649 'IN' => 'hi-IN',
1650 'ID' => 'id-ID',
1651 'IR' => 'fa-IR',
1652 'IQ' => 'ar-IQ',
1653 'IE' => 'en-IE',
1654 'IL' => 'he-IL',
1655 'IT' => 'it-IT',
1656 'JM' => 'en-JM',
1657 'JP' => 'ja-JP',
1658 'JO' => 'ar-JO',
1659 'KZ' => 'kk-KZ',
1660 'KE' => 'sw-KE',
1661 'KI' => 'en-KI',
1662 'KR' => 'ko-KR',
1663 'KW' => 'ar-KW',
1664 'KG' => 'ky-KG',
1665 'LA' => 'lo-LA',
1666 'LV' => 'lv-LV',
1667 'LB' => 'ar-LB',
1668 'LS' => 'en-LS',
1669 'LR' => 'en-LR',
1670 'LY' => 'ar-LY',
1671 'LI' => 'de-LI',
1672 'LT' => 'lt-LT',
1673 'LU' => 'lb-LU',
1674 'MG' => 'mg-MG',
1675 'MW' => 'en-MW',
1676 'MY' => 'ms-MY',
1677 'MV' => 'dv-MV',
1678 'ML' => 'fr-ML',
1679 'MT' => 'mt-MT',
1680 'MH' => 'mh-MH',
1681 'MR' => 'ar-MR',
1682 'MU' => 'mfe-MU',
1683 'MX' => 'es-MX',
1684 'FM' => 'en-FM',
1685 'MD' => 'ro-MD',
1686 'MC' => 'fr-MC',
1687 'MN' => 'mn-MN',
1688 'ME' => 'sr-ME',
1689 'MA' => 'ar-MA',
1690 'MZ' => 'pt-MZ',
1691 'NA' => 'en-NA',
1692 'NR' => 'en-NR',
1693 'NP' => 'ne-NP',
1694 'NL' => 'nl-NL',
1695 'NZ' => 'en-NZ',
1696 'NI' => 'es-NI',
1697 'NG' => 'en-NG',
1698 'NO' => 'no-NO',
1699 'OM' => 'ar-OM',
1700 'PK' => 'ur-PK',
1701 'PW' => 'en-PW',
1702 'PA' => 'es-PA',
1703 'PG' => 'en-PG',
1704 'PY' => 'es-PY',
1705 'PE' => 'es-PE',
1706 'PH' => 'en-PH',
1707 'PL' => 'pl-PL',
1708 'PT' => 'pt-PT',
1709 'QA' => 'ar-QA',
1710 'RO' => 'ro-RO',
1711 'RU' => 'ru-RU',
1712 'RW' => 'rw-RW',
1713 'WS' => 'sm-WS',
1714 'SM' => 'it-SM',
1715 'SA' => 'ar-SA',
1716 'SN' => 'fr-SN',
1717 'RS' => 'sr-RS',
1718 'SC' => 'fr-SC',
1719 'SL' => 'en-SL',
1720 'SG' => 'en-SG',
1721 'SK' => 'sk-SK',
1722 'SI' => 'sl-SI',
1723 'SB' => 'en-SB',
1724 'SO' => 'so-SO',
1725 'ZA' => 'en-ZA',
1726 'ES' => 'es-ES',
1727 'LK' => 'si-LK',
1728 'SD' => 'ar-SD',
1729 'SR' => 'nl-SR',
1730 'SZ' => 'en-SZ',
1731 'SE' => 'sv-SE',
1732 'CH' => 'de-CH',
1733 'SY' => 'ar-SY',
1734 'TW' => 'zh-TW',
1735 'TJ' => 'tg-TJ',
1736 'TZ' => 'sw-TZ',
1737 'TH' => 'th-TH',
1738 'TL' => 'pt-TL',
1739 'TG' => 'fr-TG',
1740 'TO' => 'to-TO',
1741 'TT' => 'en-TT',
1742 'TN' => 'ar-TN',
1743 'TR' => 'tr-TR',
1744 'TM' => 'tk-TM',
1745 'TV' => 'en-TV',
1746 'UG' => 'en-UG',
1747 'UA' => 'uk-UA',
1748 'AE' => 'ar-AE',
1749 'GB' => 'en-GB',
1750 'US' => 'en-US',
1751 'UY' => 'es-UY',
1752 'UZ' => 'uz-UZ',
1753 'VU' => 'bi-VU',
1754 'VE' => 'es-VE',
1755 'VN' => 'vi-VN',
1756 'YE' => 'ar-YE',
1757 'ZM' => 'en-ZM',
1758 'ZW' => 'en-ZW'
1759 ];
1760 }
1761
1762 /**
1763 * Returns a translatable string with a shortcode inserted in the correct format.
1764 *
1765 * @param string $shortcode The shortcode to be inserted (e.g., '[fluent_cart_receipt]').
1766 * @return string The formatted translatable string.
1767 */
1768 public static function getShortcodeInstructionString(string $shortcode, $pageName = ''): string
1769 {
1770 $copyToClipboard = __('Copy to clipboard', 'fluent-cart');
1771 return sprintf(
1772 /* translators: %s: Shortcode */
1773 '<p>' . _x('Use %1$s shortcode in your page.', 'Shortcode instruction message', 'fluent-cart') . '</p>',
1774 '<code class="copyable-content" title="' . $copyToClipboard . '">' . ($shortcode) . '</code>',
1775 //$pageName
1776 );
1777 }
1778
1779
1780 /**
1781 * Get the current user Model.
1782 * @return User|\FluentCart\Framework\Database\Orm\Builder|\FluentCart\Framework\Database\Orm\Builder[]|\FluentCart\Framework\Database\Orm\Collection|\FluentCart\Framework\Database\Orm\Model|null
1783 */
1784 public static function getCurrentUser($refresh = false)
1785 {
1786 static $user = false;
1787
1788 if (!$refresh && $user !== false) {
1789 return $user;
1790 }
1791
1792 $userId = get_current_user_id();
1793 if (!$userId) {
1794 $user = null;
1795 return $user;
1796 }
1797
1798 $user = User::query()->find($userId);
1799
1800 return $user;
1801
1802 }
1803
1804 public static function hasLicense($product): bool
1805 {
1806 if (empty($product)) {
1807 return false;
1808 }
1809
1810 $meta = Arr::get($product, 'licensesMeta.meta_value', []);
1811
1812 if (empty($meta)) {
1813 return false;
1814 }
1815
1816 $meta = is_string($meta) ? json_decode($meta, true) : $meta;
1817
1818 return Arr::get($meta, 'enabled') === 'yes';
1819
1820 }
1821
1822
1823 public static function generateDownloadFileLink($productDownload, $orderId = null, $validityInMinutes = 60, $isAdmin = false): string
1824 {
1825 $identifier = Arr::get($productDownload, 'download_identifier', '');
1826
1827 $validityInMinutes = apply_filters('fluent_cart/download_link_validity_in_minutes', $validityInMinutes, [
1828 'product_download' => $productDownload,
1829 'order_id' => $orderId,
1830 'is_admin' => $isAdmin,
1831 ]);
1832
1833 $signParams = [
1834 'download_identifier' => $identifier,
1835 'valid_till' => DateTime::now()
1836 ->addMinutes($validityInMinutes ?? 60)
1837 ->getTimestamp()
1838 ];
1839
1840 if ($orderId) {
1841 $orderId = Arr::wrap($orderId);
1842 $signParams['order_id'] = json_encode($orderId);
1843 }
1844
1845 $url = (new BaseUrl())->sign(site_url('/'), $signParams);
1846
1847 return URL::appendQueryParams($url, [
1848 'fluent-cart' => 'download-by-id',
1849 ]);
1850
1851 }
1852
1853
1854 public static function readableFileSize($bytes): string
1855 {
1856 // Converts bytes to a human-readable format (e.g., KB, MB, GB)
1857 // Example: 1024 -> "1 KB"
1858 // Example: 1048576 -> "1 MB"
1859
1860 if (!$bytes && $bytes !== 0) return '';
1861 $units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
1862 $i = floor(log($bytes, 2) / 10);
1863 $size = $bytes / pow(1024, $i);
1864 return round($size, 2) . ' ' . $units[$i];
1865 }
1866
1867
1868 public static function getSitePrefix()
1869 {
1870 $siteUrl = rtrim(home_url(), '/');
1871 // remove http:// or https:// from the URL
1872 $siteUrl = preg_replace('#^https?://#', '', $siteUrl);
1873 $sitePrefix = str_replace(['/', '.'], '_', $siteUrl);
1874
1875 return apply_filters('fluent_cart/site_prefix', $sitePrefix, []);
1876 }
1877
1878 public static function humanIntervalMaps($interval = '')
1879 {
1880 $intervals = [
1881 'daily' => __('day', 'fluent-cart'),
1882 'weekly' => __('week', 'fluent-cart'),
1883 'monthly' => __('month', 'fluent-cart'),
1884 'quarterly' => __('quarter', 'fluent-cart'),
1885 'half_yearly' => __('six month', 'fluent-cart'),
1886 'yearly' => __('year', 'fluent-cart'),
1887 ];
1888
1889 return Arr::get($intervals, $interval);
1890 }
1891
1892 /**
1893 * @return array Array of intervals with label and value
1894 */
1895 public static function getAvailableSubscriptionIntervalOptions(): array
1896 {
1897 $intervals = [
1898 [
1899 'label' => __('Yearly', 'fluent-cart'),
1900 'value' => 'yearly',
1901 'map_value' => 'year',
1902 ],
1903 [
1904 'label' => __('Half Yearly', 'fluent-cart'),
1905 'value' => 'half_yearly',
1906 'map_value' => 'half_year',
1907 ],
1908 [
1909 'label' => __('Quarterly', 'fluent-cart'),
1910 'value' => 'quarterly',
1911 'map_value' => 'quarter',
1912 ],
1913 [
1914 'label' => __('Monthly', 'fluent-cart'),
1915 'value' => 'monthly',
1916 'map_value' => 'month',
1917 ],
1918 [
1919 'label' => __('Weekly', 'fluent-cart'),
1920 'value' => 'weekly',
1921 'map_value' => 'week',
1922 ],
1923 [
1924 'label' => __('Daily', 'fluent-cart'),
1925 'value' => 'daily',
1926 'map_value' => 'day',
1927 ]
1928 ];
1929
1930 return apply_filters('fluent_cart/available_subscription_interval_options', $intervals);
1931 }
1932
1933 public static function translateIntervalToStandardFormat($repeatInterval)
1934 {
1935 if (empty($repeatInterval)) {
1936 return 'year';
1937 }
1938
1939 $intervalMaps = static::getAvailableSubscriptionIntervalMaps();
1940
1941 if (!isset($intervalMaps[$repeatInterval])) {
1942 return 'year';
1943 }
1944
1945 return $intervalMaps[$repeatInterval];
1946 }
1947
1948 public static function getAvailableSubscriptionIntervalMaps()
1949 {
1950 $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1951
1952 $intervalMaps = [];
1953 foreach ($intervalOptions as $option) {
1954 $intervalMaps[$option['value']] = $option['map_value'];
1955 }
1956
1957 return $intervalMaps;
1958
1959 }
1960
1961 public static function calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval)
1962 {
1963 $intervalInDays = static::subscriptionIntervalInDays($repeatInterval);
1964
1965 $maxTrialDaysAllowed = apply_filters('fluent_cart/max_trial_days_allowed', 365, [
1966 'existing_trial_days' => $trialDays,
1967 'repeat_interval' => $repeatInterval,
1968 'interval_in_days' => $intervalInDays,
1969 ]);
1970
1971 return min($trialDays + $intervalInDays, $maxTrialDaysAllowed); // return the minimum of the sum of the existing trial days and the interval days, and the max trial allowed
1972
1973 }
1974
1975 public static function subscriptionIntervalInDays($interval)
1976 {
1977 return PaymentHelper::getIntervalDays($interval);
1978 }
1979
1980 public static function parseTermIdsForFilter($filters): array
1981 {
1982 $taxonomies = Taxonomy::getTaxonomies();
1983 if (is_string($filters)) {
1984 $filters = json_decode($filters, true);
1985 }
1986 $formattedFilters = [];
1987
1988 foreach ($taxonomies as $key => $taxonomy) {
1989 $terms = Arr::get($filters, $key, []);
1990 if (!is_array($terms)) {
1991 $terms = [$terms];
1992 }
1993
1994 $terms = array_filter($terms, function ($term) {
1995 return !empty($term);
1996 });
1997
1998 if (!empty($terms)) {
1999 $terms = array_map(function ($term) {
2000 return sanitize_text_field((string)$term);
2001 }, $terms);
2002
2003
2004 $formattedFilters[$key] = $terms;
2005 }
2006
2007
2008 }
2009
2010 return $formattedFilters;
2011 }
2012
2013 public static function mergeTermIdsForFilter($array1 = [], $array2 = []): array
2014 {
2015 $result = [];
2016
2017 foreach ([$array1, $array2] as $array) {
2018 foreach ($array as $key => $values) {
2019 if (!isset($result[$key])) {
2020 $result[$key] = [];
2021 }
2022 $result[$key] = array_values(array_unique(array_merge($result[$key], $values)));
2023 }
2024 }
2025
2026 return $result;
2027 }
2028
2029 public static function loadBundleChild(array $variants, $select = ['id', 'variation_title']): array
2030 {
2031 $allChildVariants = Arr::pluck($variants, 'other_info.bundle_child_ids');
2032
2033 $allChildVariants = array_unique(Arr::flatten($allChildVariants));
2034 $allChildVariants = array_values(array_diff(
2035 $allChildVariants,
2036 Arr::pluck($variants, 'id')
2037 ));
2038 $allChildVariants = array_filter($allChildVariants);
2039 $childVariants = ProductVariation::query()
2040 ->whereIn('id', $allChildVariants)
2041 ->with('product:ID,post_title')
2042 ->select($select)
2043 ->get()
2044 ->toArray();
2045
2046 // Extract post_title from product and remove product object to keep data clean
2047 foreach ($childVariants as $key => $childVariant) {
2048 $postTitle = Arr::get($childVariant, 'product.post_title');
2049 if ($postTitle) {
2050 $childVariants[$key]['post_title'] = $postTitle;
2051 unset($childVariants[$key]['product']);
2052 }
2053 }
2054
2055 foreach ($variants as &$variant) {
2056 $childIds = Arr::get($variant, 'other_info.bundle_child_ids', []);
2057 $variant['bundle_child_ids'] = $childIds;
2058 if (count($childIds) < 1) {
2059 $variant['child_variants'] = [];
2060 continue;
2061 }
2062 foreach ($childVariants as $childVariant) {
2063 if (in_array($childVariant['id'], Arr::get($variant, 'other_info.bundle_child_ids', []))) {
2064 $variant['child_variants'][$childVariant['id']] = $childVariant;
2065 }
2066 }
2067 }
2068
2069 return $variants;
2070 }
2071
2072 /**
2073 * Translate digits in a number according to the configured numeric system.
2074 *
2075 * Converts the digits 0-9 in the given number to their equivalents
2076 * defined in the numeric system string from `dateTimeStrings()`.
2077 * This allows displaying numbers in other numeral systems, e.g., Bengali, Arabic, Hindi, etc.
2078 *
2079 * Only digits are translated; other characters such as decimal points, currency symbols, or text remain unchanged.
2080 *
2081 * @param int|float|string $number The number to translate.
2082 * @return string The number with digits translated according to the configured numeric system.
2083 */
2084 public static function translateNumber($number): string
2085 {
2086 $config = TransStrings::dateTimeStrings();
2087 $numericSystem = Arr::get($config, 'numericSystem', '0_1_2_3_4_5_6_7_8_9');
2088 $digits = explode('_', $numericSystem);
2089
2090 return strtr(
2091 (string)$number,
2092 array_combine(range(0, 9),
2093 $digits)
2094 );
2095 }
2096
2097 public static function isModalCheckoutEnabled(): bool
2098 {
2099 //$storeSettings = new StoreSettings();
2100 //$enableModalCheckout = $storeSettings->get('enable_modal_checkout', 'no');
2101 return apply_filters('fluent_cart/enable_modal_checkout', false);
2102 }
2103
2104 public static function isAdminUser(): bool
2105 {
2106 return current_user_can('manage_options');
2107 }
2108
2109 /**
2110 * Convert string/boolean to actual boolean value.
2111 * Handles shortcode string attributes like "true"/"false"
2112 *
2113 * @param mixed $value The value to convert to boolean
2114 * @param bool $default Default value if conversion fails
2115 * @return bool The boolean result
2116 */
2117 public static function toBool($value, bool $default = false): bool
2118 {
2119 if (is_bool($value)) {
2120 return $value;
2121 }
2122
2123 if (is_string($value)) {
2124 $value = strtolower(trim($value));
2125 if (in_array($value, ['true', '1', 'yes', 'on'], true)) {
2126 return true;
2127 }
2128 if (in_array($value, ['false', '0', 'no', 'off'], true)) {
2129 return false;
2130 }
2131 }
2132
2133 return $default;
2134 }
2135
2136 public static function formatTaxRatePercent(float $rate): string
2137 {
2138 $formatted = number_format($rate, 4, '.', '');
2139 if (strpos($formatted, '.') !== false) {
2140 $formatted = rtrim($formatted, '0');
2141 $formatted = rtrim($formatted, '.');
2142 }
2143 return $formatted;
2144 }
2145
2146 /**
2147 * Returns the tax label for order-level tax rows (tax_total, not per-item).
2148 * For mixed orders, indicates tax varies per item.
2149 *
2150 * @param \FluentCart\App\Models\Order $order
2151 * @return string
2152 */
2153 public static function getOrderTaxLabel($order) {
2154 if ((int) $order->tax_behavior === 3) {
2155 return esc_html__('(Varies)', 'fluent-cart');
2156 }
2157 return (int) $order->tax_behavior === 2
2158 ? esc_html__('(Included)', 'fluent-cart')
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;
2194 }
2195 }
2196