@react-spa-scaffold/mcp 0.3.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.
Files changed (173) hide show
  1. package/README.md +423 -0
  2. package/dist/features/index.d.ts +5 -0
  3. package/dist/features/index.d.ts.map +1 -0
  4. package/dist/features/index.js +3 -0
  5. package/dist/features/index.js.map +1 -0
  6. package/dist/features/registry.d.ts +10 -0
  7. package/dist/features/registry.d.ts.map +1 -0
  8. package/dist/features/registry.js +508 -0
  9. package/dist/features/registry.js.map +1 -0
  10. package/dist/features/types.d.ts +45 -0
  11. package/dist/features/types.d.ts.map +1 -0
  12. package/dist/features/types.js +5 -0
  13. package/dist/features/types.js.map +1 -0
  14. package/dist/features/versions.d.ts +16 -0
  15. package/dist/features/versions.d.ts.map +1 -0
  16. package/dist/features/versions.js +46 -0
  17. package/dist/features/versions.js.map +1 -0
  18. package/dist/features/versions.json +5 -0
  19. package/dist/index.d.ts +22 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +43 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/resources/docs.d.ts +29 -0
  24. package/dist/resources/docs.d.ts.map +1 -0
  25. package/dist/resources/docs.js +105 -0
  26. package/dist/resources/docs.js.map +1 -0
  27. package/dist/resources/index.d.ts +2 -0
  28. package/dist/resources/index.d.ts.map +1 -0
  29. package/dist/resources/index.js +2 -0
  30. package/dist/resources/index.js.map +1 -0
  31. package/dist/server.d.ts +12 -0
  32. package/dist/server.d.ts.map +1 -0
  33. package/dist/server.js +115 -0
  34. package/dist/server.js.map +1 -0
  35. package/dist/tools/get-example.d.ts +51 -0
  36. package/dist/tools/get-example.d.ts.map +1 -0
  37. package/dist/tools/get-example.js +90 -0
  38. package/dist/tools/get-example.js.map +1 -0
  39. package/dist/tools/get-features.d.ts +30 -0
  40. package/dist/tools/get-features.d.ts.map +1 -0
  41. package/dist/tools/get-features.js +46 -0
  42. package/dist/tools/get-features.js.map +1 -0
  43. package/dist/tools/get-scaffold.d.ts +77 -0
  44. package/dist/tools/get-scaffold.d.ts.map +1 -0
  45. package/dist/tools/get-scaffold.js +153 -0
  46. package/dist/tools/get-scaffold.js.map +1 -0
  47. package/dist/tools/index.d.ts +4 -0
  48. package/dist/tools/index.d.ts.map +1 -0
  49. package/dist/tools/index.js +4 -0
  50. package/dist/tools/index.js.map +1 -0
  51. package/dist/utils/docs.d.ts +14 -0
  52. package/dist/utils/docs.d.ts.map +1 -0
  53. package/dist/utils/docs.js +64 -0
  54. package/dist/utils/docs.js.map +1 -0
  55. package/dist/utils/examples.d.ts +27 -0
  56. package/dist/utils/examples.d.ts.map +1 -0
  57. package/dist/utils/examples.js +399 -0
  58. package/dist/utils/examples.js.map +1 -0
  59. package/dist/utils/index.d.ts +5 -0
  60. package/dist/utils/index.d.ts.map +1 -0
  61. package/dist/utils/index.js +5 -0
  62. package/dist/utils/index.js.map +1 -0
  63. package/dist/utils/paths.d.ts +28 -0
  64. package/dist/utils/paths.d.ts.map +1 -0
  65. package/dist/utils/paths.js +40 -0
  66. package/dist/utils/paths.js.map +1 -0
  67. package/dist/utils/scaffold.d.ts +50 -0
  68. package/dist/utils/scaffold.d.ts.map +1 -0
  69. package/dist/utils/scaffold.js +500 -0
  70. package/dist/utils/scaffold.js.map +1 -0
  71. package/dist/version.d.ts +5 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +19 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +63 -0
  76. package/templates/.bundled +0 -0
  77. package/templates/CLAUDE.md +145 -0
  78. package/templates/docs/API_REFERENCE.md +58 -0
  79. package/templates/docs/ARCHITECTURE.md +185 -0
  80. package/templates/docs/CODING_STANDARDS.md +53 -0
  81. package/templates/docs/COMPONENT_GUIDELINES.md +301 -0
  82. package/templates/docs/E2E_TESTING.md +116 -0
  83. package/templates/docs/INTERNATIONALIZATION.md +67 -0
  84. package/templates/docs/TESTING.md +259 -0
  85. package/templates/docs/WORKFLOW.md +170 -0
  86. package/templates/src/App.tsx +42 -0
  87. package/templates/src/components/layout/Header.tsx +19 -0
  88. package/templates/src/components/layout/index.ts +1 -0
  89. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +104 -0
  90. package/templates/src/components/shared/ErrorBoundary/index.ts +1 -0
  91. package/templates/src/components/shared/LanguageSwitcher/LanguageSwitcher.tsx +45 -0
  92. package/templates/src/components/shared/LanguageSwitcher/index.ts +1 -0
  93. package/templates/src/components/shared/SEO/SEO.tsx +55 -0
  94. package/templates/src/components/shared/SEO/index.ts +1 -0
  95. package/templates/src/components/shared/ThemeToggle/ThemeToggle.tsx +41 -0
  96. package/templates/src/components/shared/ThemeToggle/index.ts +1 -0
  97. package/templates/src/components/shared/index.ts +4 -0
  98. package/templates/src/components/ui/button.tsx +48 -0
  99. package/templates/src/components/ui/dropdown-menu.tsx +228 -0
  100. package/templates/src/components/ui/form-error.tsx +95 -0
  101. package/templates/src/components/ui/loading.tsx +58 -0
  102. package/templates/src/components/ui/skeleton.tsx +52 -0
  103. package/templates/src/components/ui/sonner.tsx +34 -0
  104. package/templates/src/components/ui/spinner.tsx +40 -0
  105. package/templates/src/components/ui/visually-hidden.tsx +51 -0
  106. package/templates/src/contexts/mobileContext.tsx +66 -0
  107. package/templates/src/contexts/queryContext.tsx +28 -0
  108. package/templates/src/hooks/index.ts +7 -0
  109. package/templates/src/hooks/useContactForm.ts +33 -0
  110. package/templates/src/hooks/useExampleQuery.ts +20 -0
  111. package/templates/src/hooks/useLanguage.ts +23 -0
  112. package/templates/src/hooks/useMediaQuery.ts +53 -0
  113. package/templates/src/hooks/useThemeEffect.ts +31 -0
  114. package/templates/src/hooks/useTouchSizes.ts +16 -0
  115. package/templates/src/i18n/config.ts +11 -0
  116. package/templates/src/i18n/detectLanguage.ts +57 -0
  117. package/templates/src/i18n/index.ts +20 -0
  118. package/templates/src/i18n/loadCatalog.ts +30 -0
  119. package/templates/src/index.css +98 -0
  120. package/templates/src/lib/api.ts +142 -0
  121. package/templates/src/lib/config.ts +15 -0
  122. package/templates/src/lib/constants.ts +8 -0
  123. package/templates/src/lib/env.ts +53 -0
  124. package/templates/src/lib/format.ts +119 -0
  125. package/templates/src/lib/index.ts +24 -0
  126. package/templates/src/lib/routes.ts +11 -0
  127. package/templates/src/lib/storage.ts +91 -0
  128. package/templates/src/lib/storageKeys.ts +10 -0
  129. package/templates/src/lib/utils.ts +6 -0
  130. package/templates/src/lib/validations.ts +39 -0
  131. package/templates/src/locales/de.po +65 -0
  132. package/templates/src/locales/en.po +65 -0
  133. package/templates/src/locales/es.po +65 -0
  134. package/templates/src/main.tsx +107 -0
  135. package/templates/src/mocks/fixtures/index.ts +1 -0
  136. package/templates/src/mocks/fixtures/todos.ts +40 -0
  137. package/templates/src/mocks/handlers/index.ts +7 -0
  138. package/templates/src/mocks/handlers/todos.ts +59 -0
  139. package/templates/src/mocks/index.ts +3 -0
  140. package/templates/src/mocks/node.ts +9 -0
  141. package/templates/src/pages/Home.tsx +27 -0
  142. package/templates/src/pages/NotFound.tsx +28 -0
  143. package/templates/src/pages/index.ts +2 -0
  144. package/templates/src/stores/index.ts +2 -0
  145. package/templates/src/stores/preferencesStore.ts +85 -0
  146. package/templates/src/test/index.ts +8 -0
  147. package/templates/src/test/mocks.ts +17 -0
  148. package/templates/src/test/providers.tsx +54 -0
  149. package/templates/src/test-setup.ts +54 -0
  150. package/templates/src/types/api.ts +31 -0
  151. package/templates/src/types/index.ts +2 -0
  152. package/templates/src/types/preferences.ts +5 -0
  153. package/templates/src/vite-env.d.ts +10 -0
  154. package/templates/tests/unit/components/ErrorBoundary.test.tsx +193 -0
  155. package/templates/tests/unit/components/Header.test.tsx +33 -0
  156. package/templates/tests/unit/components/LanguageSwitcher.test.tsx +40 -0
  157. package/templates/tests/unit/components/Loading.test.tsx +76 -0
  158. package/templates/tests/unit/components/SEO.test.tsx +80 -0
  159. package/templates/tests/unit/components/ThemeToggle.test.tsx +62 -0
  160. package/templates/tests/unit/contexts/mobileContext.test.tsx +54 -0
  161. package/templates/tests/unit/hooks/useContactForm.test.ts +60 -0
  162. package/templates/tests/unit/hooks/useExampleQuery.test.tsx +94 -0
  163. package/templates/tests/unit/hooks/useLanguage.test.tsx +75 -0
  164. package/templates/tests/unit/hooks/useMediaQuery.test.ts +57 -0
  165. package/templates/tests/unit/hooks/useThemeEffect.test.ts +42 -0
  166. package/templates/tests/unit/i18n/detectLanguage.test.ts +40 -0
  167. package/templates/tests/unit/i18n/loadCatalog.test.ts +70 -0
  168. package/templates/tests/unit/lib/api.test.ts +142 -0
  169. package/templates/tests/unit/lib/format.test.ts +100 -0
  170. package/templates/tests/unit/lib/storage.test.ts +90 -0
  171. package/templates/tests/unit/lib/utils.test.ts +19 -0
  172. package/templates/tests/unit/lib/validations.test.ts +56 -0
  173. package/templates/tests/unit/stores/preferencesStore.test.ts +75 -0
