@open-mercato/shared 0.6.8-develop.6975.1.b21e19d444 → 0.6.8-develop.6976.1.9b79145ac4

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.
@@ -55,6 +55,10 @@ function useLocale() {
55
55
  if (!ctx) throw new Error("useLocale must be used within I18nProvider");
56
56
  return ctx.locale;
57
57
  }
58
+ function useOptionalLocale() {
59
+ const ctx = useContext(I18nContext);
60
+ return ctx?.locale;
61
+ }
58
62
  function useLocaleLocked() {
59
63
  const ctx = useContext(I18nContext);
60
64
  return ctx?.localeLocked ?? false;
@@ -63,6 +67,7 @@ export {
63
67
  I18nProvider,
64
68
  useLocale,
65
69
  useLocaleLocked,
70
+ useOptionalLocale,
66
71
  useOptionalT,
67
72
  useT
68
73
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/i18n/context.tsx"],
4
- "sourcesContent": ["\"use client\"\nimport { createContext, useContext, useMemo, type ReactNode } from 'react'\nimport type { Locale } from './config'\n\nexport type Dict = Record<string, string>\n\nexport type TranslateParams = Record<string, string | number>\n\nexport type TranslateFn = (\n key: string,\n fallbackOrParams?: string | TranslateParams,\n params?: TranslateParams\n) => string\n\nexport type I18nContextValue = {\n locale: Locale\n t: TranslateFn\n /** True when the locale is pinned via `OM_FORCE_LOCALE`; UI should hide switchers. */\n localeLocked: boolean\n}\n\nconst I18N_CONTEXT_KEY = '__openMercatoI18nContext'\n\ntype GlobalI18nContextStore = typeof globalThis & {\n [I18N_CONTEXT_KEY]?: ReturnType<typeof createContext<I18nContextValue | null>>\n}\n\nfunction getI18nContext() {\n const store = globalThis as GlobalI18nContextStore\n if (!store[I18N_CONTEXT_KEY]) {\n store[I18N_CONTEXT_KEY] = createContext<I18nContextValue | null>(null)\n }\n return store[I18N_CONTEXT_KEY]\n}\n\nconst I18nContext = getI18nContext()\n\nfunction format(template: string, params?: TranslateParams) {\n if (!params) return template\n return template.replace(/\\{\\{(\\w+)\\}\\}|\\{(\\w+)\\}/g, (_, doubleKey, singleKey) => {\n const key = doubleKey ?? singleKey\n if (!key) return _\n const value = params[key]\n if (value === undefined) {\n return doubleKey ? `{{${key}}}` : `{${key}}`\n }\n return String(value)\n })\n}\n\nexport function I18nProvider({ children, locale, dict, localeLocked = false }: { children: ReactNode; locale: Locale; dict: Dict; localeLocked?: boolean }) {\n const value = useMemo<I18nContextValue>(() => ({\n locale,\n localeLocked,\n t: (key, fallbackOrParams, params) => {\n let fallback: string | undefined\n let resolvedParams: TranslateParams | undefined\n\n if (typeof fallbackOrParams === 'string') {\n fallback = fallbackOrParams\n resolvedParams = params\n } else {\n resolvedParams = fallbackOrParams ?? params\n }\n\n const template = dict[key] ?? fallback ?? key\n return format(template, resolvedParams)\n },\n }), [locale, dict, localeLocked])\n return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>\n}\n\nexport function useT() {\n const ctx = useContext(I18nContext)\n if (!ctx) throw new Error('useT must be used within I18nProvider')\n return ctx.t\n}\n\n/**\n * Like `useT`, but returns `undefined` instead of throwing when no\n * `I18nProvider` is in scope. Use where a translator is desirable but not\n * guaranteed (e.g. plumbing `t` into side-effect handlers that may run before\n * the provider mounts) \u2014 callers MUST provide a fallback.\n */\nexport function useOptionalT(): TranslateFn | undefined {\n const ctx = useContext(I18nContext)\n return ctx?.t\n}\n\nexport function useLocale() {\n const ctx = useContext(I18nContext)\n if (!ctx) throw new Error('useLocale must be used within I18nProvider')\n return ctx.locale\n}\n\n/**\n * True when the active locale is pinned via `OM_FORCE_LOCALE`. Returns `false`\n * when no provider is in scope so callers can render unconditionally.\n */\nexport function useLocaleLocked() {\n const ctx = useContext(I18nContext)\n return ctx?.localeLocked ?? false\n}\n"],
5
- "mappings": ";AAqES;AApET,SAAS,eAAe,YAAY,eAA+B;AAoBnE,MAAM,mBAAmB;AAMzB,SAAS,iBAAiB;AACxB,QAAM,QAAQ;AACd,MAAI,CAAC,MAAM,gBAAgB,GAAG;AAC5B,UAAM,gBAAgB,IAAI,cAAuC,IAAI;AAAA,EACvE;AACA,SAAO,MAAM,gBAAgB;AAC/B;AAEA,MAAM,cAAc,eAAe;AAEnC,SAAS,OAAO,UAAkB,QAA0B;AAC1D,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,SAAS,QAAQ,4BAA4B,CAAC,GAAG,WAAW,cAAc;AAC/E,UAAM,MAAM,aAAa;AACzB,QAAI,CAAC,IAAK,QAAO;AACjB,UAAM,QAAQ,OAAO,GAAG;AACxB,QAAI,UAAU,QAAW;AACvB,aAAO,YAAY,KAAK,GAAG,OAAO,IAAI,GAAG;AAAA,IAC3C;AACA,WAAO,OAAO,KAAK;AAAA,EACrB,CAAC;AACH;AAEO,SAAS,aAAa,EAAE,UAAU,QAAQ,MAAM,eAAe,MAAM,GAAgF;AAC1J,QAAM,QAAQ,QAA0B,OAAO;AAAA,IAC7C;AAAA,IACA;AAAA,IACA,GAAG,CAAC,KAAK,kBAAkB,WAAW;AACpC,UAAI;AACJ,UAAI;AAEJ,UAAI,OAAO,qBAAqB,UAAU;AACxC,mBAAW;AACX,yBAAiB;AAAA,MACnB,OAAO;AACL,yBAAiB,oBAAoB;AAAA,MACvC;AAEA,YAAM,WAAW,KAAK,GAAG,KAAK,YAAY;AAC1C,aAAO,OAAO,UAAU,cAAc;AAAA,IACxC;AAAA,EACF,IAAI,CAAC,QAAQ,MAAM,YAAY,CAAC;AAChC,SAAO,oBAAC,YAAY,UAAZ,EAAqB,OAAe,UAAS;AACvD;AAEO,SAAS,OAAO;AACrB,QAAM,MAAM,WAAW,WAAW;AAClC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,uCAAuC;AACjE,SAAO,IAAI;AACb;AAQO,SAAS,eAAwC;AACtD,QAAM,MAAM,WAAW,WAAW;AAClC,SAAO,KAAK;AACd;AAEO,SAAS,YAAY;AAC1B,QAAM,MAAM,WAAW,WAAW;AAClC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,4CAA4C;AACtE,SAAO,IAAI;AACb;AAMO,SAAS,kBAAkB;AAChC,QAAM,MAAM,WAAW,WAAW;AAClC,SAAO,KAAK,gBAAgB;AAC9B;",
4
+ "sourcesContent": ["\"use client\"\nimport { createContext, useContext, useMemo, type ReactNode } from 'react'\nimport type { Locale } from './config'\n\nexport type Dict = Record<string, string>\n\nexport type TranslateParams = Record<string, string | number>\n\nexport type TranslateFn = (\n key: string,\n fallbackOrParams?: string | TranslateParams,\n params?: TranslateParams\n) => string\n\nexport type I18nContextValue = {\n locale: Locale\n t: TranslateFn\n /** True when the locale is pinned via `OM_FORCE_LOCALE`; UI should hide switchers. */\n localeLocked: boolean\n}\n\nconst I18N_CONTEXT_KEY = '__openMercatoI18nContext'\n\ntype GlobalI18nContextStore = typeof globalThis & {\n [I18N_CONTEXT_KEY]?: ReturnType<typeof createContext<I18nContextValue | null>>\n}\n\nfunction getI18nContext() {\n const store = globalThis as GlobalI18nContextStore\n if (!store[I18N_CONTEXT_KEY]) {\n store[I18N_CONTEXT_KEY] = createContext<I18nContextValue | null>(null)\n }\n return store[I18N_CONTEXT_KEY]\n}\n\nconst I18nContext = getI18nContext()\n\nfunction format(template: string, params?: TranslateParams) {\n if (!params) return template\n return template.replace(/\\{\\{(\\w+)\\}\\}|\\{(\\w+)\\}/g, (_, doubleKey, singleKey) => {\n const key = doubleKey ?? singleKey\n if (!key) return _\n const value = params[key]\n if (value === undefined) {\n return doubleKey ? `{{${key}}}` : `{${key}}`\n }\n return String(value)\n })\n}\n\nexport function I18nProvider({ children, locale, dict, localeLocked = false }: { children: ReactNode; locale: Locale; dict: Dict; localeLocked?: boolean }) {\n const value = useMemo<I18nContextValue>(() => ({\n locale,\n localeLocked,\n t: (key, fallbackOrParams, params) => {\n let fallback: string | undefined\n let resolvedParams: TranslateParams | undefined\n\n if (typeof fallbackOrParams === 'string') {\n fallback = fallbackOrParams\n resolvedParams = params\n } else {\n resolvedParams = fallbackOrParams ?? params\n }\n\n const template = dict[key] ?? fallback ?? key\n return format(template, resolvedParams)\n },\n }), [locale, dict, localeLocked])\n return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>\n}\n\nexport function useT() {\n const ctx = useContext(I18nContext)\n if (!ctx) throw new Error('useT must be used within I18nProvider')\n return ctx.t\n}\n\n/**\n * Like `useT`, but returns `undefined` instead of throwing when no\n * `I18nProvider` is in scope. Use where a translator is desirable but not\n * guaranteed (e.g. plumbing `t` into side-effect handlers that may run before\n * the provider mounts) \u2014 callers MUST provide a fallback.\n */\nexport function useOptionalT(): TranslateFn | undefined {\n const ctx = useContext(I18nContext)\n return ctx?.t\n}\n\nexport function useLocale() {\n const ctx = useContext(I18nContext)\n if (!ctx) throw new Error('useLocale must be used within I18nProvider')\n return ctx.locale\n}\n\n/**\n * Like `useLocale`, but returns `undefined` instead of throwing when no\n * `I18nProvider` is in scope. Use in shared components that receive their\n * translator as a prop and may render outside a provider (galleries, tests).\n */\nexport function useOptionalLocale(): Locale | undefined {\n const ctx = useContext(I18nContext)\n return ctx?.locale\n}\n\n/**\n * True when the active locale is pinned via `OM_FORCE_LOCALE`. Returns `false`\n * when no provider is in scope so callers can render unconditionally.\n */\nexport function useLocaleLocked() {\n const ctx = useContext(I18nContext)\n return ctx?.localeLocked ?? false\n}\n"],
5
+ "mappings": ";AAqES;AApET,SAAS,eAAe,YAAY,eAA+B;AAoBnE,MAAM,mBAAmB;AAMzB,SAAS,iBAAiB;AACxB,QAAM,QAAQ;AACd,MAAI,CAAC,MAAM,gBAAgB,GAAG;AAC5B,UAAM,gBAAgB,IAAI,cAAuC,IAAI;AAAA,EACvE;AACA,SAAO,MAAM,gBAAgB;AAC/B;AAEA,MAAM,cAAc,eAAe;AAEnC,SAAS,OAAO,UAAkB,QAA0B;AAC1D,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,SAAS,QAAQ,4BAA4B,CAAC,GAAG,WAAW,cAAc;AAC/E,UAAM,MAAM,aAAa;AACzB,QAAI,CAAC,IAAK,QAAO;AACjB,UAAM,QAAQ,OAAO,GAAG;AACxB,QAAI,UAAU,QAAW;AACvB,aAAO,YAAY,KAAK,GAAG,OAAO,IAAI,GAAG;AAAA,IAC3C;AACA,WAAO,OAAO,KAAK;AAAA,EACrB,CAAC;AACH;AAEO,SAAS,aAAa,EAAE,UAAU,QAAQ,MAAM,eAAe,MAAM,GAAgF;AAC1J,QAAM,QAAQ,QAA0B,OAAO;AAAA,IAC7C;AAAA,IACA;AAAA,IACA,GAAG,CAAC,KAAK,kBAAkB,WAAW;AACpC,UAAI;AACJ,UAAI;AAEJ,UAAI,OAAO,qBAAqB,UAAU;AACxC,mBAAW;AACX,yBAAiB;AAAA,MACnB,OAAO;AACL,yBAAiB,oBAAoB;AAAA,MACvC;AAEA,YAAM,WAAW,KAAK,GAAG,KAAK,YAAY;AAC1C,aAAO,OAAO,UAAU,cAAc;AAAA,IACxC;AAAA,EACF,IAAI,CAAC,QAAQ,MAAM,YAAY,CAAC;AAChC,SAAO,oBAAC,YAAY,UAAZ,EAAqB,OAAe,UAAS;AACvD;AAEO,SAAS,OAAO;AACrB,QAAM,MAAM,WAAW,WAAW;AAClC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,uCAAuC;AACjE,SAAO,IAAI;AACb;AAQO,SAAS,eAAwC;AACtD,QAAM,MAAM,WAAW,WAAW;AAClC,SAAO,KAAK;AACd;AAEO,SAAS,YAAY;AAC1B,QAAM,MAAM,WAAW,WAAW;AAClC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,4CAA4C;AACtE,SAAO,IAAI;AACb;AAOO,SAAS,oBAAwC;AACtD,QAAM,MAAM,WAAW,WAAW;AAClC,SAAO,KAAK;AACd;AAMO,SAAS,kBAAkB;AAChC,QAAM,MAAM,WAAW,WAAW;AAClC,SAAO,KAAK,gBAAgB;AAC9B;",
6
6
  "names": []
7
7
  }
package/dist/lib/time.js CHANGED
@@ -15,18 +15,13 @@ function formatRelativeTime(value, options) {
15
15
  const rtf = typeof Intl !== "undefined" && typeof Intl.RelativeTimeFormat === "function" ? new Intl.RelativeTimeFormat(options?.locale, { numeric: "auto" }) : null;
16
16
  const format = (unit, divisor) => {
17
17
  const valueToFormat = Math.round(diffSeconds / divisor);
18
- const isPast = diffSeconds < 0;
19
- if (translate) {
20
- const suffixKey = isPast ? "time.relative.ago" : "time.relative.fromNow";
21
- const fallbackSuffix2 = isPast ? "ago" : "from now";
22
- const suffix = translate(suffixKey, fallbackSuffix2);
23
- const magnitude2 = Math.abs(valueToFormat);
24
- return `${magnitude2} ${unit}${magnitude2 === 1 ? "" : "s"} ${suffix}`;
25
- }
26
18
  if (rtf) return rtf.format(valueToFormat, unit);
19
+ const isPast = diffSeconds < 0;
27
20
  const fallbackSuffix = isPast ? "ago" : "from now";
21
+ const suffixKey = isPast ? "time.relative.ago" : "time.relative.fromNow";
22
+ const suffix = translate ? translate(suffixKey, fallbackSuffix) : fallbackSuffix;
28
23
  const magnitude = Math.abs(valueToFormat);
29
- return `${magnitude} ${unit}${magnitude === 1 ? "" : "s"} ${fallbackSuffix}`;
24
+ return `${magnitude} ${unit}${magnitude === 1 ? "" : "s"} ${suffix}`;
30
25
  };
31
26
  if (absSeconds < 45) return format("second", 1);
32
27
  if (absSeconds < 45 * 60) return format("minute", 60);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/time.ts"],
4
- "sourcesContent": ["\nexport function formatDateTime(value?: string | null): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n return date.toLocaleString()\n}\nexport type RelativeTimeTranslator = (\n key: string,\n fallback?: string,\n params?: Record<string, string | number>\n) => string\n\nexport type FormatRelativeTimeOptions = {\n translate?: RelativeTimeTranslator\n locale?: string | string[]\n}\n\ntype RelativeTimeUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'\n\nexport function formatRelativeTime(\n value?: string | null,\n options?: FormatRelativeTimeOptions\n): string | null {\n if (!value) return null\n\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n\n const now = Date.now()\n const diffSeconds = (date.getTime() - now) / 1000\n const absSeconds = Math.abs(diffSeconds)\n const translate = options?.translate\n\n const rtf =\n typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function'\n ? new Intl.RelativeTimeFormat(options?.locale, { numeric: 'auto' })\n : null\n\nconst format = (unit: RelativeTimeUnit, divisor: number) => {\n const valueToFormat = Math.round(diffSeconds / divisor)\n const isPast = diffSeconds < 0\n\n if (translate) {\n const suffixKey = isPast ? 'time.relative.ago' : 'time.relative.fromNow'\n const fallbackSuffix = isPast ? 'ago' : 'from now'\n const suffix = translate(suffixKey, fallbackSuffix)\n const magnitude = Math.abs(valueToFormat)\n return `${magnitude} ${unit}${magnitude === 1 ? '' : 's'} ${suffix}`\n }\n\n if (rtf) return rtf.format(valueToFormat, unit)\n\n const fallbackSuffix = isPast ? 'ago' : 'from now'\n const magnitude = Math.abs(valueToFormat)\n return `${magnitude} ${unit}${magnitude === 1 ? '' : 's'} ${fallbackSuffix}`\n }\n\n if (absSeconds < 45) return format('second', 1)\n if (absSeconds < 45 * 60) return format('minute', 60)\n if (absSeconds < 24 * 60 * 60) return format('hour', 60 * 60)\n if (absSeconds < 7 * 24 * 60 * 60) return format('day', 24 * 60 * 60)\n if (absSeconds < 30 * 24 * 60 * 60) return format('week', 7 * 24 * 60 * 60)\n if (absSeconds < 365 * 24 * 60 * 60) return format('month', 30 * 24 * 60 * 60)\n return format('year', 365 * 24 * 60 * 60)\n}\n"],
5
- "mappings": "AACO,SAAS,eAAe,OAAsC;AACnE,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,MAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO;AACzC,SAAO,KAAK,eAAe;AAC7B;AAcO,SAAS,mBACd,OACA,SACe;AACf,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,MAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO;AAEzC,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,eAAe,KAAK,QAAQ,IAAI,OAAO;AAC7C,QAAM,aAAa,KAAK,IAAI,WAAW;AACvC,QAAM,YAAY,SAAS;AAE3B,QAAM,MACJ,OAAO,SAAS,eAAe,OAAO,KAAK,uBAAuB,aAC9D,IAAI,KAAK,mBAAmB,SAAS,QAAQ,EAAE,SAAS,OAAO,CAAC,IAChE;AAER,QAAM,SAAS,CAAC,MAAwB,YAAoB;AAC1D,UAAM,gBAAgB,KAAK,MAAM,cAAc,OAAO;AACtD,UAAM,SAAS,cAAc;AAE7B,QAAI,WAAW;AACb,YAAM,YAAY,SAAS,sBAAsB;AACjD,YAAMA,kBAAiB,SAAS,QAAQ;AACxC,YAAM,SAAS,UAAU,WAAWA,eAAc;AAClD,YAAMC,aAAY,KAAK,IAAI,aAAa;AACxC,aAAO,GAAGA,UAAS,IAAI,IAAI,GAAGA,eAAc,IAAI,KAAK,GAAG,IAAI,MAAM;AAAA,IACpE;AAEA,QAAI,IAAK,QAAO,IAAI,OAAO,eAAe,IAAI;AAE9C,UAAM,iBAAiB,SAAS,QAAQ;AACxC,UAAM,YAAY,KAAK,IAAI,aAAa;AACxC,WAAO,GAAG,SAAS,IAAI,IAAI,GAAG,cAAc,IAAI,KAAK,GAAG,IAAI,cAAc;AAAA,EAC1E;AAEA,MAAI,aAAa,GAAI,QAAO,OAAO,UAAU,CAAC;AAC9C,MAAI,aAAa,KAAK,GAAI,QAAO,OAAO,UAAU,EAAE;AACpD,MAAI,aAAa,KAAK,KAAK,GAAI,QAAO,OAAO,QAAQ,KAAK,EAAE;AAC5D,MAAI,aAAa,IAAI,KAAK,KAAK,GAAI,QAAO,OAAO,OAAO,KAAK,KAAK,EAAE;AACpE,MAAI,aAAa,KAAK,KAAK,KAAK,GAAI,QAAO,OAAO,QAAQ,IAAI,KAAK,KAAK,EAAE;AAC1E,MAAI,aAAa,MAAM,KAAK,KAAK,GAAI,QAAO,OAAO,SAAS,KAAK,KAAK,KAAK,EAAE;AAC7E,SAAO,OAAO,QAAQ,MAAM,KAAK,KAAK,EAAE;AAC1C;",
6
- "names": ["fallbackSuffix", "magnitude"]
4
+ "sourcesContent": ["\nexport function formatDateTime(value?: string | null): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n return date.toLocaleString()\n}\nexport type RelativeTimeTranslator = (\n key: string,\n fallback?: string,\n params?: Record<string, string | number>\n) => string\n\nexport type FormatRelativeTimeOptions = {\n /**\n * Supplies the suffix only when the runtime has no `Intl.RelativeTimeFormat`.\n * Otherwise the number, unit name, plural form and suffix all come from\n * `Intl` in `locale` \u2014 pass `useLocale()` client-side so the text follows the\n * application language rather than the runtime one.\n */\n translate?: RelativeTimeTranslator\n locale?: string | string[]\n}\n\ntype RelativeTimeUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'\n\nexport function formatRelativeTime(\n value?: string | null,\n options?: FormatRelativeTimeOptions\n): string | null {\n if (!value) return null\n\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n\n const now = Date.now()\n const diffSeconds = (date.getTime() - now) / 1000\n const absSeconds = Math.abs(diffSeconds)\n const translate = options?.translate\n\n const rtf =\n typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function'\n ? new Intl.RelativeTimeFormat(options?.locale, { numeric: 'auto' })\n : null\n\n const format = (unit: RelativeTimeUnit, divisor: number) => {\n const valueToFormat = Math.round(diffSeconds / divisor)\n if (rtf) return rtf.format(valueToFormat, unit)\n\n const isPast = diffSeconds < 0\n const fallbackSuffix = isPast ? 'ago' : 'from now'\n const suffixKey = isPast ? 'time.relative.ago' : 'time.relative.fromNow'\n const suffix = translate ? translate(suffixKey, fallbackSuffix) : fallbackSuffix\n const magnitude = Math.abs(valueToFormat)\n return `${magnitude} ${unit}${magnitude === 1 ? '' : 's'} ${suffix}`\n }\n\n if (absSeconds < 45) return format('second', 1)\n if (absSeconds < 45 * 60) return format('minute', 60)\n if (absSeconds < 24 * 60 * 60) return format('hour', 60 * 60)\n if (absSeconds < 7 * 24 * 60 * 60) return format('day', 24 * 60 * 60)\n if (absSeconds < 30 * 24 * 60 * 60) return format('week', 7 * 24 * 60 * 60)\n if (absSeconds < 365 * 24 * 60 * 60) return format('month', 30 * 24 * 60 * 60)\n return format('year', 365 * 24 * 60 * 60)\n}\n"],
5
+ "mappings": "AACO,SAAS,eAAe,OAAsC;AACnE,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,MAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO;AACzC,SAAO,KAAK,eAAe;AAC7B;AAoBO,SAAS,mBACd,OACA,SACe;AACf,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,MAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO;AAEzC,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,eAAe,KAAK,QAAQ,IAAI,OAAO;AAC7C,QAAM,aAAa,KAAK,IAAI,WAAW;AACvC,QAAM,YAAY,SAAS;AAE3B,QAAM,MACJ,OAAO,SAAS,eAAe,OAAO,KAAK,uBAAuB,aAC9D,IAAI,KAAK,mBAAmB,SAAS,QAAQ,EAAE,SAAS,OAAO,CAAC,IAChE;AAEN,QAAM,SAAS,CAAC,MAAwB,YAAoB;AAC1D,UAAM,gBAAgB,KAAK,MAAM,cAAc,OAAO;AACtD,QAAI,IAAK,QAAO,IAAI,OAAO,eAAe,IAAI;AAE9C,UAAM,SAAS,cAAc;AAC7B,UAAM,iBAAiB,SAAS,QAAQ;AACxC,UAAM,YAAY,SAAS,sBAAsB;AACjD,UAAM,SAAS,YAAY,UAAU,WAAW,cAAc,IAAI;AAClE,UAAM,YAAY,KAAK,IAAI,aAAa;AACxC,WAAO,GAAG,SAAS,IAAI,IAAI,GAAG,cAAc,IAAI,KAAK,GAAG,IAAI,MAAM;AAAA,EACpE;AAEA,MAAI,aAAa,GAAI,QAAO,OAAO,UAAU,CAAC;AAC9C,MAAI,aAAa,KAAK,GAAI,QAAO,OAAO,UAAU,EAAE;AACpD,MAAI,aAAa,KAAK,KAAK,GAAI,QAAO,OAAO,QAAQ,KAAK,EAAE;AAC5D,MAAI,aAAa,IAAI,KAAK,KAAK,GAAI,QAAO,OAAO,OAAO,KAAK,KAAK,EAAE;AACpE,MAAI,aAAa,KAAK,KAAK,KAAK,GAAI,QAAO,OAAO,QAAQ,IAAI,KAAK,KAAK,EAAE;AAC1E,MAAI,aAAa,MAAM,KAAK,KAAK,GAAI,QAAO,OAAO,SAAS,KAAK,KAAK,KAAK,EAAE;AAC7E,SAAO,OAAO,QAAQ,MAAM,KAAK,KAAK,EAAE;AAC1C;",
6
+ "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.8-develop.6975.1.b21e19d444";
1
+ const APP_VERSION = "0.6.8-develop.6976.1.9b79145ac4";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6975.1.b21e19d444';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6976.1.9b79145ac4';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.8-develop.6975.1.b21e19d444",
3
+ "version": "0.6.8-develop.6976.1.9b79145ac4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -105,7 +105,7 @@
105
105
  "@mikro-orm/core": "^7.1.8",
106
106
  "@mikro-orm/decorators": "^7.1.8",
107
107
  "@mikro-orm/postgresql": "^7.1.8",
108
- "@open-mercato/cache": "0.6.8-develop.6975.1.b21e19d444",
108
+ "@open-mercato/cache": "0.6.8-develop.6976.1.9b79145ac4",
109
109
  "@types/sanitize-html": "^2.16.1",
110
110
  "dotenv": "^17.4.2",
111
111
  "pino": "^10.3.1",
@@ -82,22 +82,54 @@ describe('formatRelativeTime', () => {
82
82
  expect(/year|month/.test(future!)).toBeTruthy()
83
83
  })
84
84
 
85
- it('uses fallback if Intl.RelativeTimeFormat is not available', () => {
86
- const descriptor = Object.getOwnPropertyDescriptor(Intl, 'RelativeTimeFormat')
87
- Object.defineProperty(Intl, 'RelativeTimeFormat', { value: undefined, configurable: true })
88
- try {
89
- expect(formatRelativeTime(sub(3_600_000))).toMatch(/ago/)
90
- expect(formatRelativeTime(add(3_600_000))).toMatch(/from now/)
91
- } finally {
92
- Object.defineProperty(Intl, 'RelativeTimeFormat', descriptor!)
93
- }
94
- })
85
+ it('uses fallback if Intl.RelativeTimeFormat is not available', () => {
86
+ const descriptor = Object.getOwnPropertyDescriptor(Intl, 'RelativeTimeFormat')
87
+ Object.defineProperty(Intl, 'RelativeTimeFormat', { value: undefined, configurable: true })
88
+ try {
89
+ expect(formatRelativeTime(sub(3_600_000))).toMatch(/ago/)
90
+ expect(formatRelativeTime(add(3_600_000))).toMatch(/from now/)
91
+ } finally {
92
+ Object.defineProperty(Intl, 'RelativeTimeFormat', descriptor!)
93
+ }
94
+ })
95
95
 
96
- it('uses custom translate if provided', () => {
97
- const translate = (key: string, fallback?: string) => `T(${key})`
98
- expect(formatRelativeTime(sub(3_600_000), { translate })).toMatch(/T\(time\.relative\.ago\)/)
99
- expect(formatRelativeTime(add(3_600_000), { translate })).toMatch(/T\(time\.relative\.fromNow\)/)
100
- })
96
+ it('uses custom translate for the fallback suffix when Intl.RelativeTimeFormat is not available', () => {
97
+ const translate = (key: string) => `T(${key})`
98
+ const descriptor = Object.getOwnPropertyDescriptor(Intl, 'RelativeTimeFormat')
99
+ Object.defineProperty(Intl, 'RelativeTimeFormat', { value: undefined, configurable: true })
100
+ try {
101
+ expect(formatRelativeTime(sub(3_600_000), { translate })).toMatch(/T\(time\.relative\.ago\)/)
102
+ expect(formatRelativeTime(add(3_600_000), { translate })).toMatch(/T\(time\.relative\.fromNow\)/)
103
+ } finally {
104
+ Object.defineProperty(Intl, 'RelativeTimeFormat', descriptor!)
105
+ }
106
+ })
107
+
108
+ it('localizes unit, number and suffix in a non-English locale', () => {
109
+ expect(formatRelativeTime(sub(3_600_000), { locale: 'pl' })).toBe('1 godzinę temu')
110
+ expect(formatRelativeTime(sub(600_000), { locale: 'pl' })).toBe('10 minut temu')
111
+ expect(formatRelativeTime(add(18_000_000), { locale: 'pl' })).toBe('za 5 godzin')
112
+ })
113
+
114
+ it('localizes in the requested locale even when a translate function is supplied', () => {
115
+ const translate = (_key: string, fallback?: string) => fallback ?? _key
116
+ const polish = formatRelativeTime(sub(600_000), { locale: 'pl', translate })
117
+ const english = formatRelativeTime(sub(600_000), { locale: 'en', translate })
118
+ expect(polish).toBe('10 minut temu')
119
+ expect(polish).not.toMatch(/minutes|ago/)
120
+ expect(english).not.toEqual(polish)
121
+ })
122
+
123
+ it('applies the locale plural rules instead of English pluralization', () => {
124
+ expect(formatRelativeTime(sub(120_000), { locale: 'pl' })).toBe('2 minuty temu')
125
+ expect(formatRelativeTime(sub(300_000), { locale: 'pl' })).toBe('5 minut temu')
126
+ })
127
+
128
+ it('renders differently per locale for the same instant', () => {
129
+ const value = sub(172_800_000)
130
+ const rendered = ['en', 'pl', 'de', 'es'].map((locale) => formatRelativeTime(value, { locale }))
131
+ expect(new Set(rendered).size).toBe(rendered.length)
132
+ })
101
133
  })
