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

DateFormatter.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.6, at app/Services/DateTime/DateFormatter.php

300 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\DateTime;
4
5 use FluentCart\Api\StoreSettings;
6 use FluentCart\App\Services\Translations\TransStrings;
7 use FluentCart\Framework\Support\Arr;
8
9 /**
10 * Renders dates using the store's date/time format and timezone preference.
11 *
12 * Every user-facing date in FluentCart should go through this class rather than
13 * calling ->format() with a literal pattern. A literal pattern cannot be
14 * localized: DateTime::format() has no locale, so 'M' is always 'Aug', and the
15 * day/month order and the 12-hour clock are baked into the source.
16 *
17 * Two store settings steer the output:
18 *
19 * - date_time_format_source: DEFAULTS TO 'wordpress', following Settings >
20 * General. 'fluent_cart' keeps FALLBACK_DATE/FALLBACK_TIME instead.
21 * - timezone_source: defaults to 'fluent_cart', rendering in the timezone
22 * captured on the order at checkout (falling back to UTC); 'wordpress' uses
23 * the site timezone.
24 *
25 * The format source deliberately does NOT default to reproducing the
26 * pre-settings strings, and the reason is worth keeping in view. The FluentCart
27 * patterns are literals, so on a localized store wp_date() translates the month
28 * name but cannot reorder the fields: German renders 'Juli 30, 2026' -- a German
29 * month in English order, which a German reader takes day-first and misreads.
30 * A fully English date would at least be recognized as English. There is no
31 * literal that is right in every locale, so the default follows the one source
32 * that is: the site's own WordPress format. English stores see a changed string
33 * on upgrade ('Aug 16, 2026' -> 'August 16, 2026'); a store that wants the old
34 * output back selects 'fluent_cart'.
35 */
36 class DateFormatter
37 {
38 /**
39 * FluentCart's own patterns — the literals the call sites carried before
40 * these settings existed.
41 *
42 * These were never uniform, and the difference is load-bearing: date-only
43 * sites (the shortcode parsers, the FluentCRM contact stats) used 'M j, Y'
44 * with an unpadded day, while date-and-time sites (every e-mail view) used
45 * 'M d, Y h:i A' with a padded one. Reproducing one from the other would
46 * silently renumber whichever side lost, so both are kept verbatim.
47 *
48 * The JS side has the same split for its own literals; see
49 * LEGACY_DATE_FORMATS in utils/dateFormats.js.
50 */
51 const FALLBACK_DATE = 'M j, Y';
52 const FALLBACK_TIME = 'h:i A';
53 const FALLBACK_DATE_TIME = 'M d, Y h:i A';
54
55 /**
56 * Whether the store follows the WordPress date/time format options.
57 */
58 public static function usesWordPressFormats(): bool
59 {
60 return (new StoreSettings())->get('date_time_format_source') === 'wordpress';
61 }
62
63 /**
64 * Whether the store renders dates in the WordPress site timezone.
65 */
66 public static function usesWordPressTimezone(): bool
67 {
68 return (new StoreSettings())->get('timezone_source') === 'wordpress';
69 }
70
71 /**
72 * The store's formats, before the date/time filter runs.
73 *
74 * Unfiltered on purpose: this is what TransStrings::dateTimeStrings()
75 * starts from before running the single `fluent_cart/date_time_strings`
76 * filter over the whole map. Read formats() instead of this.
77 *
78 * @return array<string, string>
79 */
80 public static function defaultFormats(): array
81 {
82 if (!static::usesWordPressFormats()) {
83 // Verbatim, not derived: date_time is deliberately not date + time
84 // here, because the two literals never matched. See the constants.
85 return [
86 'date' => static::FALLBACK_DATE,
87 'time' => static::FALLBACK_TIME,
88 'date_time' => static::FALLBACK_DATE_TIME,
89 ];
90 }
91
92 $date = (string)get_option('date_format') ?: static::FALLBACK_DATE;
93 $time = (string)get_option('time_format') ?: static::FALLBACK_TIME;
94
95 return [
96 'date' => $date,
97 'time' => $time,
98 'date_time' => $date . static::dateTimeSeparator($date, $time) . $time,
99 ];
100 }
101
102 /**
103 * What goes between the date and the time in the combined format.
104 *
105 * WordPress has no combined date/time option -- core itself joins the two
106 * with a space, and that is the default here for the same reason. But the
107 * space is an English convention, not a universal one: German writes
108 * '16. August 2026, 15:30' with a comma, and a locale that wants no
109 * separator at all cannot express that by editing either option.
110 *
111 * The separator is a LITERAL in a PHP date() pattern, so a separator
112 * containing a date token would be rendered as one. Anything a filter
113 * returns is escaped before it reaches the pattern, which means a filter
114 * can safely return ', ' without knowing that 'a' is the meridiem token.
115 *
116 * Listeners receive the two store formats as one read-only context array
117 * and must register with `add_filter('fluent_cart/date_time_separator',
118 * $callback, 10, 2)`.
119 *
120 * @param string $date The store's date format, for context only
121 * @param string $time The store's time format, for context only
122 */
123 protected static function dateTimeSeparator(string $date, string $time): string
124 {
125 $separator = apply_filters('fluent_cart/date_time_separator', ' ', [
126 'date' => $date,
127 'time' => $time,
128 ]);
129
130 if (!is_string($separator) || $separator === '') {
131 return ' ';
132 }
133
134 return static::escapeDateLiteral($separator);
135 }
136
137 /**
138 * Backslash-escape every character PHP's date() would read as a token.
139 *
140 * Without this a filter returning ', ' would be fine but one returning
141 * ' at ' would render the 'a' as 'pm' and the 't' as the month length.
142 */
143 protected static function escapeDateLiteral(string $literal): string
144 {
145 $out = '';
146 $length = strlen($literal);
147
148 for ($i = 0; $i < $length; $i++) {
149 $char = $literal[$i];
150 // Only ASCII letters and the backslash are meaningful to date();
151 // punctuation, digits and multibyte characters pass through.
152 // ctype_alpha() rather than a regex: the character class needed to
153 // match a backslash is itself a backslash-escaping trap.
154 if (ctype_alpha($char) || $char === chr(92)) {
155 $out .= chr(92);
156 }
157 $out .= $char;
158 }
159
160 return $out;
161 }
162
163 /**
164 * The store's formats after the single date/time filter has run.
165 *
166 * @return array<string, string>
167 */
168 public static function formats(): array
169 {
170 return (array)Arr::get(TransStrings::dateTimeStrings(), 'formats', []);
171 }
172
173 /**
174 * The store's date format, e.g. 'F j, Y' or 'j. F Y'.
175 */
176 public static function dateFormat(): string
177 {
178 return (string)Arr::get(static::formats(), 'date', static::FALLBACK_DATE);
179 }
180
181 /**
182 * The store's time format, e.g. 'g:i a' or 'H:i'.
183 */
184 public static function timeFormat(): string
185 {
186 return (string)Arr::get(static::formats(), 'time', static::FALLBACK_TIME);
187 }
188
189 /**
190 * The store's month-and-year pattern, e.g. 'F Y' or 'Y年n月'.
191 *
192 * There is no WordPress setting for this, so derive it from the store's date
193 * format by dropping the day. That keeps a year-first or suffix-marked locale
194 * in its own field order instead of assuming the English 'month year', and it
195 * matches how DayjsFormatter derives `month_year` for the charts.
196 */
197 public static function monthYearFormat(): string
198 {
199 return DayjsFormatter::withoutDay(static::dateFormat());
200 }
201
202 /**
203 * The store's hour-only pattern for an hour-of-day axis, 'H' or 'g A'.
204 *
205 * Derived from the store's time format: if it asks for a 24-hour clock
206 * (G or H), label the axis 0-23 rather than forcing 1 AM - 12 PM.
207 */
208 public static function hourFormat(): string
209 {
210 $time = static::timeFormat();
211
212 return (strpos($time, 'H') !== false || strpos($time, 'G') !== false) ? 'H' : 'g A';
213 }
214
215 /**
216 * The timezone a date should be displayed in.
217 *
218 * On 'wordpress' this is always the site timezone. On 'fluent_cart' there is
219 * no browser to read here — PHP renders e-mails from cron and invoices from
220 * a queue — so we use the timezone captured on the order at checkout, which
221 * is the same source ReceiptRenderer and OrderParser already read. Records
222 * with no order behind them (customer-level dates, for instance) fall back
223 * to UTC, which is how they rendered before these settings existed.
224 *
225 * @param mixed $context Order model, order config array, timezone string, or null
226 */
227 public static function displayTimezone($context = null): \DateTimeZone
228 {
229 if (static::usesWordPressTimezone()) {
230 return wp_timezone();
231 }
232
233 $timezone = static::contextTimezone($context);
234
235 // timezone_open() first: PHP 8.4 throws on deprecated aliases like
236 // Asia/Saigon, and user_tz is browser-captured, so it can carry one.
237 if ($timezone !== '' && @timezone_open($timezone) !== false) {
238 return new \DateTimeZone($timezone);
239 }
240
241 return new \DateTimeZone('UTC');
242 }
243
244 /**
245 * Pull a timezone name out of whatever the call site had to hand.
246 *
247 * @param mixed $context
248 */
249 protected static function contextTimezone($context): string
250 {
251 if ($context instanceof \DateTimeZone) {
252 return $context->getName();
253 }
254
255 if (is_string($context)) {
256 return $context;
257 }
258
259 if (is_array($context)) {
260 return (string)Arr::get($context, 'user_tz', '');
261 }
262
263 if (is_object($context) && isset($context->config)) {
264 return (string)Arr::get((array)$context->config, 'user_tz', '');
265 }
266
267 return '';
268 }
269
270 /**
271 * Format a GMT/UTC datetime for display.
272 *
273 * @param string|\DateTimeInterface|int|null $datetime GMT datetime, timestamp, or null for now
274 * @param bool $withTime Append the store's time format
275 * @param mixed $context Order model, order config array, or timezone string,
276 * used only when timezone_source is 'fluent_cart'
277 * @return string Empty string when $datetime is absent or unparseable
278 */
279 public static function format($datetime, bool $withTime = false, $context = null): string
280 {
281 if ($datetime === null || $datetime === '' || $datetime === '0000-00-00 00:00:00') {
282 return '';
283 }
284
285 try {
286 // Only the instant matters here; displayTimezone() decides how it renders.
287 $timestamp = DateTime::gmtToTimezone($datetime, new \DateTimeZone('UTC'))->getTimestamp();
288 } catch (\Exception $e) {
289 return '';
290 }
291
292 $key = $withTime ? 'date_time' : 'date';
293 $format = (string)Arr::get(static::formats(), $key, static::dateFormat());
294
295 // wp_date() applies the translated month/weekday names from WordPress
296 // core, which DateTime::format() cannot do.
297 return (string)wp_date($format, $timestamp, static::displayTimezone($context));
298 }
299 }
300