first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '../http/backend-response/backend-response';
import {Localization} from './localization';
import {apiClient} from '../http/query-client';
import {showHttpErrorToast} from '../utils/http/show-http-error-toast';
import {useBootstrapData} from '../core/bootstrap-data/bootstrap-data-context';
export interface ChangeLocaleResponse extends BackendResponse {
locale: Localization;
}
export function useChangeLocale() {
const {mergeBootstrapData} = useBootstrapData();
return useMutation({
mutationFn: (props: {locale?: string}) => changeLocale(props),
onSuccess: response => {
mergeBootstrapData({
i18n: response.locale,
});
},
onError: err => showHttpErrorToast(err),
});
}
function changeLocale(props: {locale?: string}): Promise<ChangeLocaleResponse> {
return apiClient.post(`users/me/locale`, props).then(r => r.data);
}

View File

@@ -0,0 +1,17 @@
import {useSelectedLocale} from '@common/i18n/selected-locale';
import {Fragment, memo} from 'react';
interface Props {
code: string;
}
export const FormattedCountryName = memo(({code: countryCode}: Props) => {
const {localeCode} = useSelectedLocale();
const regionNames = new Intl.DisplayNames([localeCode], {type: 'region'});
let formattedName: string | undefined;
try {
formattedName = regionNames.of(countryCode.toUpperCase());
} catch (e) {}
return <Fragment>{formattedName}</Fragment>;
});

View File

@@ -0,0 +1,22 @@
import {Fragment, memo} from 'react';
import {useNumberFormatter} from './use-number-formatter';
interface FormattedCurrencyProps {
value: number;
currency: string;
}
export const FormattedCurrency = memo(
({value, currency}: FormattedCurrencyProps) => {
const formatter = useNumberFormatter({
style: 'currency',
currency,
currencyDisplay: 'narrowSymbol',
});
if (isNaN(value)) {
value = 0;
}
return <Fragment>{formatter.format(value)}</Fragment>;
}
);

View File

@@ -0,0 +1,54 @@
import {DateValue, parseAbsolute} from '@internationalized/date';
import {Fragment, memo} from 'react';
import {useDateFormatter} from './use-date-formatter';
import {useSettings} from '../core/settings/use-settings';
import {shallowEqual} from '../utils/shallow-equal';
import {useUserTimezone} from './use-user-timezone';
import {DateFormatPresets} from '@common/i18n/formatted-date';
interface FormattedDateTimeRangeProps {
start?: string | DateValue | Date;
end?: string | DateValue | Date;
options?: Intl.DateTimeFormatOptions;
preset?: keyof typeof DateFormatPresets;
}
export const FormattedDateTimeRange = memo(
({start, end, options, preset}: FormattedDateTimeRangeProps) => {
const {dates} = useSettings();
const timezone = useUserTimezone();
const formatter = useDateFormatter(
options ||
(DateFormatPresets as Record<string, Intl.DateTimeFormatOptions>)[
preset || dates?.format
]
);
if (!start || !end) {
return null;
}
let value: string;
try {
value = formatter.formatRange(
castToDate(start, timezone),
castToDate(end, timezone)
);
} catch (e) {
value = '';
}
return <Fragment>{value}</Fragment>;
},
shallowEqual
);
function castToDate(date: string | DateValue | Date, timezone: string): Date {
if (typeof date === 'string') {
return parseAbsolute(date, timezone).toDate();
}
if ('toDate' in date) {
return date.toDate(timezone);
}
return date;
}

View File

@@ -0,0 +1,59 @@
import {DateValue, parseAbsoluteToLocal} from '@internationalized/date';
import {Fragment, memo} from 'react';
import {useDateFormatter} from './use-date-formatter';
import {shallowEqual} from '../utils/shallow-equal';
import {useSettings} from '../core/settings/use-settings';
import {useUserTimezone} from './use-user-timezone';
export const DateFormatPresets: Record<
'numeric' | 'short' | 'long' | 'timestamp',
Intl.DateTimeFormatOptions
> = {
numeric: {year: 'numeric', month: '2-digit', day: '2-digit'},
short: {year: 'numeric', month: 'short', day: '2-digit'},
long: {month: 'long', day: '2-digit', year: 'numeric'},
timestamp: {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
},
};
interface FormattedDateProps {
date?: string | DateValue | Date;
options?: Intl.DateTimeFormatOptions;
preset?: keyof typeof DateFormatPresets;
}
export const FormattedDate = memo(
({date, options, preset}: FormattedDateProps) => {
const {dates} = useSettings();
const timezone = useUserTimezone();
const formatter = useDateFormatter(
options ||
(DateFormatPresets as Record<string, Intl.DateTimeFormatOptions>)[
preset || dates?.format
],
);
if (!date) {
return null;
}
// make sure date with invalid format does not blow up the app
try {
if (typeof date === 'string') {
date = parseAbsoluteToLocal(date).toDate();
} else if ('toDate' in date) {
date = date.toDate(timezone);
}
} catch (e) {
return null;
}
return <Fragment>{formatter.format(date as Date)}</Fragment>;
},
shallowEqual,
);

