@doswiftly/storefront-sdk 15.1.0 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,47 +3,45 @@
3
3
  *
4
4
  * Pure functions for formatting prices, dates, numbers.
5
5
  * 0 runtime dependencies — works in Node.js, Edge, Deno, Bun.
6
+ *
7
+ * Locale handling:
8
+ * - Every formatter takes an optional `locale: string` argument. When omitted,
9
+ * the runtime default (`Intl.NumberFormat().resolvedOptions().locale`) is
10
+ * used — in browsers this resolves from `navigator.language`, in Node from
11
+ * `LANG` / system settings.
12
+ * - For deterministic output (typical in a storefront with explicit i18n) the
13
+ * caller should pass `locale` explicitly (`useLocale()` in Client Components,
14
+ * `getLocale()` in Server Components, `next-intl`).
15
+ * - For per-shop locale↔currency overrides (e.g. PLN displayed in `'en-PL'`
16
+ * for an English-speaking customer of a Polish shop), the storefront reads
17
+ * `shop.localeToCurrencyMap` from the API and passes the chosen locale.
18
+ *
19
+ * Money precision:
20
+ * - `Money.amount` is a string (Decimal scalar) — values are forwarded to
21
+ * `Intl.NumberFormat.format()` verbatim, without `parseFloat`. This preserves
22
+ * decimal precision exactly as returned by the GraphQL API, including
23
+ * currencies with non-2-digit subunits (JPY/KRW, BHD/JOD, ISK, etc.).
24
+ *
25
+ * Symbol resolution:
26
+ * - `getCurrencySymbol(code, locale?)` derives the symbol via
27
+ * `Intl.NumberFormat.formatToParts()`. The SDK no longer ships a hardcoded
28
+ * subset of currencies — every ISO 4217 currency known to the runtime is
29
+ * supported, with the locale-correct symbol (`'zł'` in pl-PL, `'PLN'` in
30
+ * en-US, `'€'` in de-DE, etc.).
6
31
  */
7
32
  // ============================================================================
8
- // CONSTANTS
9
- // ============================================================================
10
- /** Currency symbols mapping */
11
- export const CURRENCY_SYMBOLS = {
12
- PLN: 'zł',
13
- EUR: '€',
14
- USD: '$',
15
- GBP: '£',
16
- CHF: 'CHF',
17
- CZK: 'Kč',
18
- SEK: 'kr',
19
- NOK: 'kr',
20
- DKK: 'kr',
21
- JPY: '¥',
22
- CNY: '¥',
23
- AUD: 'A$',
24
- CAD: 'C$',
25
- };
26
- /** Currency locale mapping for proper formatting */
27
- export const CURRENCY_LOCALES = {
28
- PLN: 'pl-PL',
29
- EUR: 'de-DE',
30
- USD: 'en-US',
31
- GBP: 'en-GB',
32
- CHF: 'de-CH',
33
- CZK: 'cs-CZ',
34
- SEK: 'sv-SE',
35
- NOK: 'nb-NO',
36
- DKK: 'da-DK',
37
- JPY: 'ja-JP',
38
- CNY: 'zh-CN',
39
- AUD: 'en-AU',
40
- CAD: 'en-CA',
41
- };
42
- // ============================================================================
43
33
  // FORMATTER CACHE (avoids re-creating Intl objects on every call)
44
34
  // ============================================================================
45
35
  const numberFormatCache = new Map();
46
36
  const dateFormatCache = new Map();
37
+ /** Runtime default locale, resolved once and reused for every `locale?`-less call. */
38
+ let defaultLocaleCache;
39
+ function resolveDefaultLocale() {
40
+ if (defaultLocaleCache === undefined) {
41
+ defaultLocaleCache = new Intl.NumberFormat().resolvedOptions().locale;
42
+ }
43
+ return defaultLocaleCache;
44
+ }
47
45
  function getCurrencyFormatter(locale, currency) {
48
46
  const key = `${locale}:${currency}`;
49
47
  let fmt = numberFormatCache.get(key);
@@ -75,111 +73,136 @@ function getDateFormatter(locale, options) {
75
73
  }
76
74
  return fmt;
77
75
  }
76
+ // Internal — forwards a Decimal string to the formatter via the string overload
77
+ // added through declaration merging at the top of this file. Centralised here
78
+ // so future precision-sensitive call sites have one place to depend on.
79
+ function formatDecimalString(value, formatter) {
80
+ return formatter.format(value);
81
+ }
78
82
  // ============================================================================
79
83
  // UTILITY FUNCTIONS
80
84
  // ============================================================================