102
134
  describe('formatDateTime', () => {
103
135
  it('returns null for invalid or missing values', () => {
@@ -93,6 +93,16 @@ export function useLocale() {
93
93
  return ctx.locale
94
94
  }
95
95
 
96
+ /**
97
+ * Like `useLocale`, but returns `undefined` instead of throwing when no
98
+ * `I18nProvider` is in scope. Use in shared components that receive their
99
+ * translator as a prop and may render outside a provider (galleries, tests).
100
+ */
101
+ export function useOptionalLocale(): Locale | undefined {
102
+ const ctx = useContext(I18nContext)
103
+ return ctx?.locale
104
+ }
105
+
96
106
  /**
97
107
  * True when the active locale is pinned via `OM_FORCE_LOCALE`. Returns `false`
98
108
  * when no provider is in scope so callers can render unconditionally.
package/src/lib/time.ts CHANGED
@@ -12,6 +12,12 @@ export type RelativeTimeTranslator = (
12
12
  ) => string
13
13
 
14
14
  export type FormatRelativeTimeOptions = {
15
+ /**
16
+ * Supplies the suffix only when the runtime has no `Intl.RelativeTimeFormat`.
17
+ * Otherwise the number, unit name, plural form and suffix all come from
18
+ * `Intl` in `locale` — pass `useLocale()` client-side so the text follows the
19
+ * application language rather than the runtime one.
20
+ */
15
21
  translate?: RelativeTimeTranslator
16
22
  locale?: string | string[]
17
23
  }
@@ -37,25 +43,18 @@ export function formatRelativeTime(
37
43
  ? new Intl.RelativeTimeFormat(options?.locale, { numeric: 'auto' })
38
44
  : null
39
45
 
40
- const format = (unit: RelativeTimeUnit, divisor: number) => {
41
- const valueToFormat = Math.round(diffSeconds / divisor)
42
- const isPast = diffSeconds < 0
46
+ const format = (unit: RelativeTimeUnit, divisor: number) => {
47
+ const valueToFormat = Math.round(diffSeconds / divisor)
48
+ if (rtf) return rtf.format(valueToFormat, unit)
43
49
 
44
- if (translate) {
45
- const suffixKey = isPast ? 'time.relative.ago' : 'time.relative.fromNow'
50
+ const isPast = diffSeconds < 0
46
51
  const fallbackSuffix = isPast ? 'ago' : 'from now'
47
- const suffix = translate(suffixKey, fallbackSuffix)
52
+ const suffixKey = isPast ? 'time.relative.ago' : 'time.relative.fromNow'
53
+ const suffix = translate ? translate(suffixKey, fallbackSuffix) : fallbackSuffix
48
54
  const magnitude = Math.abs(valueToFormat)
49
55
  return `${magnitude} ${unit}${magnitude === 1 ? '' : 's'} ${suffix}`
50
56
  }
51
57
 
52
- if (rtf) return rtf.format(valueToFormat, unit)
53
-
54
- const fallbackSuffix = isPast ? 'ago' : 'from now'
55
- const magnitude = Math.abs(valueToFormat)
56
- return `${magnitude} ${unit}${magnitude === 1 ? '' : 's'} ${fallbackSuffix}`
57
- }
58
-
59
58
  if (absSeconds < 45) return format('second', 1)
60
59
  if (absSeconds < 45 * 60) return format('minute', 60)
61
60
  if (absSeconds < 24 * 60 * 60) return format('hour', 60 * 60)