View File

@@ -0,0 +1,100 @@
import {Fragment, memo} from 'react';
import {useTrans, UseTransReturn} from '@common/i18n/use-trans';
import {message} from '@common/i18n/message';
interface ParsedMS {
days: number;
hours: number;
minutes: number;
seconds: number;
}
interface FormattedTrackDurationProps {
ms?: number;
minutes?: number;
seconds?: number;
verbose?: boolean;
addZeroToFirstUnit?: boolean;
}
export const FormattedDuration = memo(
({
minutes,
seconds,
ms,
verbose = false,
addZeroToFirstUnit = true,
}: FormattedTrackDurationProps) => {
const {trans} = useTrans();
if (minutes) {
ms = minutes * 60000;
} else if (seconds) {
ms = seconds * 1000;
}
if (!ms) {
ms = 0;
}
const unsignedMs = ms < 0 ? -ms : ms;
const parsedMS: ParsedMS = {
days: Math.trunc(unsignedMs / 86400000),
hours: Math.trunc(unsignedMs / 3600000) % 24,
minutes: Math.trunc(unsignedMs / 60000) % 60,
seconds: Math.trunc(unsignedMs / 1000) % 60,
};
let formattedValue: string;
if (verbose) {
formattedValue = formatVerbose(parsedMS, trans);
} else {
formattedValue = formatCompact(parsedMS, addZeroToFirstUnit);
}
return <Fragment>{formattedValue}</Fragment>;
}
);
function formatVerbose(t: ParsedMS, trans: UseTransReturn['trans']) {
const output: string[] = [];
if (t.days) {
output.push(`${t.days}${trans(message('d'))}`);
}
if (t.hours) {
output.push(`${t.hours}${trans(message('hr'))}`);
}
if (t.minutes) {
output.push(`${t.minutes}${trans(message('min'))}`);
}
if (t.seconds && !t.hours) {
output.push(`${t.seconds}${trans(message('sec'))}`);
}
return output.join(' ');
}
function formatCompact(t: ParsedMS, addZeroToFirstUnit = true) {
const seconds = addZero(t.seconds);
let output = '';
if (t.days && !output) {
output = `${t.days}:${addZero(t.hours)}:${addZero(t.minutes)}:${seconds}`;
}
if (t.hours && !output) {
output = `${addZero(t.hours, addZeroToFirstUnit)}:${addZero(
t.minutes
)}:${seconds}`;
}
if (!output) {
output = `${addZero(t.minutes, addZeroToFirstUnit)}:${seconds}`;
}
return output;
}
function addZero(v: number, addZero = true) {
if (!addZero) return v;
let value = `${v}`;
if (value.length === 1) {
value = '0' + value;
}
return value;
}

View File

@@ -0,0 +1,20 @@
import {Fragment, memo} from 'react';
import {useNumberFormatter} from './use-number-formatter';
import {NumberFormatOptions} from '@internationalized/number';
import {shallowEqual} from '../utils/shallow-equal';
interface FormattedNumberProps extends NumberFormatOptions {
value: number;
}
export const FormattedNumber = memo(
({value, ...options}: FormattedNumberProps) => {
const formatter = useNumberFormatter(options);
if (isNaN(value)) {
value = 0;
}
return <Fragment>{formatter.format(value)}</Fragment>;
},
shallowEqual
);

View File