81
85
  /**
82
- * Get currency symbol
86
+ * Get the currency symbol for an ISO 4217 code in the requested locale. Derived
87
+ * from `Intl.NumberFormat.formatToParts()` — every currency known to the
88
+ * runtime is supported, with the locale-correct symbol.
89
+ *
90
+ * @example
91
+ * getCurrencySymbol('PLN', 'pl-PL') // => "zł"
92
+ * getCurrencySymbol('PLN', 'en-US') // => "PLN" (en-US has no narrow symbol for PLN)
93
+ * getCurrencySymbol('EUR', 'de-DE') // => "€"
94
+ * getCurrencySymbol('USD') // depends on the runtime locale
83
95
  */
84
- export function getCurrencySymbol(code) {
85
- return CURRENCY_SYMBOLS[code] || code;
96
+ export function getCurrencySymbol(code, locale) {
97
+ const resolvedLocale = locale ?? resolveDefaultLocale();
98
+ try {
99
+ const parts = new Intl.NumberFormat(resolvedLocale, {
100
+ style: 'currency',
101
+ currency: code,
102
+ currencyDisplay: 'symbol',
103
+ }).formatToParts(0);
104
+ return parts.find((p) => p.type === 'currency')?.value ?? code;
105
+ }
106
+ catch {
107
+ return code;
108
+ }
86
109
  }
87
110
  // ============================================================================
88
111
  // PRICE FORMATTING
89
112
  // ============================================================================
90
113
  /**
91
- * Format price with currency symbol
114
+ * Format a `Money` value in the requested locale. `amount` is passed to
115
+ * `Intl.NumberFormat` as a string — no `parseFloat`, so decimal precision is
116
+ * preserved exactly as returned by the GraphQL API.
92
117
  *
93
118
  * @example
94
- * ```typescript
95
- * formatPrice({ amount: "99.99", currencyCode: "USD" })
96
- * // => "$99.99"
97
- * ```
119
+ * formatPrice({ amount: "99.99", currencyCode: "USD" }, 'en-US') // => "$99.99"
120
+ * formatPrice({ amount: "12.50", currencyCode: "PLN" }, 'pl-PL') // => "12,50 zł"
121
+ * formatPrice({ amount: "115.20", currencyCode: "EUR" }, 'de-DE') // => "115,20 €"
98
122
  */
99
- export function formatPrice(price) {
123
+ export function formatPrice(price, locale) {
100
124
  if (!price)
101
125
  return '';
102
- const amount = parseFloat(price.amount);
103
- const code = price.currencyCode;
104
- const locale = CURRENCY_LOCALES[code] || 'en-US';
126
+ const resolvedLocale = locale ?? resolveDefaultLocale();
105
127
  try {
106
- return getCurrencyFormatter(locale, code).format(amount);
128
+ return formatDecimalString(price.amount, getCurrencyFormatter(resolvedLocale, price.currencyCode));
107
129
  }
108
130
  catch {
109
- const symbol = CURRENCY_SYMBOLS[code] || code;
110
- return `${amount.toFixed(2)} ${symbol}`;
131
+ return `${price.amount} ${price.currencyCode}`;
111
132
  }
112
133
  }
113
134
  /**
114
- * Format price range
135
+ * Format a price range (`min - max`). Returns a single price when `min` equals
136
+ * `max` by string comparison (no numeric conversion).
115
137
  *
116
138
  * @example
117
- * ```typescript
118
139
  * formatPriceRange(
119
140
  * { amount: "10.00", currencyCode: "USD" },
120
- * { amount: "50.00", currencyCode: "USD" }
141
+ * { amount: "50.00", currencyCode: "USD" },
142
+ * 'en-US'
121
143
  * )
122
144
  * // => "$10.00 - $50.00"
123
- * ```
124
145
  */
125
- export function formatPriceRange(minPrice, maxPrice) {
146
+ export function formatPriceRange(minPrice, maxPrice, locale) {
126
147
  if (minPrice.amount === maxPrice.amount) {
127
- return formatPrice(minPrice);
148
+ return formatPrice(minPrice, locale);
128
149
  }
129
- return `${formatPrice(minPrice)} - ${formatPrice(maxPrice)}`;
150
+ return `${formatPrice(minPrice, locale)} - ${formatPrice(maxPrice, locale)}`;
130
151
  }
131
152
  /**
132
- * Format amount with currency
153
+ * Format an amount with currency. Accepts a string (recommended — precision
154
+ * preserved) or a number. String values are forwarded to `Intl.NumberFormat`
155
+ * verbatim; number values pass through the standard number path.
133
156
  *
134
157
  * @example
135
- * ```tsx
136
- * const formatted = formatAmount("115.20", "EUR");
137
- * // => "115,20 €"
138
- * ```
158
+ * formatAmount("115.20", "EUR", 'de-DE') // => "115,20 €"
159
+ * formatAmount(50, "USD", 'en-US') // => "$50.00"
139
160
  */
140
- export function formatAmount(amount, currencyCode) {
141
- const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
142
- const locale = CURRENCY_LOCALES[currencyCode] || 'en-US';
161
+ export function formatAmount(amount, currencyCode, locale) {
162
+ const resolvedLocale = locale ?? resolveDefaultLocale();
143
163
  try {
144
- return getCurrencyFormatter(locale, currencyCode).format(numAmount);
164
+ const formatter = getCurrencyFormatter(resolvedLocale, currencyCode);
165
+ return typeof amount === 'string'
166
+ ? formatDecimalString(amount, formatter)
167
+ : formatter.format(amount);
145
168
  }
146
169
  catch {
147
- const symbol = CURRENCY_SYMBOLS[currencyCode] || currencyCode;
148
- return `${numAmount.toFixed(2)} ${symbol}`;
170
+ return typeof amount === 'string'
171
+ ? `${amount} ${currencyCode}`
172
+ : `${amount.toFixed(2)} ${currencyCode}`;
149
173
  }
150
174
  }
151
175
  // ============================================================================
152
176
  // DATE FORMATTING
153
177
  // ============================================================================
154
178
  /**
155
- * Format date to locale string
179
+ * Format a date in the requested locale using a `MMM d, y`-style pattern
180
+ * (locale-specific month abbreviation and ordering).
156
181
  *
157
182
  * @example
158
- * ```typescript
159
- * formatDate(new Date())
160
- * // => "Dec 9, 2025"
161
- * ```
183
+ * formatDate(new Date(2025, 11, 9), 'en-US') // => "Dec 9, 2025"
184
+ * formatDate(new Date(2025, 11, 9), 'pl-PL') // => "9 gru 2025"
185
+ * formatDate('2025-12-09T12:00:00Z', 'de-DE') // => "9. Dez. 2025"
162
186
  */
163
- export function formatDate(date) {
187
+ export function formatDate(date, locale) {
164
188
  const d = typeof date === 'string' ? new Date(date) : date;
165
- return getDateFormatter('en-US', {
189
+ return getDateFormatter(locale, {
166
190
  year: 'numeric',
167
191
  month: 'short',
168
192
  day: 'numeric',
169
193
  }).format(d);
170
194
  }
171
195
  /**
172
- * Format date with time
196
+ * Format a date with time in the requested locale (month-abbreviated date plus
197
+ * hour and minute, 12- or 24-hour depending on locale convention).
173
198
  *
174
199
  * @example
175
- * ```typescript
176
- * formatDateTime(new Date())
177
- * // => "Dec 9, 2025, 10:30 PM"
178
- * ```
200
+ * formatDateTime(new Date(), 'en-US') // => "Dec 9, 2025, 10:30 PM"
201
+ * formatDateTime(new Date(), 'pl-PL') // => "9 gru 2025, 22:30"
179
202
  */
180
- export function formatDateTime(date) {
203
+ export function formatDateTime(date, locale) {
181
204
  const d = typeof date === 'string' ? new Date(date) : date;
182
- return getDateFormatter('en-US', {
205
+ return getDateFormatter(locale, {
183
206
  year: 'numeric',
184
207
  month: 'short',
185
208
  day: 'numeric',
@@ -191,25 +214,24 @@ export function formatDateTime(date) {
191
214
  // NUMBER FORMATTING
192
215
  // ============================================================================
193
216
  /**
194
- * Format number with thousands separator
217
+ * Format a number with the locale-appropriate thousands separator and decimal
218
+ * mark.
195
219
  *
196
220
  * @example
197
- * ```typescript
198
- * formatNumber(1234567)
199
- * // => "1,234,567"
200
- * ```
221
+ * formatNumber(1234567, 'en-US') // => "1,234,567"
222
+ * formatNumber(1234567, 'pl-PL') // => "1 234 567" (NBSP separator)
223
+ * formatNumber(1234.5, 'de-DE') // => "1.234,5"
201
224
  */
202
- export function formatNumber(num) {
203
- return getNumberFormatter('en-US').format(num);
225
+ export function formatNumber(num, locale) {
226
+ return getNumberFormatter(locale).format(num);
204
227
  }
205
228
  /**
206
- * Format percentage
229
+ * Format a 0-1 ratio as an integer percentage. Locale-independent — uses
230
+ * `Math.round` and a literal `%` suffix.
207
231
  *
208
232
  * @example
209
- * ```typescript
210
- * formatPercentage(0.15)
211
- * // => "15%"
212
- * ```
233
+ * formatPercentage(0.15) // => "15%"
234
+ * formatPercentage(0.125) // => "13%" (Math.round half-up)
213
235
  */
214
236
  export function formatPercentage(value) {
215
237
  return `${Math.round(value * 100)}%`;