Skip to content

Commit d647a10

Browse files
committed
feat(WIP): add user config for dates and hours format
1 parent b79b5ec commit d647a10

File tree

9 files changed

+501
-49
lines changed

9 files changed

+501
-49
lines changed

packages/app-builder/src/repositories/SessionStorageRepositories/LngStorageRepository.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { createCookie, createCookieSessionStorage } from '@remix-run/node';
22

33
import { type SessionStorageRepositoryOptions } from './SessionStorageRepository';
44

5-
export function getLngStorageRepository({
5+
export function getUserPreferencesStorageRepository({
66
secrets,
77
secure,
88
}: Omit<SessionStorageRepositoryOptions, 'maxAge'>) {
9-
const lngCookie = createCookie('lng', {
9+
const userPreferencesCookie = createCookie('user-preferences', {
1010
sameSite: 'lax', // this helps with CSRF
1111
path: '/', // remember to add this so the cookie will work in all routes
1212
httpOnly: true,
@@ -15,13 +15,21 @@ export function getLngStorageRepository({
1515
});
1616

1717
// export the whole sessionStorage object
18-
const lngStorage = createCookieSessionStorage<{
18+
const userPreferencesStorage = createCookieSessionStorage<{
1919
lng: string;
20+
dateFormat?: string;
21+
hoursFormat?: string;
2022
}>({
21-
cookie: lngCookie,
23+
cookie: userPreferencesCookie,
2224
});
2325

24-
return { lngStorage };
26+
return { userPreferencesStorage };
2527
}
2628

27-
export type LngStorageRepository = ReturnType<typeof getLngStorageRepository>;
29+
export type UserPreferencesStorageRepository = ReturnType<
30+
typeof getUserPreferencesStorageRepository
31+
>;
32+
33+
// Legacy export for backward compatibility
34+
export const getLngStorageRepository = getUserPreferencesStorageRepository;
35+
export type LngStorageRepository = UserPreferencesStorageRepository;

packages/app-builder/src/repositories/init.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ import { makeGetScenarioRepository } from './ScenarioRepository';
2828
import {
2929
getAuthStorageRepository,
3030
getCsrfCookie,
31-
getLngStorageRepository,
3231
getToastStorageRepository,
32+
getUserPreferencesStorageRepository,
3333
type SessionStorageRepositoryOptions,
3434
} from './SessionStorageRepositories';
3535
import { makeGetTestRunRepository } from './TestRunRepository';
@@ -57,7 +57,7 @@ export function makeServerRepositories({
5757
authStorageRepository: getAuthStorageRepository(sessionStorageRepositoryOptions),
5858
csrfCookie: getCsrfCookie(sessionStorageRepositoryOptions),
5959
toastStorageRepository: getToastStorageRepository(sessionStorageRepositoryOptions),
60-
lngStorageRepository: getLngStorageRepository(sessionStorageRepositoryOptions),
60+
lngStorageRepository: getUserPreferencesStorageRepository(sessionStorageRepositoryOptions),
6161
getFeatureAccessApiClientWithoutAuth,
6262
getFeatureAccessAPIClientWithAuth,
6363
marbleCoreApiClient,

packages/app-builder/src/root.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
8383
const disableSegment = getServerEnv('DISABLE_SEGMENT') ?? false;
8484
const appConfig = await appConfigRepository.getAppConfig();
8585

86+
const userPreferences = await i18nextService.getUserPreferences(request);
87+
8688
return Response.json(
8789
{
8890
ENV,
@@ -91,6 +93,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
9193
toastMessage,
9294
segmentScript: !disableSegment && segmentApiKey ? getSegmentScript(segmentApiKey) : undefined,
9395
appConfig,
96+
userPreferences,
9497
},
9598
{
9699
headers,
Lines changed: 136 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,70 @@
1-
import { supportedLngs } from '@app-builder/services/i18n/i18n-config';
1+
import {
2+
sampleDateFormats,
3+
sampleHoursFormats,
4+
supportedLngs,
5+
} from '@app-builder/services/i18n/i18n-config';
26
import { initServerServices } from '@app-builder/services/init.server';
37
import { parseForm } from '@app-builder/utils/input-validation';
48
import { getRoute } from '@app-builder/utils/routes';
5-
import { type ActionFunctionArgs, json } from '@remix-run/node';
9+
import { type ActionFunctionArgs } from '@remix-run/node';
610
import { useFetcher } from '@remix-run/react';
711
import { useTranslation } from 'react-i18next';
812
import { redirectBack } from 'remix-utils/redirect-back';
9-
import { Select } from 'ui-design-system';
13+
import { Button, MenuCommand } from 'ui-design-system';
1014
import * as z from 'zod';
1115

1216
import { setToastMessage } from '../../../components/MarbleToaster';
17+
import { useFormatPreferencesHook } from '../../../utils/format';
1318

1419
const formSchema = z.object({
15-
preferredLanguage: z.enum(supportedLngs),
20+
preferredLanguage: z.enum(supportedLngs).optional(),
21+
preferredDate: z.enum(Object.keys(sampleDateFormats) as [string, ...string[]]).optional(),
22+
preferredHours: z.enum(Object.keys(sampleHoursFormats) as [string, ...string[]]).optional(),
1623
});
1724

25+
// Helper function to get human-readable language names
26+
function getLanguageDisplayName(languageCode: string): string {
27+
const languageNames: Record<string, string> = {
28+
en: 'English',
29+
fr: 'Français',
30+
ar: 'العربية',
31+
};
32+
33+
return languageNames[languageCode] ?? languageCode;
34+
}
35+
1836
export async function action({ request }: ActionFunctionArgs) {
1937
const { i18nextService, toastSessionService } = initServerServices(request);
2038

2139
try {
22-
const { preferredLanguage } = await parseForm(request, formSchema);
40+
const { preferredLanguage, preferredDate, preferredHours } = await parseForm(
41+
request,
42+
formSchema,
43+
);
44+
45+
const headers = new Headers();
2346

24-
const { cookie } = await i18nextService.setLanguage(request, preferredLanguage);
47+
// Set language if provided
48+
if (preferredLanguage) {
49+
const { cookie } = await i18nextService.setLanguage(request, preferredLanguage);
50+
headers.append('Set-Cookie', cookie);
51+
}
52+
53+
// Set date format if provided
54+
if (preferredDate) {
55+
const { cookie } = await i18nextService.setDateFormat(request, preferredDate);
56+
headers.append('Set-Cookie', cookie);
57+
}
58+
59+
// Set hours format if provided
60+
if (preferredHours) {
61+
const { cookie } = await i18nextService.setHoursFormat(request, preferredHours);
62+
headers.append('Set-Cookie', cookie);
63+
}
2564

2665
return redirectBack(request, {
2766
fallback: getRoute('/scenarios'),
28-
headers: {
29-
'Set-Cookie': cookie,
30-
},
67+
headers,
3168
});
3269
} catch (_error) {
3370
const toastSession = await toastSessionService.getSession(request);
@@ -36,7 +73,7 @@ export async function action({ request }: ActionFunctionArgs) {
3673
messageKey: 'common:errors.unknown',
3774
});
3875

39-
return json(
76+
return Response.json(
4077
{
4178
success: false as const,
4279
},
@@ -56,27 +93,99 @@ export function LanguagePicker() {
5693
const {
5794
i18n: { language },
5895
} = useTranslation<'common'>();
96+
const formatPreferences = useFormatPreferencesHook();
5997
const fetcher = useFetcher<typeof action>();
6098

6199
if (supportedLngs.every((lng: string) => lng.startsWith('en'))) return null;
62100

101+
console.log('language', language);
63102
return (
64-
<Select.Default
65-
value={language}
66-
onValueChange={(newPreferredLanguage) => {
67-
fetcher.submit(
68-
{ preferredLanguage: newPreferredLanguage },
69-
{ method: 'POST', action: getRoute('/ressources/user/language') },
70-
);
71-
}}
72-
>
73-
{supportedLngs.map((lng) => {
74-
return (
75-
<Select.DefaultItem key={lng} value={lng}>
76-
{lng}
77-
</Select.DefaultItem>
78-
);
79-
})}
80-
</Select.Default>
103+
<div className="flex flex-col gap-2">
104+
<MenuCommand.Menu>
105+
<MenuCommand.Trigger>
106+
<Button variant="secondary" className="h-10 gap-2">
107+
{getLanguageDisplayName(language)}
108+
<MenuCommand.Arrow />
109+
</Button>
110+
</MenuCommand.Trigger>
111+
<MenuCommand.Content sameWidth>
112+
<MenuCommand.List>
113+
{supportedLngs.map((lng) => {
114+
return (
115+
<MenuCommand.Item
116+
key={lng}
117+
value={lng}
118+
onSelect={(selectedLanguage) => {
119+
fetcher.submit(
120+
{ preferredLanguage: selectedLanguage },
121+
{ method: 'POST', action: getRoute('/ressources/user/language') },
122+
);
123+
}}
124+
>
125+
{getLanguageDisplayName(lng)}
126+
</MenuCommand.Item>
127+
);
128+
})}
129+
</MenuCommand.List>
130+
</MenuCommand.Content>
131+
</MenuCommand.Menu>
132+
<MenuCommand.Menu>
133+
<MenuCommand.Trigger>
134+
<Button variant="secondary" className="h-10 gap-2">
135+
{formatPreferences.dateFormatDisplay || 'Unknown Format'}
136+
<MenuCommand.Arrow />
137+
</Button>
138+
</MenuCommand.Trigger>
139+
<MenuCommand.Content sameWidth>
140+
<MenuCommand.List>
141+
{Object.values(sampleDateFormats).map((dateFormat) => {
142+
return (
143+
<MenuCommand.Item
144+
key={dateFormat.value}
145+
value={dateFormat.value}
146+
onSelect={(selectedDate) => {
147+
fetcher.submit(
148+
{ preferredDate: selectedDate },
149+
{ method: 'POST', action: getRoute('/ressources/user/language') },
150+
);
151+
}}
152+
>
153+
{dateFormat.displayName}
154+
</MenuCommand.Item>
155+
);
156+
})}
157+
</MenuCommand.List>
158+
</MenuCommand.Content>
159+
</MenuCommand.Menu>
160+
<MenuCommand.Menu>
161+
<MenuCommand.Trigger>
162+
<Button variant="secondary" className="h-10 gap-2">
163+
{formatPreferences.hoursFormatDisplay || 'Unknown Format'}
164+
<MenuCommand.Arrow />
165+
</Button>
166+
</MenuCommand.Trigger>
167+
168+
<MenuCommand.Content sameWidth>
169+
<MenuCommand.List>
170+
{Object.values(sampleHoursFormats).map((hoursFormat) => {
171+
return (
172+
<MenuCommand.Item
173+
key={hoursFormat.value}
174+
value={hoursFormat.value}
175+
onSelect={(selectedHours) => {
176+
fetcher.submit(
177+
{ preferredHours: selectedHours },
178+
{ method: 'POST', action: getRoute('/ressources/user/language') },
179+
);
180+
}}
181+
>
182+
{hoursFormat.displayName}
183+
</MenuCommand.Item>
184+
);
185+
})}
186+
</MenuCommand.List>
187+
</MenuCommand.Content>
188+
</MenuCommand.Menu>
189+
</div>
81190
);
82191
}

packages/app-builder/src/services/i18n/i18n-config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,21 @@ export function getDateFnsLocale(locale: string): Locale {
3939
const supportedLocale = supportedLngs.find((lng) => lang === lng) ?? fallbackLng;
4040
return dateFnsLocales[supportedLocale];
4141
}
42+
43+
/** Dates and hours formats */
44+
export const dateFormatsLocales = {
45+
en: 'en-GB',
46+
fr: 'fr-FR',
47+
ar: 'ar-SA',
48+
} satisfies Record<(typeof supportedLngs)[number], string>;
49+
50+
export const sampleDateFormats = {
51+
'dd/MM/yyyy': { value: 'dd/MM/yyyy', displayName: 'European (DD/MM/YYYY)' },
52+
'MM/dd/yyyy': { value: 'MM/dd/yyyy', displayName: 'US (MM/DD/YYYY)' },
53+
'yyyy-MM-dd': { value: 'yyyy-MM-dd', displayName: 'ISO (YYYY-MM-DD)' },
54+
} as const;
55+
56+
export const sampleHoursFormats = {
57+
'HH:mm': { value: 'HH:mm', displayName: '24 hours' },
58+
'hh:mm': { value: 'hh:mm', displayName: '12 hours' },
59+
} as const;

packages/app-builder/src/services/i18n/i18next.server.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type LngStorageRepository } from '@app-builder/repositories/SessionStorageRepositories/LngStorageRepository';
1+
import { type UserPreferencesStorageRepository } from '@app-builder/repositories/SessionStorageRepositories/LngStorageRepository';
22
import { type EntryContext } from '@remix-run/node';
33
import { createInstance, type FlatNamespace, type InitOptions } from 'i18next';
44
import { initReactI18next } from 'react-i18next';
@@ -7,12 +7,14 @@ import { RemixI18Next } from 'remix-i18next/server';
77
import { i18nConfig } from './i18n-config';
88
import { resources } from './resources/resources.server';
99

10-
export function makeI18nextServerService({ lngStorage }: LngStorageRepository) {
10+
export function makeI18nextServerService({
11+
userPreferencesStorage,
12+
}: UserPreferencesStorageRepository) {
1113
const remixI18next = new RemixI18Next({
1214
detection: {
1315
supportedLanguages: i18nConfig.supportedLngs,
1416
fallbackLanguage: i18nConfig.fallbackLng,
15-
sessionStorage: lngStorage,
17+
sessionStorage: userPreferencesStorage,
1618
},
1719
// This is the configuration for i18next used
1820
// when translating messages server-side only
@@ -43,12 +45,45 @@ export function makeI18nextServerService({ lngStorage }: LngStorageRepository) {
4345
}
4446

4547
async function setLanguage(request: Request, language: string) {
46-
const session = await lngStorage.getSession(request.headers.get('cookie'));
48+
const session = await userPreferencesStorage.getSession(request.headers.get('cookie'));
4749
session.set('lng', language);
48-
const cookie = await lngStorage.commitSession(session);
50+
const cookie = await userPreferencesStorage.commitSession(session);
4951
return { cookie };
5052
}
5153

54+
async function setDateFormat(request: Request, dateFormat: string) {
55+
const session = await userPreferencesStorage.getSession(request.headers.get('cookie'));
56+
session.set('dateFormat', dateFormat);
57+
const cookie = await userPreferencesStorage.commitSession(session);
58+
return { cookie };
59+
}
60+
61+
async function setHoursFormat(request: Request, hoursFormat: string) {
62+
const session = await userPreferencesStorage.getSession(request.headers.get('cookie'));
63+
session.set('hoursFormat', hoursFormat);
64+
const cookie = await userPreferencesStorage.commitSession(session);
65+
return { cookie };
66+
}
67+
68+
async function getDateFormat(request: Request): Promise<string | undefined> {
69+
const session = await userPreferencesStorage.getSession(request.headers.get('cookie'));
70+
return session.get('dateFormat');
71+
}
72+
73+
async function getHoursFormat(request: Request): Promise<string | undefined> {
74+
const session = await userPreferencesStorage.getSession(request.headers.get('cookie'));
75+
return session.get('hoursFormat');
76+
}
77+
78+
async function getUserPreferences(request: Request) {
79+
const session = await userPreferencesStorage.getSession(request.headers.get('cookie'));
80+
return {
81+
language: await remixI18next.getLocale(request),
82+
dateFormat: session.get('dateFormat'),
83+
hoursFormat: session.get('hoursFormat'),
84+
};
85+
}
86+
5287
return {
5388
getLocale: (request: Request) => remixI18next.getLocale(request),
5489
getFixedT: <N extends FlatNamespace | readonly [FlatNamespace, ...FlatNamespace[]]>(
@@ -58,6 +93,11 @@ export function makeI18nextServerService({ lngStorage }: LngStorageRepository) {
5893
) => remixI18next.getFixedT(request, namespaces, options),
5994
getI18nextServerInstance,
6095
setLanguage,
96+
setDateFormat,
97+
setHoursFormat,
98+
getDateFormat,
99+
getHoursFormat,
100+
getUserPreferences,
61101
};
62102
}
63103

0 commit comments

Comments
 (0)