@@ -0,0 +1,51 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface VisuallyHiddenProps {
4
+ children: ReactNode;
5
+ as?: 'span' | 'div';
6
+ }
7
+
8
+ /**
9
+ * Visually hidden content for screen readers.
10
+ * Content is hidden visually but remains accessible to assistive technologies.
11
+ */
12
+ export function VisuallyHidden({ children, as: Component = 'span' }: VisuallyHiddenProps) {
13
+ return (
14
+ <Component
15
+ style={{
16
+ position: 'absolute',
17
+ width: '1px',
18
+ height: '1px',
19
+ padding: 0,
20
+ margin: '-1px',
21
+ overflow: 'hidden',
22
+ clip: 'rect(0, 0, 0, 0)',
23
+ whiteSpace: 'nowrap',
24
+ border: 0,
25
+ }}
26
+ >
27
+ {children}
28
+ </Component>
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Skip link for keyboard navigation.
34
+ * Becomes visible on focus.
35
+ */
36
+ export function SkipLink({
37
+ href = '#main',
38
+ children = 'Skip to main content',
39
+ }: {
40
+ href?: string;
41
+ children?: ReactNode;
42
+ }) {
43
+ return (
44
+ <a
45
+ href={href}
46
+ className="bg-background text-foreground ring-ring fixed top-4 left-4 z-50 -translate-y-16 rounded-md px-4 py-2 font-medium transition-transform focus:translate-y-0 focus:ring-2"
47
+ >
48
+ {children}
49
+ </a>
50
+ );
51
+ }
@@ -0,0 +1,66 @@
1
+ import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react';
2
+
3
+ import { BREAKPOINTS } from '@/hooks/useMediaQuery';
4
+
5
+ interface MobileContextValue {
6
+ isMobile: boolean;
7
+ isTablet: boolean;
8
+ isDesktop: boolean;
9
+ /** Current viewport width in pixels */
10
+ width: number;
11
+ }
12
+
13
+ const MobileContext = createContext<MobileContextValue | null>(null);
14
+
15
+ /**
16
+ * Optimized MobileProvider that uses a single resize listener
17
+ * instead of multiple matchMedia listeners.
18
+ */
19
+ export function MobileProvider({ children }: { children: ReactNode }) {
20
+ const [width, setWidth] = useState(() => {
21
+ if (typeof window === 'undefined') return BREAKPOINTS.lg;
22
+ return window.innerWidth;
23
+ });
24
+
25
+ useEffect(() => {
26
+ let rafId: number;
27
+ let lastWidth = window.innerWidth;
28
+
29
+ const handleResize = () => {
30
+ // Debounce with requestAnimationFrame for performance
31
+ cancelAnimationFrame(rafId);
32
+ rafId = requestAnimationFrame(() => {
33
+ const newWidth = window.innerWidth;
34
+ // Only update if width actually changed to prevent unnecessary re-renders
35
+ if (newWidth !== lastWidth) {
36
+ lastWidth = newWidth;
37
+ setWidth(newWidth);
38
+ }
39
+ });
40
+ };
41
+
42
+ window.addEventListener('resize', handleResize, { passive: true });
43
+ return () => {
44
+ window.removeEventListener('resize', handleResize);
45
+ cancelAnimationFrame(rafId);
46
+ };
47
+ }, []);
48
+
49
+ const value = useMemo(() => {
50
+ const isMobile = width < BREAKPOINTS.md;
51
+ const isTablet = width >= BREAKPOINTS.md && width < BREAKPOINTS.lg;
52
+ const isDesktop = width >= BREAKPOINTS.lg;
53
+
54
+ return { isMobile, isTablet, isDesktop, width };
55
+ }, [width]);
56
+
57
+ return <MobileContext.Provider value={value}>{children}</MobileContext.Provider>;
58
+ }
59
+
60
+ export function useMobileContext(): MobileContextValue {
61
+ const context = useContext(MobileContext);
62
+ if (!context) {
63
+ throw new Error('useMobileContext must be used within MobileProvider');
64
+ }
65
+ return context;
66
+ }
@@ -0,0 +1,28 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { type ReactNode, useState } from 'react';
3
+
4
+ /**
5
+ * Create QueryClient with optimized defaults.
6
+ * Using a function ensures each provider instance gets its own client,
7
+ * which is important for SSR and testing.
8
+ */
9
+ function createQueryClient() {
10
+ return new QueryClient({
11
+ defaultOptions: {
12
+ queries: {
13
+ staleTime: 1000 * 60 * 5, // 5 minutes
14
+ gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
15
+ retry: 1,
16
+ refetchOnWindowFocus: false,
17
+ },
18
+ },
19
+ });
20
+ }
21
+
22
+ export function QueryProvider({ children }: { children: ReactNode }) {
23
+ // Use useState to ensure queryClient is created once per component instance
24
+ // This is the recommended pattern from TanStack Query docs
25
+ const [queryClient] = useState(createQueryClient);
26
+
27
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
28
+ }
@@ -0,0 +1,7 @@
1
+ export { useMediaQuery, BREAKPOINTS } from './useMediaQuery';
2
+ export { useThemeEffect } from './useThemeEffect';
3
+ export { useTouchSizes } from './useTouchSizes';
4
+ export { useLanguage } from './useLanguage';
5
+ export { useExampleQuery } from './useExampleQuery';
6
+ export { useContactForm } from './useContactForm';
7
+ export { useMobileContext } from '@/contexts/mobileContext';
@@ -0,0 +1,33 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod';
2
+ import { useForm } from 'react-hook-form';
3
+
4
+ import { type ContactFormData, contactFormSchema } from '@/lib/validations';
5
+
6
+ /**
7
+ * Example React Hook Form + Zod hook for a contact form.
8
+ * Demonstrates the pattern for form validation.
9
+ */
10
+ export function useContactForm() {
11
+ const form = useForm<ContactFormData>({
12
+ resolver: zodResolver(contactFormSchema),
13
+ defaultValues: {
14
+ name: '',
15
+ email: '',
16
+ message: '',
17
+ },
18
+ });
19
+
20
+ const onSubmit = async (data: ContactFormData) => {
21
+ // Replace with your actual form submission logic
22
+ // eslint-disable-next-line no-console
23
+ console.log('Form submitted:', data);
24
+ // await api.submitContactForm(data);
25
+ };
26
+
27
+ return {
28
+ form,
29
+ onSubmit: form.handleSubmit(onSubmit),
30
+ isSubmitting: form.formState.isSubmitting,
31
+ errors: form.formState.errors,
32
+ };
33
+ }
@@ -0,0 +1,20 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+
3
+ import { api } from '@/lib/api';
4
+ import type { Todo } from '@/types/api';
5
+
6
+ async function fetchTodos(): Promise<Todo[]> {
7
+ return api.get<Todo[]>('/todos?_limit=5');
8
+ }
9
+
10
+ /**
11
+ * Example TanStack Query hook demonstrating the pattern.
12
+ * Uses the centralized API client and shared types.
13
+ */
14
+ export function useExampleQuery() {
15
+ return useQuery({
16
+ queryKey: ['example', 'todos'],
17
+ queryFn: fetchTodos,
18
+ // Uses default staleTime from QueryProvider (5 minutes)
19
+ });
20
+ }
@@ -0,0 +1,23 @@
1
+ import { useLingui } from '@lingui/react/macro';
2
+ import { useCallback } from 'react';
3
+
4
+ import { dynamicActivate, SUPPORTED_LOCALES, type SupportedLocale } from '@/i18n';
5
+ import { setStorageItem } from '@/lib/storage';
6
+ import { STORAGE_KEYS } from '@/lib/storageKeys';
7
+
8
+ export function useLanguage() {
9
+ const { i18n } = useLingui();
10
+
11
+ const currentLocale = i18n.locale as SupportedLocale;
12
+
13
+ const changeLanguage = useCallback(async (locale: SupportedLocale) => {
14
+ await dynamicActivate(locale);
15
+ setStorageItem(STORAGE_KEYS.locale, locale);
16
+ }, []);
17
+
18
+ return {
19
+ currentLocale,
20
+ changeLanguage,
21
+ supportedLocales: SUPPORTED_LOCALES,
22
+ };
23
+ }
@@ -0,0 +1,53 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ export const BREAKPOINTS = {
4
+ sm: 640,
5
+ md: 768,
6
+ lg: 1024,
7
+ xl: 1280,
8
+ } as const;
9
+
10
+ /**
11
+ * Hook to track a media query match status.
12
+ * Optimized to reuse matchMedia objects when the query doesn't change.
13
+ */
14
+ export function useMediaQuery(query: string): boolean {
15
+ const [matches, setMatches] = useState(() => {
16
+ if (typeof window !== 'undefined') {
17
+ return window.matchMedia(query).matches;
18
+ }
19
+ return false;
20
+ });
21
+
22
+ // Use ref to store the MediaQueryList to avoid recreating on every render
23
+ const mediaQueryRef = useRef<MediaQueryList | null>(null);
24
+
25
+ useEffect(() => {
26
+ // Create or update the MediaQueryList only when query changes
27
+ mediaQueryRef.current = window.matchMedia(query);
28
+
29
+ const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
30
+
31
+ // Sync initial state
32
+ setMatches(mediaQueryRef.current.matches);
33
+
34
+ mediaQueryRef.current.addEventListener('change', handler);
35
+
36
+ return () => {
37
+ mediaQueryRef.current?.removeEventListener('change', handler);
38
+ };
39
+ }, [query]);
40
+
41
+ return matches;
42
+ }
43
+
44
+ /**
45
+ * Convenience hooks for common breakpoints
46
+ */
47
+ export function useIsMobile(): boolean {
48
+ return !useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
49
+ }
50
+
51
+ export function useIsDesktop(): boolean {
52
+ return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
53
+ }
@@ -0,0 +1,31 @@
1
+ import { useEffect } from 'react';
2
+
3
+ import { usePreferencesStore } from '@/stores';
4
+
5
+ /**
6
+ * Hook that syncs the theme preference to the DOM.
7
+ * Handles 'system' theme by listening to prefers-color-scheme changes.
8
+ */
9
+ export function useThemeEffect() {
10
+ const theme = usePreferencesStore((state) => state.theme);
11
+ const getResolvedTheme = usePreferencesStore((state) => state.getResolvedTheme);
12
+
13
+ useEffect(() => {
14
+ // Apply the resolved theme to the DOM
15
+ const applyTheme = () => {
16
+ const resolved = getResolvedTheme();
17
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
18
+ };
19
+
20
+ applyTheme();
21
+
22
+ // If theme is 'system', listen for system preference changes
23
+ if (theme === 'system') {
24
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
25
+ const handleChange = () => applyTheme();
26
+
27
+ mediaQuery.addEventListener('change', handleChange);
28
+ return () => mediaQuery.removeEventListener('change', handleChange);
29
+ }
30
+ }, [theme, getResolvedTheme]);
31
+ }
@@ -0,0 +1,16 @@
1
+ import { useMobileContext } from '@/contexts/mobileContext';
2
+
3
+ export function useTouchSizes() {
4
+ const { isMobile } = useMobileContext();
5
+
6
+ return {
7
+ button: isMobile ? 'touch' : 'default',
8
+ buttonSm: isMobile ? 'touch' : 'sm',
9
+ iconButton: isMobile ? 'icon-touch' : 'icon-sm',
10
+ iconButtonLg: isMobile ? 'icon-touch' : 'icon',
11
+ input: isMobile ? 'touch' : 'default',
12
+ select: isMobile ? 'touch' : 'default',
13
+ toggle: isMobile ? 'touch' : 'sm',
14
+ textarea: isMobile ? 'touch' : 'default',
15
+ } as const;
16
+ }
@@ -0,0 +1,11 @@
1
+ export const SUPPORTED_LOCALES = ['en', 'es', 'de'] as const;
2
+
3
+ export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
4
+
5
+ export const DEFAULT_LOCALE: SupportedLocale = 'en';
6
+
7
+ export const LOCALE_LABELS: Record<SupportedLocale, { native: string; english: string }> = {
8
+ en: { native: 'English', english: 'English' },
9
+ es: { native: 'Español', english: 'Spanish' },
10
+ de: { native: 'Deutsch', english: 'German' },
11
+ };
@@ -0,0 +1,57 @@
1
+ import { STORAGE_KEYS } from '@/lib/storageKeys';
2
+
3
+ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SupportedLocale } from './config';
4
+
5
+ function isSupported(locale: string): locale is SupportedLocale {
6
+ return SUPPORTED_LOCALES.includes(locale.toLowerCase() as SupportedLocale);
7
+ }
8
+
9
+ function findBestMatch(langTag: string): SupportedLocale | null {
10
+ const normalized = langTag.toLowerCase();
11
+
12
+ if (isSupported(normalized)) return normalized;
13
+
14
+ const exactMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === normalized);
15
+ if (exactMatch) return exactMatch;
16
+
17
+ const baseLanguage = normalized.split('-')[0];
18
+ if (isSupported(baseLanguage)) return baseLanguage as SupportedLocale;
19
+
20
+ const regionalFallback = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase().startsWith(baseLanguage));
21
+ if (regionalFallback) return regionalFallback;
22
+
23
+ return null;
24
+ }
25
+
26
+ export function detectLanguage(): SupportedLocale {
27
+ // 1. Check localStorage for user preference
28
+ if (typeof localStorage !== 'undefined') {
29
+ const stored = localStorage.getItem(STORAGE_KEYS.locale);
30
+ if (stored) {
31
+ let parsedLocale: string;
32
+ try {
33
+ parsedLocale = JSON.parse(stored) as string;
34
+ } catch {
35
+ parsedLocale = stored;
36
+ }
37
+ const match = findBestMatch(parsedLocale);
38
+ if (match) return match;
39
+ }
40
+ }
41
+
42
+ // 2. Check browser language preferences
43
+ if (typeof navigator !== 'undefined' && navigator.languages) {
44
+ for (const lang of navigator.languages) {
45
+ const match = findBestMatch(lang);
46
+ if (match) return match;
47
+ }
48
+ }
49
+
50
+ // 3. Check primary browser language
51
+ if (typeof navigator !== 'undefined' && navigator.language) {
52
+ const match = findBestMatch(navigator.language);
53
+ if (match) return match;
54
+ }
55
+
56
+ return DEFAULT_LOCALE;
57
+ }
@@ -0,0 +1,20 @@
1
+ import { i18n } from '@lingui/core';
2
+
3
+ import { type SupportedLocale } from './config';
4
+ import { detectLanguage } from './detectLanguage';
5
+ import { dynamicActivate } from './loadCatalog';
6
+
7
+ // Re-export everything from a single source
8
+ export { i18n };
9
+ export { detectLanguage };
10
+ export { dynamicActivate };
11
+ export { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_LABELS, type SupportedLocale } from './config';
12
+
13
+ export async function initI18n(locale?: SupportedLocale): Promise<void> {
14
+ const targetLocale = locale || detectLanguage();
15
+ await dynamicActivate(targetLocale);
16
+ }
17
+
18
+ export function getLocale(): string {
19
+ return i18n.locale || 'en';
20
+ }
@@ -0,0 +1,30 @@
1
+ import { i18n } from '@lingui/core';
2
+
3
+ import { DEFAULT_LOCALE, type SupportedLocale } from './config';
4
+
5
+ export async function dynamicActivate(locale: SupportedLocale): Promise<void> {
6
+ if (i18n.locale === locale && i18n.messages[locale]) return;
7
+ if (i18n.messages[locale]) {
8
+ i18n.activate(locale);
9
+ return;
10
+ }
11
+
12
+ try {
13
+ const { messages } = await import(`../locales/${locale}.po`);
14
+ i18n.loadAndActivate({ locale, messages });
15
+ } catch (error) {
16
+ console.error(`Failed to load locale: ${locale}`, error);
17
+ if (locale !== DEFAULT_LOCALE) {
18
+ if (i18n.messages[DEFAULT_LOCALE]) {
19
+ i18n.activate(DEFAULT_LOCALE);
20
+ } else {
21
+ try {
22
+ const { messages } = await import(`../locales/${DEFAULT_LOCALE}.po`);
23
+ i18n.loadAndActivate({ locale: DEFAULT_LOCALE, messages });
24
+ } catch {
25
+ console.error('Failed to load default locale');
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,98 @@
1
+ @import 'tailwindcss';
2
+ @import 'tw-animate-css';
3
+ @import 'shadcn/tailwind.css';
4
+ @import '@fontsource-variable/inter';
5
+
6
+ @custom-variant dark (&:is(.dark *));
7
+
8
+ :root {
9
+ --background: oklch(1 0 0);
10
+ --foreground: oklch(0.141 0.005 285.823);
11
+ --card: oklch(1 0 0);
12
+ --card-foreground: oklch(0.141 0.005 285.823);
13
+ --popover: oklch(1 0 0);
14
+ --popover-foreground: oklch(0.141 0.005 285.823);
15
+ --primary: oklch(0.488 0.243 264.376);
16
+ --primary-foreground: oklch(0.97 0.014 254.604);
17
+ --secondary: oklch(0.967 0.001 286.375);
18
+ --secondary-foreground: oklch(0.21 0.006 285.885);
19
+ --muted: oklch(0.967 0.001 286.375);
20
+ --muted-foreground: oklch(0.552 0.016 285.938);
21
+ --accent: oklch(0.967 0.001 286.375);
22
+ --accent-foreground: oklch(0.21 0.006 285.885);
23
+ --destructive: oklch(0.577 0.245 27.325);
24
+ --border: oklch(0.92 0.004 286.32);
25
+ --input: oklch(0.92 0.004 286.32);
26
+ --ring: oklch(0.705 0.015 286.067);
27
+ --radius: 0.45rem;
28
+ }
29
+
30
+ .dark {
31
+ --background: oklch(0.141 0.005 285.823);
32
+ --foreground: oklch(0.985 0 0);
33
+ --card: oklch(0.21 0.006 285.885);
34
+ --card-foreground: oklch(0.985 0 0);
35
+ --popover: oklch(0.21 0.006 285.885);
36
+ --popover-foreground: oklch(0.985 0 0);
37
+ --primary: oklch(0.42 0.18 266);
38
+ --primary-foreground: oklch(0.97 0.014 254.604);
39
+ --secondary: oklch(0.274 0.006 286.033);
40
+ --secondary-foreground: oklch(0.985 0 0);
41
+ --muted: oklch(0.274 0.006 286.033);
42
+ --muted-foreground: oklch(0.705 0.015 286.067);
43
+ --accent: oklch(0.274 0.006 286.033);
44
+ --accent-foreground: oklch(0.985 0 0);
45
+ --destructive: oklch(0.704 0.191 22.216);
46
+ --border: oklch(1 0 0 / 10%);
47
+ --input: oklch(1 0 0 / 15%);
48
+ --ring: oklch(0.552 0.016 285.938);
49
+ }
50
+
51
+ @theme inline {
52
+ --font-sans: 'Inter Variable', sans-serif;
53
+ --color-ring: var(--ring);
54
+ --color-input: var(--input);
55
+ --color-border: var(--border);
56
+ --color-destructive: var(--destructive);
57
+ --color-accent-foreground: var(--accent-foreground);
58
+ --color-accent: var(--accent);
59
+ --color-muted-foreground: var(--muted-foreground);
60
+ --color-muted: var(--muted);
61
+ --color-secondary-foreground: var(--secondary-foreground);
62
+ --color-secondary: var(--secondary);
63
+ --color-primary-foreground: var(--primary-foreground);
64
+ --color-primary: var(--primary);
65
+ --color-popover-foreground: var(--popover-foreground);
66
+ --color-popover: var(--popover);
67
+ --color-card-foreground: var(--card-foreground);
68
+ --color-card: var(--card);
69
+ --color-foreground: var(--foreground);
70
+ --color-background: var(--background);
71
+ --radius-sm: calc(var(--radius) - 4px);
72
+ --radius-md: calc(var(--radius) - 2px);
73
+ --radius-lg: var(--radius);
74
+ --radius-xl: calc(var(--radius) + 4px);
75
+ }
76
+
77
+ @layer base {
78
+ * {
79
+ @apply border-border outline-ring/50;
80
+ }
81
+
82
+ html {
83
+ @apply h-full font-sans;
84
+ }
85
+
86
+ body {
87
+ @apply bg-background text-foreground h-full font-sans;
88
+ }
89
+
90
+ /* Prevent iOS Safari zoom on input focus - must be 16px+ */
91
+ @media screen and (max-width: 640px) {
92
+ input,
93
+ textarea,
94
+ select {
95
+ font-size: 16px !important;
96
+ }
97
+ }
98
+ }