@@ -0,0 +1,42 @@
import {FormattedCurrency} from './formatted-currency';
import React from 'react';
import {Price} from '../billing/price';
import {Trans} from './trans';
import clsx from 'clsx';
interface FormattedPriceProps {
price?: Omit<Price, 'id'>;
variant?: 'slash' | 'separateLine';
className?: string;
priceClassName?: string;
periodClassName?: string;
}
export function FormattedPrice({
price,
variant = 'slash',
className,
priceClassName,
periodClassName,
}: FormattedPriceProps) {
if (!price) return null;
const translatedInterval = <Trans message={price.interval} />;
return (
<div className={clsx('flex gap-6 items-center', className)}>
<div className={priceClassName}>
<FormattedCurrency
value={price.amount / (price.interval_count ?? 1)}
currency={price.currency}
/>
</div>
{variant === 'slash' ? (
<div className={periodClassName}> / {translatedInterval}</div>
) : (
<div className={periodClassName}>
<Trans message="per" /> <br /> {translatedInterval}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
import {DateValue, parseAbsoluteToLocal} from '@internationalized/date';
import {Fragment, memo, useMemo} from 'react';
import {shallowEqual} from '@common/utils/shallow-equal';
import {useSelectedLocale} from '@common/i18n/selected-locale';
import {useUserTimezone} from '@common/i18n/use-user-timezone';
import {Trans} from '@common/i18n/trans';
const DIVISIONS: {amount: number; name: Intl.RelativeTimeFormatUnit}[] = [
{amount: 60, name: 'seconds'},
{amount: 60, name: 'minutes'},
{amount: 24, name: 'hours'},
{amount: 7, name: 'days'},
{amount: 4.34524, name: 'weeks'},
{amount: 12, name: 'months'},
{amount: Number.POSITIVE_INFINITY, name: 'years'},
];
interface FormattedDateProps {
date?: string | DateValue | Date;
style?: Intl.RelativeTimeFormatStyle;
}
export const FormattedRelativeTime = memo(
({date, style}: FormattedDateProps) => {
const {localeCode} = useSelectedLocale();
const timezone = useUserTimezone();
const formatter = useMemo(
() =>
new Intl.RelativeTimeFormat(localeCode, {
numeric: 'auto',
style,
}),
[localeCode, style]
);
if (!date) {
return null;
}
// make sure date with invalid format does not blow up the app
try {
if (typeof date === 'string') {
date = parseAbsoluteToLocal(date).toDate();
} else if ('toDate' in date) {
date = date.toDate(timezone);
}
} catch (e) {
return null;
}
let duration = (date.getTime() - Date.now()) / 1000;
for (let i = 0; i <= DIVISIONS.length; i++) {
const division = DIVISIONS[i];
if (Math.abs(duration) < division.amount) {
if (division.name === 'seconds') {
return <Trans message="a few seconds ago" />;
}
return (
<Fragment>
{formatter.format(Math.round(duration), division.name)}
</Fragment>
);
}
duration /= division.amount;
}
return <Fragment>{formatter.format(Math.round(duration), 'day')}</Fragment>;
},
shallowEqual
);

View File

@@ -0,0 +1,13 @@
import {getBootstrapData} from '../core/bootstrap-data/use-backend-bootstrap-data';
import {getLocalTimeZone} from '@internationalized/date';
export function getUserTimezone(): string {
const defaultTimezone = getBootstrapData()?.settings.dates.default_timezone;
const preferredTimezone =
getBootstrapData()?.user?.timezone || defaultTimezone || 'auto';
if (!preferredTimezone || preferredTimezone === 'auto') {
return getLocalTimeZone();
}
return preferredTimezone;
}

View File

@@ -0,0 +1,40 @@
import memoize from 'nano-memoize';
import {MessageDescriptor} from './message-descriptor';
// this will get memoized by enclosing function (<Trans> or useTrans)
export function handlePluralMessage(
localeCode: string,
{message, values}: MessageDescriptor
): string {
// find plural config e.g. [one 1 item|other :count items]
const match = message.match(/\[(.+?)]/);
const count = values?.count;
if (match && match[1] && !Number.isNaN(count)) {
// get config without brackets and split by pipe e.g. [one 1 item, other :count items]
const [pluralPlaceholder, pluralConfig] = match;
const choices = pluralConfig.split('|');
if (!choices.length) return message;
// use Intl.PluralRules to determine which choice to use, based on special "count" value
const rules = getRules(localeCode);
const choiceName = rules.select(count as number);
// find the correct choice from config, or use first one
let choiceConfig = choices.find(c => {
return c.startsWith(choiceName);
});
if (!choiceConfig) {
choiceConfig = choices[0];
}
// get rid of plural prefix e.g. one 1 item => 1 item
const choice = choiceConfig.substring(choiceConfig.indexOf(' ') + 1);
return message.replace(pluralPlaceholder, choice);
}
return message;
}
const getRules = memoize((localeCode: string) => {
return new Intl.PluralRules(localeCode);
});

View File

@@ -0,0 +1,51 @@
import {useValueLists} from '../http/value-lists';
import {Button} from '../ui/buttons/button';
import {LanguageIcon} from '../icons/material/Language';
import {KeyboardArrowDownIcon} from '../icons/material/KeyboardArrowDown';
import {useSelectedLocale} from './selected-locale';
import {Menu, MenuItem, MenuTrigger} from '../ui/navigation/menu/menu-trigger';
import {useChangeLocale} from './change-locale';
import {useSettings} from '../core/settings/use-settings';
export function LocaleSwitcher() {
const {locale} = useSelectedLocale();
const changeLocale = useChangeLocale();
const {data} = useValueLists(['localizations']);
const {i18n} = useSettings();
if (!data?.localizations || !locale || !i18n.enable) return null;
return (
<MenuTrigger
floatingWidth="matchTrigger"
selectionMode="single"
selectedValue={locale.language}
onSelectionChange={value => {
const newLocale = value as string;
if (newLocale !== locale?.language) {
changeLocale.mutate({locale: newLocale});
}
}}
>
<Button
disabled={changeLocale.isPending}
className="capitalize"
startIcon={<LanguageIcon />}
endIcon={<KeyboardArrowDownIcon />}
>
{locale.name}
</Button>
<Menu>
{data.localizations.map(localization => (
<MenuItem
value={localization.language}
key={localization.language}
className="capitalize"
>
{localization.name}
</MenuItem>
))}
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,8 @@
export interface Localization {
id: number;
name: string;
language: string;
lines?: Record<string, string>;
created_at?: string;
updated_at?: string;
}

View File

@@ -0,0 +1,14 @@
import {ReactElement} from 'react';
export interface MessageDescriptor {
message: string;
values?: Record<
string,
| string
| number
| null
| undefined
| ReactElement
| ((parts: string) => ReactElement)
>;
}

View File

@@ -0,0 +1,6 @@
import {MessageDescriptor} from './message-descriptor';
interface MessageProps extends Omit<MessageDescriptor, 'message'> {}
export function message(msg: string, props?: MessageProps): MessageDescriptor {
return {...props, message: msg};
}

View File

@@ -0,0 +1,16 @@
import {MessageDescriptor} from './message-descriptor';
import {Trans} from './trans';
import {Fragment} from 'react';
interface Props {
value?: string | MessageDescriptor | null;
}
export function MixedText({value}: Props) {
if (!value) {
return null;
}
if (typeof value === 'string') {
return <Fragment>{value}</Fragment>;
}
return <Trans {...value} />;
}

View File

@@ -0,0 +1,12 @@
import {useBootstrapData} from '../core/bootstrap-data/bootstrap-data-context';
export function useSelectedLocale() {
const {
data: {i18n},
} = useBootstrapData();
return {
locale: i18n,
localeCode: i18n?.language || 'en',
lines: i18n?.lines,
};
}

View File

@@ -0,0 +1,120 @@
import {cloneElement, Fragment, isValidElement, memo} from 'react';
import {shallowEqual} from '../utils/shallow-equal';
import {useSelectedLocale} from './selected-locale';
import {handlePluralMessage} from './handle-plural-message';
import {MessageDescriptor} from './message-descriptor';
function hasOwn(obj: any, key: string): boolean {
if (obj == null) {
return false;
}
if (Object.hasOwn !== undefined) {
return Object.hasOwn(obj, key);
}
return Object.hasOwnProperty(key);
}
export const Trans = memo((props: MessageDescriptor) => {
const {message: initialMessage, values} = props;
const {lines, localeCode} = useSelectedLocale();
let translatedMessage: string | undefined;
if (hasOwn(lines, initialMessage)) {
translatedMessage = lines?.[initialMessage];
} else if (hasOwn(lines, initialMessage?.toLowerCase())) {
translatedMessage = lines?.[initialMessage.toLowerCase()];
} else {
translatedMessage = initialMessage;
}
if (!values || !translatedMessage) {
return <Fragment>{translatedMessage}</Fragment>;
}
translatedMessage = handlePluralMessage(localeCode, {
message: translatedMessage,
values,
});
// placeholders that need to be replaced with react element, eg. <Icon/>
const nodePlaceholders: string[] = [];
// placeholders that need to be replaced with render fn, eg. <a>link text</a>
const tagNames: string[] = [];
Object.entries(values).forEach(([key, value]) => {
// value is react render function
if (typeof value === 'function') {
tagNames.push(key);
// value is react element
} else if (isValidElement(value)) {
nodePlaceholders.push(key);
// value is primitive, can do simple string replace
} else if (value != undefined) {
translatedMessage = translatedMessage?.replace(`:${key}`, `${value}`);
}
});
// if we need to replace placeholder with react element or render fn, we will need to split the
// string by these placeholders and replace static string values with matching react element value
if (tagNames.length || nodePlaceholders.length) {
// we'll build simple OR regex to split the string eg. (<[ab]>content</[ab]>)|({(?:icon|link)})
const regexArray: string[] = [];
if (tagNames.length) {
const tagNameMatchers = tagNames.join('');
regexArray.push(`(<[${tagNameMatchers}]>.+?<\\/[${tagNameMatchers}]>)`);
}
if (nodePlaceholders.length) {
const nodePlaceholderMatchers = nodePlaceholders.join('|');
regexArray.push(`(\:(?:${nodePlaceholderMatchers}))`);
}
const regex = new RegExp(regexArray.join('|'), 'gm');
const parts = translatedMessage.split(regex);
// get rid of any empty strings or undefined from split by regex
const compiledMessage = parts.filter(Boolean).map((part, i) => {
// it's a tag name placeholder, eg. <a>content</a>
if (part.startsWith('<') && part.endsWith('>')) {
// grab tag content
const matches = part.match(/<([a-z]+)>(.+?)<\/([a-z]+)>/);
if (matches) {
const [, tagName, content] = matches;
const renderFn = values?.[tagName];
if (typeof renderFn === 'function') {
// pass it to render fn from values
const node = renderFn(content);
// add a key to avoid react errors
return cloneElement(node, {key: i});
}
}
}
// it's a regular placeholder with react element value, eg. {icon}
if (part.startsWith(':')) {
const key = part.replace(':', '');
const node = values?.[key];
if (isValidElement(node)) {
return cloneElement(node, {key: i});
}
}
// it's a regular string
return part;
});
return <Fragment>{compiledMessage}</Fragment>;
}
return <Fragment>{translatedMessage}</Fragment>;
}, areEqual);
export function areEqual<T extends MessageDescriptor = MessageDescriptor>(
prevProps: T,
nextProps: T,
): boolean {
const {values, ...otherProps} = prevProps;
const {values: nextValues, ...nextOtherProps} = nextProps;
return (
shallowEqual(nextValues, values) &&
shallowEqual(otherProps as any, nextOtherProps)
);
}

View File

@@ -0,0 +1,23 @@
import {useSelectedLocale} from './selected-locale';
const cache = new Map<string, Intl.Collator>();
export function useCollator(options?: Intl.CollatorOptions): Intl.Collator {
const {localeCode} = useSelectedLocale();
const cacheKey =
localeCode +
(options
? Object.entries(options)
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.join()
: '');
if (cache.has(cacheKey)) {
return cache.get(cacheKey)!;
}
const formatter = new Intl.Collator(localeCode, options);
cache.set(cacheKey, formatter);
return formatter;
}

View File

@@ -0,0 +1,10 @@
import {useMemo} from 'react';
import {now} from '@internationalized/date';
import {useUserTimezone} from './use-user-timezone';
export function useCurrentDateTime() {
const timezone = useUserTimezone();
return useMemo(() => {
return now(timezone);
}, [timezone]);
}

View File

@@ -0,0 +1,28 @@
import {DateFormatter} from '@internationalized/date';
import {useMemo, useRef} from 'react';
import {useSelectedLocale} from './selected-locale';
import {shallowEqual} from '../utils/shallow-equal';
export function useDateFormatter(
options?: Intl.DateTimeFormatOptions
): DateFormatter {
// Reuse last options object if it is shallowly equal, which allows the useMemo result to also be reused.
const lastOptions = useRef<Intl.DateTimeFormatOptions | undefined | null>(
null
);
if (
options &&
lastOptions.current &&
shallowEqual(options as any, lastOptions.current)
) {
options = lastOptions.current;
}
lastOptions.current = options;
const {localeCode} = useSelectedLocale();
return useMemo(
() => new DateFormatter(localeCode, options),
[localeCode, options]
);
}

View File

@@ -0,0 +1,59 @@
import {useCollator} from './use-collator';
interface Filter {
/** Returns whether a string starts with a given substring. */
startsWith(string: string, substring: string): boolean;
/** Returns whether a string ends with a given substring. */
endsWith(string: string, substring: string): boolean;
/** Returns whether a string contains a given substring. */
contains(string: string, substring: string): boolean;
}
export function useFilter(options?: Intl.CollatorOptions): Filter {
const collator = useCollator({
usage: 'search',
...options,
});
return {
startsWith(string, substring) {
if (substring.length === 0) {
return true;
}
string = string.normalize('NFC');
substring = substring.normalize('NFC');
return (
collator.compare(string.slice(0, substring.length), substring) === 0
);
},
endsWith(string, substring) {
if (substring.length === 0) {
return true;
}
string = string.normalize('NFC');
substring = substring.normalize('NFC');
return collator.compare(string.slice(-substring.length), substring) === 0;
},
contains(string, substring) {
if (substring.length === 0) {
return true;
}
string = string.normalize('NFC');
substring = substring.normalize('NFC');
let scan = 0;
const sliceLen = substring.length;
for (; scan + sliceLen <= string.length; scan++) {
const slice = string.slice(scan, scan + sliceLen);
if (collator.compare(substring, slice) === 0) {
return true;
}
}
return false;
},
};
}

View File

@@ -0,0 +1,13 @@
import {NumberFormatOptions, NumberFormatter} from '@internationalized/number';
import {useMemo} from 'react';
import {useSelectedLocale} from './selected-locale';
export function useNumberFormatter(
options: NumberFormatOptions = {}
): Intl.NumberFormat {
const {localeCode} = useSelectedLocale();
return useMemo(
() => new NumberFormatter(localeCode, options),
[localeCode, options]
);
}

View File

@@ -0,0 +1,51 @@
import {useCallback} from 'react';
import memoize from 'nano-memoize';
import {useSelectedLocale} from './selected-locale';
import {handlePluralMessage} from './handle-plural-message';
import {MessageDescriptor} from './message-descriptor';
import {shallowEqual} from '../utils/shallow-equal';
export interface UseTransReturn {
trans: (props: MessageDescriptor) => string;
}
export function useTrans(): UseTransReturn {
const {lines, localeCode} = useSelectedLocale();
const trans = useCallback(
(props: MessageDescriptor): string => {
return translate({...props, lines, localeCode});
},
[lines, localeCode],
);
return {trans};
}
interface TranslateProps extends MessageDescriptor {
lines?: Record<string, string>;
localeCode: string;
}
const translate = memoize(
(props: TranslateProps) => {
let {lines, message, values, localeCode} = props;
if (message == null) {
return '';
}
message = lines?.[message] || lines?.[message.toLowerCase()] || message;
if (!values) {
return message;
}
message = handlePluralMessage(localeCode, props);
Object.entries(values).forEach(([key, value]) => {
message = message.replace(`:${key}`, `${value}`);
});
return message;
},
{equals: shallowEqual, callTimeout: 0},
);

View File

@@ -0,0 +1,17 @@
import {useContext, useMemo} from 'react';
import {BoostrapDataContext} from '../core/bootstrap-data/bootstrap-data-context';
import {getLocalTimeZone} from '@internationalized/date';
export function useUserTimezone(): string {
const {
data: {user, settings},
} = useContext(BoostrapDataContext);
const defaultTimezone = settings.dates.default_timezone;
const preferredTimezone = user?.timezone || defaultTimezone || 'auto';
return useMemo(() => {
return !preferredTimezone || preferredTimezone === 'auto'
? getLocalTimeZone()
: preferredTimezone;
}, [preferredTimezone]);
}