@open-mercato/shared 0.4.2-canary-c02407ff85
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.
- package/build.mjs +101 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +7 -0
- package/dist/lib/api/crud.js +47 -0
- package/dist/lib/api/crud.js.map +7 -0
- package/dist/lib/api/scoped.js +140 -0
- package/dist/lib/api/scoped.js.map +7 -0
- package/dist/lib/auth/jwt.js +34 -0
- package/dist/lib/auth/jwt.js.map +7 -0
- package/dist/lib/auth/server.js +157 -0
- package/dist/lib/auth/server.js.map +7 -0
- package/dist/lib/boolean.js +22 -0
- package/dist/lib/boolean.js.map +7 -0
- package/dist/lib/bootstrap/appResolver.js +43 -0
- package/dist/lib/bootstrap/appResolver.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +108 -0
- package/dist/lib/bootstrap/dynamicLoader.js.map +7 -0
- package/dist/lib/bootstrap/factory.js +59 -0
- package/dist/lib/bootstrap/factory.js.map +7 -0
- package/dist/lib/bootstrap/index.js +11 -0
- package/dist/lib/bootstrap/index.js.map +7 -0
- package/dist/lib/bootstrap/types.js +1 -0
- package/dist/lib/bootstrap/types.js.map +7 -0
- package/dist/lib/cache/segments.js +36 -0
- package/dist/lib/cache/segments.js.map +7 -0
- package/dist/lib/cli/progress.js +46 -0
- package/dist/lib/cli/progress.js.map +7 -0
- package/dist/lib/commands/command-bus.js +285 -0
- package/dist/lib/commands/command-bus.js.map +7 -0
- package/dist/lib/commands/customFieldSnapshots.js +66 -0
- package/dist/lib/commands/customFieldSnapshots.js.map +7 -0
- package/dist/lib/commands/helpers.js +98 -0
- package/dist/lib/commands/helpers.js.map +7 -0
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +7 -0
- package/dist/lib/commands/operationMetadata.js +32 -0
- package/dist/lib/commands/operationMetadata.js.map +7 -0
- package/dist/lib/commands/registry.js +43 -0
- package/dist/lib/commands/registry.js.map +7 -0
- package/dist/lib/commands/scope.js +44 -0
- package/dist/lib/commands/scope.js.map +7 -0
- package/dist/lib/commands/types.js +8 -0
- package/dist/lib/commands/types.js.map +7 -0
- package/dist/lib/crud/cache-stats.js +98 -0
- package/dist/lib/crud/cache-stats.js.map +7 -0
- package/dist/lib/crud/cache.js +175 -0
- package/dist/lib/crud/cache.js.map +7 -0
- package/dist/lib/crud/custom-fields-client.js +52 -0
- package/dist/lib/crud/custom-fields-client.js.map +7 -0
- package/dist/lib/crud/custom-fields.js +467 -0
- package/dist/lib/crud/custom-fields.js.map +7 -0
- package/dist/lib/crud/errors.js +24 -0
- package/dist/lib/crud/errors.js.map +7 -0
- package/dist/lib/crud/exporters.js +154 -0
- package/dist/lib/crud/exporters.js.map +7 -0
- package/dist/lib/crud/factory.js +1311 -0
- package/dist/lib/crud/factory.js.map +7 -0
- package/dist/lib/crud/types.js +1 -0
- package/dist/lib/crud/types.js.map +7 -0
- package/dist/lib/custom-fields/normalize.js +36 -0
- package/dist/lib/custom-fields/normalize.js.map +7 -0
- package/dist/lib/data/engine.js +396 -0
- package/dist/lib/data/engine.js.map +7 -0
- package/dist/lib/db/escapeLikePattern.js +5 -0
- package/dist/lib/db/escapeLikePattern.js.map +7 -0
- package/dist/lib/db/mikro.js +82 -0
- package/dist/lib/db/mikro.js.map +7 -0
- package/dist/lib/di/container.js +94 -0
- package/dist/lib/di/container.js.map +7 -0
- package/dist/lib/email/send.js +12 -0
- package/dist/lib/email/send.js.map +7 -0
- package/dist/lib/encryption/aes.js +58 -0
- package/dist/lib/encryption/aes.js.map +7 -0
- package/dist/lib/encryption/customFieldValues.js +49 -0
- package/dist/lib/encryption/customFieldValues.js.map +7 -0
- package/dist/lib/encryption/entityFields.js +26 -0
- package/dist/lib/encryption/entityFields.js.map +7 -0
- package/dist/lib/encryption/entityIds.js +80 -0
- package/dist/lib/encryption/entityIds.js.map +7 -0
- package/dist/lib/encryption/find.js +45 -0
- package/dist/lib/encryption/find.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +69 -0
- package/dist/lib/encryption/indexDoc.js.map +7 -0
- package/dist/lib/encryption/kms.js +282 -0
- package/dist/lib/encryption/kms.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +330 -0
- package/dist/lib/encryption/subscriber.js.map +7 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js +252 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +7 -0
- package/dist/lib/encryption/toggles.js +18 -0
- package/dist/lib/encryption/toggles.js.map +7 -0
- package/dist/lib/entities/naming.js +9 -0
- package/dist/lib/entities/naming.js.map +7 -0
- package/dist/lib/entities/system-entities.js +43 -0
- package/dist/lib/entities/system-entities.js.map +7 -0
- package/dist/lib/frontend/organizationEvents.js +41 -0
- package/dist/lib/frontend/organizationEvents.js.map +7 -0
- package/dist/lib/frontend/useOrganizationScope.js +32 -0
- package/dist/lib/frontend/useOrganizationScope.js.map +7 -0
- package/dist/lib/hotkeys/index.js +128 -0
- package/dist/lib/hotkeys/index.js.map +7 -0
- package/dist/lib/i18n/app-dictionaries.js +17 -0
- package/dist/lib/i18n/app-dictionaries.js.map +7 -0
- package/dist/lib/i18n/config.js +7 -0
- package/dist/lib/i18n/config.js.map +7 -0
- package/dist/lib/i18n/context.js +50 -0
- package/dist/lib/i18n/context.js.map +7 -0
- package/dist/lib/i18n/server.js +68 -0
- package/dist/lib/i18n/server.js.map +7 -0
- package/dist/lib/i18n/translate.js +45 -0
- package/dist/lib/i18n/translate.js.map +7 -0
- package/dist/lib/indexers/error-log.js +82 -0
- package/dist/lib/indexers/error-log.js.map +7 -0
- package/dist/lib/indexers/status-log.js +80 -0
- package/dist/lib/indexers/status-log.js.map +7 -0
- package/dist/lib/lib/auth/jwt.js +34 -0
- package/dist/lib/lib/auth/jwt.js.map +7 -0
- package/dist/lib/lib/auth/server.js +77 -0
- package/dist/lib/lib/auth/server.js.map +7 -0
- package/dist/lib/lib/email/send.js +12 -0
- package/dist/lib/lib/email/send.js.map +7 -0
- package/dist/lib/lib/i18n/config.js +7 -0
- package/dist/lib/lib/i18n/config.js.map +7 -0
- package/dist/lib/lib/i18n/context.js +31 -0
- package/dist/lib/lib/i18n/context.js.map +7 -0
- package/dist/lib/lib/utils.js +9 -0
- package/dist/lib/lib/utils.js.map +7 -0
- package/dist/lib/location/countries.js +68 -0
- package/dist/lib/location/countries.js.map +7 -0
- package/dist/lib/modules/index.js +6 -0
- package/dist/lib/modules/index.js.map +7 -0
- package/dist/lib/modules/registry.js +18 -0
- package/dist/lib/modules/registry.js.map +7 -0
- package/dist/lib/openapi/crud.js +137 -0
- package/dist/lib/openapi/crud.js.map +7 -0
- package/dist/lib/openapi/generator.js +1131 -0
- package/dist/lib/openapi/generator.js.map +7 -0
- package/dist/lib/openapi/index.js +10 -0
- package/dist/lib/openapi/index.js.map +7 -0
- package/dist/lib/openapi/sanitize.js +110 -0
- package/dist/lib/openapi/sanitize.js.map +7 -0
- package/dist/lib/openapi/types.js +1 -0
- package/dist/lib/openapi/types.js.map +7 -0
- package/dist/lib/profiler/index.js +258 -0
- package/dist/lib/profiler/index.js.map +7 -0
- package/dist/lib/query/engine.js +729 -0
- package/dist/lib/query/engine.js.map +7 -0
- package/dist/lib/query/join-utils.js +195 -0
- package/dist/lib/query/join-utils.js.map +7 -0
- package/dist/lib/query/types.js +9 -0
- package/dist/lib/query/types.js.map +7 -0
- package/dist/lib/search/config.js +32 -0
- package/dist/lib/search/config.js.map +7 -0
- package/dist/lib/search/tokenize.js +34 -0
- package/dist/lib/search/tokenize.js.map +7 -0
- package/dist/lib/slugify.js +24 -0
- package/dist/lib/slugify.js.map +7 -0
- package/dist/lib/testing/bootstrap.js +51 -0
- package/dist/lib/testing/bootstrap.js.map +7 -0
- package/dist/lib/testing/index.js +17 -0
- package/dist/lib/testing/index.js.map +7 -0
- package/dist/lib/testing/renderWithProviders.js +15 -0
- package/dist/lib/testing/renderWithProviders.js.map +7 -0
- package/dist/lib/url.js +12 -0
- package/dist/lib/url.js.map +7 -0
- package/dist/lib/utils.js +13 -0
- package/dist/lib/utils.js.map +7 -0
- package/dist/lib/version.js +7 -0
- package/dist/lib/version.js.map +7 -0
- package/dist/modules/dashboard/widgets.js +1 -0
- package/dist/modules/dashboard/widgets.js.map +7 -0
- package/dist/modules/dsl.js +30 -0
- package/dist/modules/dsl.js.map +7 -0
- package/dist/modules/entities/kinds.js +22 -0
- package/dist/modules/entities/kinds.js.map +7 -0
- package/dist/modules/entities/options.js +26 -0
- package/dist/modules/entities/options.js.map +7 -0
- package/dist/modules/entities/validation.js +102 -0
- package/dist/modules/entities/validation.js.map +7 -0
- package/dist/modules/entities/validators.js +88 -0
- package/dist/modules/entities/validators.js.map +7 -0
- package/dist/modules/entities.js +1 -0
- package/dist/modules/entities.js.map +7 -0
- package/dist/modules/navigation/sidebarPreferences.js +50 -0
- package/dist/modules/navigation/sidebarPreferences.js.map +7 -0
- package/dist/modules/perspectives/types.js +1 -0
- package/dist/modules/perspectives/types.js.map +7 -0
- package/dist/modules/registry.js +96 -0
- package/dist/modules/registry.js.map +7 -0
- package/dist/modules/search.js +15 -0
- package/dist/modules/search.js.map +7 -0
- package/dist/modules/vector.js +1 -0
- package/dist/modules/vector.js.map +7 -0
- package/dist/modules/widgets/injection-loader.js +180 -0
- package/dist/modules/widgets/injection-loader.js.map +7 -0
- package/dist/modules/widgets/injection.js +1 -0
- package/dist/modules/widgets/injection.js.map +7 -0
- package/dist/security/features.js +23 -0
- package/dist/security/features.js.map +7 -0
- package/dist/types/pg.d.js +1 -0
- package/dist/types/pg.d.js.map +7 -0
- package/dist/types/react-email.d.js +1 -0
- package/dist/types/react-email.d.js.map +7 -0
- package/dist/types/resend.d.js +1 -0
- package/dist/types/resend.d.js.map +7 -0
- package/jest.config.cjs +22 -0
- package/package.json +88 -0
- package/src/index.ts +0 -0
- package/src/lib/api/__tests__/scoped.test.ts +38 -0
- package/src/lib/api/crud.ts +59 -0
- package/src/lib/api/scoped.ts +239 -0
- package/src/lib/auth/jwt.ts +39 -0
- package/src/lib/auth/server.ts +199 -0
- package/src/lib/boolean.ts +17 -0
- package/src/lib/bootstrap/appResolver.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +177 -0
- package/src/lib/bootstrap/factory.ts +108 -0
- package/src/lib/bootstrap/index.ts +23 -0
- package/src/lib/bootstrap/types.ts +31 -0
- package/src/lib/cache/segments.ts +56 -0
- package/src/lib/cli/progress.ts +55 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +84 -0
- package/src/lib/commands/__tests__/helpers.test.ts +42 -0
- package/src/lib/commands/command-bus.ts +349 -0
- package/src/lib/commands/customFieldSnapshots.ts +86 -0
- package/src/lib/commands/helpers.ts +143 -0
- package/src/lib/commands/index.ts +4 -0
- package/src/lib/commands/operationMetadata.ts +40 -0
- package/src/lib/commands/registry.ts +46 -0
- package/src/lib/commands/scope.ts +59 -0
- package/src/lib/commands/types.ts +63 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +333 -0
- package/src/lib/crud/__tests__/custom-fields.test.ts +150 -0
- package/src/lib/crud/cache-stats.ts +127 -0
- package/src/lib/crud/cache.ts +205 -0
- package/src/lib/crud/custom-fields-client.ts +54 -0
- package/src/lib/crud/custom-fields.ts +607 -0
- package/src/lib/crud/errors.ts +23 -0
- package/src/lib/crud/exporters.ts +188 -0
- package/src/lib/crud/factory.ts +1622 -0
- package/src/lib/crud/types.ts +29 -0
- package/src/lib/custom-fields/normalize.ts +45 -0
- package/src/lib/data/engine.ts +562 -0
- package/src/lib/db/escapeLikePattern.ts +2 -0
- package/src/lib/db/mikro.ts +100 -0
- package/src/lib/di/container.ts +105 -0
- package/src/lib/email/send.ts +18 -0
- package/src/lib/encryption/__tests__/customFieldValues.test.ts +63 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +115 -0
- package/src/lib/encryption/aes.ts +64 -0
- package/src/lib/encryption/customFieldValues.ts +67 -0
- package/src/lib/encryption/entityFields.ts +39 -0
- package/src/lib/encryption/entityIds.ts +107 -0
- package/src/lib/encryption/find.ts +81 -0
- package/src/lib/encryption/indexDoc.ts +104 -0
- package/src/lib/encryption/kms.ts +337 -0
- package/src/lib/encryption/subscriber.ts +416 -0
- package/src/lib/encryption/tenantDataEncryptionService.ts +313 -0
- package/src/lib/encryption/toggles.ts +15 -0
- package/src/lib/entities/naming.ts +6 -0
- package/src/lib/entities/system-entities.ts +43 -0
- package/src/lib/frontend/organizationEvents.ts +55 -0
- package/src/lib/frontend/useOrganizationScope.ts +30 -0
- package/src/lib/hotkeys/index.ts +168 -0
- package/src/lib/i18n/app-dictionaries.ts +18 -0
- package/src/lib/i18n/config.ts +4 -0
- package/src/lib/i18n/context.tsx +66 -0
- package/src/lib/i18n/server.ts +74 -0
- package/src/lib/i18n/translate.ts +54 -0
- package/src/lib/indexers/error-log.ts +106 -0
- package/src/lib/indexers/status-log.ts +119 -0
- package/src/lib/lib/auth/jwt.ts +39 -0
- package/src/lib/lib/auth/server.ts +94 -0
- package/src/lib/lib/email/send.ts +18 -0
- package/src/lib/lib/i18n/config.ts +4 -0
- package/src/lib/lib/i18n/context.tsx +38 -0
- package/src/lib/lib/utils.ts +6 -0
- package/src/lib/location/countries.ts +97 -0
- package/src/lib/modules/index.ts +1 -0
- package/src/lib/modules/registry.ts +18 -0
- package/src/lib/openapi/crud.ts +218 -0
- package/src/lib/openapi/generator.ts +1311 -0
- package/src/lib/openapi/index.ts +4 -0
- package/src/lib/openapi/sanitize.ts +137 -0
- package/src/lib/openapi/types.ts +79 -0
- package/src/lib/profiler/index.ts +371 -0
- package/src/lib/query/__tests__/engine.test.ts +274 -0
- package/src/lib/query/engine.ts +837 -0
- package/src/lib/query/join-utils.ts +238 -0
- package/src/lib/query/types.ts +121 -0
- package/src/lib/search/config.ts +49 -0
- package/src/lib/search/tokenize.ts +45 -0
- package/src/lib/slugify.ts +28 -0
- package/src/lib/testing/bootstrap.ts +124 -0
- package/src/lib/testing/index.ts +15 -0
- package/src/lib/testing/renderWithProviders.tsx +31 -0
- package/src/lib/url.ts +12 -0
- package/src/lib/utils.ts +17 -0
- package/src/lib/version.ts +5 -0
- package/src/modules/__tests__/dsl.test.ts +35 -0
- package/src/modules/__tests__/registry.test.ts +300 -0
- package/src/modules/dashboard/widgets.ts +57 -0
- package/src/modules/dsl.ts +32 -0
- package/src/modules/entities/__tests__/validation.test.ts +52 -0
- package/src/modules/entities/kinds.ts +20 -0
- package/src/modules/entities/options.ts +36 -0
- package/src/modules/entities/validation.ts +118 -0
- package/src/modules/entities/validators.ts +93 -0
- package/src/modules/entities.ts +102 -0
- package/src/modules/navigation/sidebarPreferences.ts +62 -0
- package/src/modules/perspectives/types.ts +40 -0
- package/src/modules/registry.ts +249 -0
- package/src/modules/search.ts +325 -0
- package/src/modules/vector.ts +122 -0
- package/src/modules/widgets/__tests__/injection.test.ts +48 -0
- package/src/modules/widgets/injection-loader.ts +235 -0
- package/src/modules/widgets/injection.ts +120 -0
- package/src/security/features.ts +22 -0
- package/src/types/pg.d.ts +2 -0
- package/src/types/react-email.d.ts +2 -0
- package/src/types/resend.d.ts +2 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import React, { createContext, useContext, useMemo } from 'react'
|
|
3
|
+
import type { Locale } from './config'
|
|
4
|
+
|
|
5
|
+
export type Dict = Record<string, string>
|
|
6
|
+
|
|
7
|
+
export type TranslateParams = Record<string, string | number>
|
|
8
|
+
|
|
9
|
+
export type TranslateFn = (
|
|
10
|
+
key: string,
|
|
11
|
+
fallbackOrParams?: string | TranslateParams,
|
|
12
|
+
params?: TranslateParams
|
|
13
|
+
) => string
|
|
14
|
+
|
|
15
|
+
export type I18nContextValue = {
|
|
16
|
+
locale: Locale
|
|
17
|
+
t: TranslateFn
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const I18nContext = createContext<I18nContextValue | null>(null)
|
|
21
|
+
|
|
22
|
+
function format(template: string, params?: TranslateParams) {
|
|
23
|
+
if (!params) return template
|
|
24
|
+
return template.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (_, doubleKey, singleKey) => {
|
|
25
|
+
const key = doubleKey ?? singleKey
|
|
26
|
+
if (!key) return _
|
|
27
|
+
const value = params[key]
|
|
28
|
+
if (value === undefined) {
|
|
29
|
+
return doubleKey ? `{{${key}}}` : `{${key}}`
|
|
30
|
+
}
|
|
31
|
+
return String(value)
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function I18nProvider({ children, locale, dict }: { children: React.ReactNode; locale: Locale; dict: Dict }) {
|
|
36
|
+
const value = useMemo<I18nContextValue>(() => ({
|
|
37
|
+
locale,
|
|
38
|
+
t: (key, fallbackOrParams, params) => {
|
|
39
|
+
let fallback: string | undefined
|
|
40
|
+
let resolvedParams: TranslateParams | undefined
|
|
41
|
+
|
|
42
|
+
if (typeof fallbackOrParams === 'string') {
|
|
43
|
+
fallback = fallbackOrParams
|
|
44
|
+
resolvedParams = params
|
|
45
|
+
} else {
|
|
46
|
+
resolvedParams = fallbackOrParams ?? params
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const template = dict[key] ?? fallback ?? key
|
|
50
|
+
return format(template, resolvedParams)
|
|
51
|
+
},
|
|
52
|
+
}), [locale, dict])
|
|
53
|
+
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useT() {
|
|
57
|
+
const ctx = useContext(I18nContext)
|
|
58
|
+
if (!ctx) throw new Error('useT must be used within I18nProvider')
|
|
59
|
+
return ctx.t
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useLocale() {
|
|
63
|
+
const ctx = useContext(I18nContext)
|
|
64
|
+
if (!ctx) throw new Error('useLocale must be used within I18nProvider')
|
|
65
|
+
return ctx.locale
|
|
66
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defaultLocale, locales, type Locale } from './config'
|
|
2
|
+
import type { Dict } from './context'
|
|
3
|
+
import { createFallbackTranslator, createTranslator } from './translate'
|
|
4
|
+
import { getModules } from '../modules/registry'
|
|
5
|
+
import { loadAppDictionary } from './app-dictionaries'
|
|
6
|
+
|
|
7
|
+
// Re-export for backwards compatibility
|
|
8
|
+
export { registerModules, getModules } from '../modules/registry'
|
|
9
|
+
export { registerAppDictionaryLoader } from './app-dictionaries'
|
|
10
|
+
|
|
11
|
+
function flattenDictionary(source: unknown, prefix = ''): Dict {
|
|
12
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return {}
|
|
13
|
+
const result: Dict = {}
|
|
14
|
+
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
|
15
|
+
if (!key) continue
|
|
16
|
+
const nextKey = prefix ? `${prefix}.${key}` : key
|
|
17
|
+
if (typeof value === 'string') {
|
|
18
|
+
result[nextKey] = value
|
|
19
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
20
|
+
Object.assign(result, flattenDictionary(value, nextKey))
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function detectLocale(): Promise<Locale> {
|
|
27
|
+
// Dynamic import to avoid requiring Next.js in non-Next.js contexts (CLI, tests)
|
|
28
|
+
try {
|
|
29
|
+
const { cookies, headers } = await import('next/headers')
|
|
30
|
+
try {
|
|
31
|
+
const c = (await cookies()).get('locale')?.value
|
|
32
|
+
if (c && locales.includes(c as Locale)) return c as Locale
|
|
33
|
+
} catch {
|
|
34
|
+
// cookies() may not be available outside request context (e.g., in tests)
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const accept = (await headers()).get('accept-language') || ''
|
|
38
|
+
const match = locales.find(l => new RegExp(`(^|,)\\s*${l}(-|;|,|$)`, 'i').test(accept))
|
|
39
|
+
if (match) return match
|
|
40
|
+
} catch {
|
|
41
|
+
// headers() may not be available outside request context (e.g., in tests)
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// next/headers not available (CLI context)
|
|
45
|
+
}
|
|
46
|
+
return defaultLocale
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function loadDictionary(locale: Locale): Promise<Dict> {
|
|
50
|
+
// Load from registry instead of @/ import (works in standalone packages)
|
|
51
|
+
const baseRaw = await loadAppDictionary(locale)
|
|
52
|
+
const merged: Dict = { ...flattenDictionary(baseRaw) }
|
|
53
|
+
const modules = getModules()
|
|
54
|
+
for (const m of modules) {
|
|
55
|
+
const dict = m.translations?.[locale]
|
|
56
|
+
if (dict) Object.assign(merged, flattenDictionary(dict))
|
|
57
|
+
}
|
|
58
|
+
return merged
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function resolveTranslations() {
|
|
62
|
+
const locale = await detectLocale()
|
|
63
|
+
const dict = await loadDictionary(locale)
|
|
64
|
+
const t = createTranslator(dict)
|
|
65
|
+
const translate = createFallbackTranslator(dict)
|
|
66
|
+
return { locale, dict, t, translate }
|
|
67
|
+
}
|
|
68
|
+
// Hint Next.js to keep this server-only; ignore if unavailable when running scripts outside Next.
|
|
69
|
+
try {
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
71
|
+
require('server-only')
|
|
72
|
+
} catch {
|
|
73
|
+
// noop: allows running generator scripts without Next's server-only package
|
|
74
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Dict, TranslateFn, TranslateParams } from './context'
|
|
2
|
+
|
|
3
|
+
export type TranslateWithFallbackFn = (key: string, fallback?: string, params?: TranslateParams) => string
|
|
4
|
+
|
|
5
|
+
function format(template: string, params?: TranslateParams) {
|
|
6
|
+
if (!params) return template
|
|
7
|
+
return template.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (match, doubleKey, singleKey) => {
|
|
8
|
+
const key = doubleKey ?? singleKey
|
|
9
|
+
if (!key) return match
|
|
10
|
+
const value = params[key]
|
|
11
|
+
if (value === undefined) return match
|
|
12
|
+
return String(value)
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createTranslator(dict: Dict): TranslateFn {
|
|
17
|
+
const translator = ((key: string, fallbackOrParams?: string | TranslateParams, params?: TranslateParams) => {
|
|
18
|
+
let fallback: string | undefined
|
|
19
|
+
let resolvedParams: TranslateParams | undefined
|
|
20
|
+
|
|
21
|
+
if (typeof fallbackOrParams === 'string') {
|
|
22
|
+
fallback = fallbackOrParams
|
|
23
|
+
resolvedParams = params
|
|
24
|
+
} else {
|
|
25
|
+
resolvedParams = fallbackOrParams
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const template = dict[key] ?? fallback ?? key
|
|
29
|
+
return format(template, resolvedParams)
|
|
30
|
+
}) as TranslateFn
|
|
31
|
+
|
|
32
|
+
return translator
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function translateWithFallback(
|
|
36
|
+
t: TranslateFn,
|
|
37
|
+
key: string,
|
|
38
|
+
fallback?: string,
|
|
39
|
+
params?: TranslateParams,
|
|
40
|
+
): string {
|
|
41
|
+
const value = params ? t(key, params) : t(key)
|
|
42
|
+
if (value !== key) return value
|
|
43
|
+
if (fallback === undefined) return key
|
|
44
|
+
return format(fallback, params)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createTranslatorWithFallback(translate: TranslateFn): TranslateWithFallbackFn {
|
|
48
|
+
return (key, fallback, params) => translateWithFallback(translate, key, fallback, params)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createFallbackTranslator(dict: Dict): TranslateWithFallbackFn {
|
|
52
|
+
const t = createTranslator(dict)
|
|
53
|
+
return (key, fallback, params) => translateWithFallback(t, key, fallback, params)
|
|
54
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { Knex } from 'knex'
|
|
3
|
+
|
|
4
|
+
export type IndexerErrorSource = 'query_index' | 'vector' | 'fulltext'
|
|
5
|
+
|
|
6
|
+
export type RecordIndexerErrorInput = {
|
|
7
|
+
source: IndexerErrorSource
|
|
8
|
+
handler: string
|
|
9
|
+
error: unknown
|
|
10
|
+
entityType?: string | null
|
|
11
|
+
recordId?: string | null
|
|
12
|
+
tenantId?: string | null
|
|
13
|
+
organizationId?: string | null
|
|
14
|
+
payload?: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type RecordIndexerErrorDeps = {
|
|
18
|
+
em?: EntityManager
|
|
19
|
+
knex?: Knex
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_MESSAGE_LENGTH = 8_192
|
|
23
|
+
const MAX_STACK_LENGTH = 32_768
|
|
24
|
+
|
|
25
|
+
function truncate(input: string | null | undefined, limit: number): string | null {
|
|
26
|
+
if (!input) return null
|
|
27
|
+
return input.length > limit ? `${input.slice(0, limit - 3)}...` : input
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeError(error: unknown): { message: string; stack: string | null } {
|
|
31
|
+
if (error instanceof Error) {
|
|
32
|
+
return {
|
|
33
|
+
message: error.message || error.name || 'Unknown error',
|
|
34
|
+
stack: typeof error.stack === 'string' ? error.stack : null,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (typeof error === 'string') {
|
|
38
|
+
return { message: error, stack: null }
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const json = JSON.stringify(error)
|
|
42
|
+
return { message: json, stack: null }
|
|
43
|
+
} catch {
|
|
44
|
+
return { message: String(error ?? 'Unknown error'), stack: null }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function safeJson(value: unknown): unknown {
|
|
49
|
+
if (value === undefined) return null
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(JSON.stringify(value))
|
|
52
|
+
} catch {
|
|
53
|
+
if (value == null) return null
|
|
54
|
+
if (typeof value === 'object') {
|
|
55
|
+
return { note: 'unserializable', asString: String(value) }
|
|
56
|
+
}
|
|
57
|
+
return value
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pickKnex(deps: RecordIndexerErrorDeps): Knex | null {
|
|
62
|
+
if (deps.knex) return deps.knex
|
|
63
|
+
if (deps.em) {
|
|
64
|
+
try {
|
|
65
|
+
const connection = deps.em.getConnection()
|
|
66
|
+
if (connection && typeof connection.getKnex === 'function') {
|
|
67
|
+
return connection.getKnex()
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function recordIndexerError(deps: RecordIndexerErrorDeps, input: RecordIndexerErrorInput): Promise<void> {
|
|
77
|
+
const knex = pickKnex(deps)
|
|
78
|
+
if (!knex) {
|
|
79
|
+
console.error('[indexers] Unable to record indexer error (missing knex connection)', {
|
|
80
|
+
source: input.source,
|
|
81
|
+
handler: input.handler,
|
|
82
|
+
})
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { message, stack } = normalizeError(input.error)
|
|
87
|
+
const payload = safeJson(input.payload)
|
|
88
|
+
const now = new Date()
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await knex('indexer_error_logs').insert({
|
|
92
|
+
source: input.source,
|
|
93
|
+
handler: input.handler,
|
|
94
|
+
entity_type: input.entityType ?? null,
|
|
95
|
+
record_id: input.recordId ?? null,
|
|
96
|
+
tenant_id: input.tenantId ?? null,
|
|
97
|
+
organization_id: input.organizationId ?? null,
|
|
98
|
+
payload,
|
|
99
|
+
message: truncate(message, MAX_MESSAGE_LENGTH),
|
|
100
|
+
stack: truncate(stack, MAX_STACK_LENGTH),
|
|
101
|
+
occurred_at: now,
|
|
102
|
+
})
|
|
103
|
+
} catch (loggingError) {
|
|
104
|
+
console.error('[indexers] Failed to persist indexer error', loggingError)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { Knex } from 'knex'
|
|
3
|
+
import type { IndexerErrorSource } from './error-log'
|
|
4
|
+
|
|
5
|
+
export type IndexerLogLevel = 'info' | 'warn'
|
|
6
|
+
|
|
7
|
+
export type RecordIndexerLogInput = {
|
|
8
|
+
source: IndexerErrorSource
|
|
9
|
+
handler: string
|
|
10
|
+
message: string
|
|
11
|
+
level?: IndexerLogLevel
|
|
12
|
+
entityType?: string | null
|
|
13
|
+
recordId?: string | null
|
|
14
|
+
tenantId?: string | null
|
|
15
|
+
organizationId?: string | null
|
|
16
|
+
details?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type RecordIndexerLogDeps = {
|
|
20
|
+
em?: EntityManager
|
|
21
|
+
knex?: Knex
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_MESSAGE_LENGTH = 4_096
|
|
25
|
+
const MAX_DELETE_BATCH = 5_000
|
|
26
|
+
const MAX_LOGS_PER_SOURCE = 10_000
|
|
27
|
+
|
|
28
|
+
function truncate(input: string | null | undefined, limit: number): string | null {
|
|
29
|
+
if (!input) return null
|
|
30
|
+
return input.length > limit ? `${input.slice(0, limit - 3)}...` : input
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeJson(value: unknown): unknown {
|
|
34
|
+
if (value === undefined) return null
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(JSON.stringify(value))
|
|
37
|
+
} catch {
|
|
38
|
+
if (value == null) return null
|
|
39
|
+
if (typeof value === 'object') {
|
|
40
|
+
return { note: 'unserializable', asString: String(value) }
|
|
41
|
+
}
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pickKnex(deps: RecordIndexerLogDeps): Knex | null {
|
|
47
|
+
if (deps.knex) return deps.knex
|
|
48
|
+
if (deps.em) {
|
|
49
|
+
try {
|
|
50
|
+
const connection = deps.em.getConnection()
|
|
51
|
+
if (connection && typeof connection.getKnex === 'function') {
|
|
52
|
+
return connection.getKnex()
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function pruneExcessLogs(knex: Knex, source: IndexerErrorSource): Promise<void> {
|
|
62
|
+
const rows = await knex('indexer_status_logs')
|
|
63
|
+
.select('id')
|
|
64
|
+
.where('source', source)
|
|
65
|
+
.orderBy('occurred_at', 'desc')
|
|
66
|
+
.orderBy('id', 'desc')
|
|
67
|
+
.offset(MAX_LOGS_PER_SOURCE)
|
|
68
|
+
.limit(MAX_DELETE_BATCH)
|
|
69
|
+
|
|
70
|
+
if (!rows.length) return
|
|
71
|
+
const ids = rows.map((row: any) => row.id).filter(Boolean)
|
|
72
|
+
if (!ids.length) return
|
|
73
|
+
await knex('indexer_status_logs')
|
|
74
|
+
.whereIn('id', ids)
|
|
75
|
+
.del()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function recordIndexerLog(
|
|
79
|
+
deps: RecordIndexerLogDeps,
|
|
80
|
+
input: RecordIndexerLogInput,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const knex = pickKnex(deps)
|
|
83
|
+
if (!knex) {
|
|
84
|
+
console.warn('[indexers] Unable to record indexer log (missing knex connection)', {
|
|
85
|
+
source: input.source,
|
|
86
|
+
handler: input.handler,
|
|
87
|
+
})
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const level: IndexerLogLevel = input.level === 'warn' ? 'warn' : 'info'
|
|
92
|
+
const message = truncate(input.message, MAX_MESSAGE_LENGTH) ?? '—'
|
|
93
|
+
const details = safeJson(input.details)
|
|
94
|
+
const occurredAt = new Date()
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await knex('indexer_status_logs').insert({
|
|
98
|
+
source: input.source,
|
|
99
|
+
handler: input.handler,
|
|
100
|
+
level,
|
|
101
|
+
entity_type: input.entityType ?? null,
|
|
102
|
+
record_id: input.recordId ?? null,
|
|
103
|
+
tenant_id: input.tenantId ?? null,
|
|
104
|
+
organization_id: input.organizationId ?? null,
|
|
105
|
+
message,
|
|
106
|
+
details,
|
|
107
|
+
occurred_at: occurredAt,
|
|
108
|
+
})
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error('[indexers] Failed to persist indexer log', error)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await pruneExcessLogs(knex, input.source)
|
|
116
|
+
} catch (pruneError) {
|
|
117
|
+
console.warn('[indexers] Failed to prune indexer logs', pruneError)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
function base64url(input: Buffer | string) {
|
|
4
|
+
return (typeof input === 'string' ? Buffer.from(input) : input)
|
|
5
|
+
.toString('base64')
|
|
6
|
+
.replace(/=/g, '')
|
|
7
|
+
.replace(/\+/g, '-')
|
|
8
|
+
.replace(/\//g, '_')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type JwtPayload = Record<string, any>
|
|
12
|
+
|
|
13
|
+
export function signJwt(payload: JwtPayload, secret = process.env.JWT_SECRET!, expiresInSec = 60 * 60 * 8) {
|
|
14
|
+
if (!secret) throw new Error('JWT_SECRET is not set')
|
|
15
|
+
const header = { alg: 'HS256', typ: 'JWT' }
|
|
16
|
+
const now = Math.floor(Date.now() / 1000)
|
|
17
|
+
const body = { iat: now, exp: now + expiresInSec, ...payload }
|
|
18
|
+
const encHeader = base64url(JSON.stringify(header))
|
|
19
|
+
const encBody = base64url(JSON.stringify(body))
|
|
20
|
+
const data = `${encHeader}.${encBody}`
|
|
21
|
+
const sig = crypto.createHmac('sha256', secret).update(data).digest()
|
|
22
|
+
const encSig = base64url(sig)
|
|
23
|
+
return `${data}.${encSig}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function verifyJwt(token: string, secret = process.env.JWT_SECRET!) {
|
|
27
|
+
if (!secret) throw new Error('JWT_SECRET is not set')
|
|
28
|
+
const parts = token.split('.')
|
|
29
|
+
if (parts.length !== 3) return null
|
|
30
|
+
const [h, p, s] = parts
|
|
31
|
+
const data = `${h}.${p}`
|
|
32
|
+
const expected = base64url(crypto.createHmac('sha256', secret).update(data).digest())
|
|
33
|
+
if (!crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) return null
|
|
34
|
+
const payload = JSON.parse(Buffer.from(p, 'base64').toString('utf8'))
|
|
35
|
+
const now = Math.floor(Date.now() / 1000)
|
|
36
|
+
if (payload.exp && now > payload.exp) return null
|
|
37
|
+
return payload
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { cookies } from 'next/headers'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { verifyJwt } from './jwt'
|
|
4
|
+
|
|
5
|
+
export type AuthContext = {
|
|
6
|
+
sub: string
|
|
7
|
+
tenantId: string | null
|
|
8
|
+
orgId: string | null
|
|
9
|
+
email?: string
|
|
10
|
+
roles?: string[]
|
|
11
|
+
isApiKey?: boolean
|
|
12
|
+
keyId?: string
|
|
13
|
+
keyName?: string
|
|
14
|
+
[k: string]: any
|
|
15
|
+
} | null
|
|
16
|
+
|
|
17
|
+
async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
18
|
+
if (!secret) return null
|
|
19
|
+
try {
|
|
20
|
+
const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
|
|
21
|
+
const container = await createRequestContainer()
|
|
22
|
+
const em = (container.resolve('em') as EntityManager)
|
|
23
|
+
const { findApiKeyBySecret } = await import('@open-mercato/core/modules/api_keys/services/apiKeyService')
|
|
24
|
+
const { Role } = await import('@open-mercato/core/modules/auth/data/entities')
|
|
25
|
+
|
|
26
|
+
const record = await findApiKeyBySecret(em, secret)
|
|
27
|
+
if (!record) return null
|
|
28
|
+
|
|
29
|
+
const roleIds = Array.isArray(record.rolesJson) ? record.rolesJson : []
|
|
30
|
+
const roles = roleIds.length
|
|
31
|
+
? await em.find(Role, { id: { $in: roleIds as any } } as any)
|
|
32
|
+
: []
|
|
33
|
+
const roleNames = roles.map((role) => role.name).filter((name): name is string => typeof name === 'string' && name.length > 0)
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
record.lastUsedAt = new Date()
|
|
37
|
+
await em.persistAndFlush(record)
|
|
38
|
+
} catch {}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
sub: `api_key:${record.id}`,
|
|
42
|
+
tenantId: record.tenantId ?? null,
|
|
43
|
+
orgId: record.organizationId ?? null,
|
|
44
|
+
roles: roleNames,
|
|
45
|
+
isApiKey: true,
|
|
46
|
+
keyId: record.id,
|
|
47
|
+
keyName: record.name,
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractApiKey(req: Request): string | null {
|
|
55
|
+
const header = (req.headers.get('x-api-key') || '').trim()
|
|
56
|
+
if (header) return header
|
|
57
|
+
const authHeader = (req.headers.get('authorization') || '').trim()
|
|
58
|
+
if (authHeader.toLowerCase().startsWith('apikey ')) {
|
|
59
|
+
return authHeader.slice(7).trim()
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getAuthFromCookies(): Promise<AuthContext> {
|
|
65
|
+
const token = (await cookies()).get('auth_token')?.value
|
|
66
|
+
if (!token) return null
|
|
67
|
+
try {
|
|
68
|
+
const payload = verifyJwt(token)
|
|
69
|
+
return payload
|
|
70
|
+
} catch {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getAuthFromRequest(req: Request): Promise<AuthContext> {
|
|
76
|
+
const authHeader = (req.headers.get('authorization') || '').trim()
|
|
77
|
+
let token: string | undefined
|
|
78
|
+
if (authHeader.toLowerCase().startsWith('bearer ')) token = authHeader.slice(7).trim()
|
|
79
|
+
if (!token) {
|
|
80
|
+
const cookie = req.headers.get('cookie') || ''
|
|
81
|
+
const match = cookie.match(/(?:^|;\s*)auth_token=([^;]+)/)
|
|
82
|
+
if (match) token = decodeURIComponent(match[1])
|
|
83
|
+
}
|
|
84
|
+
if (token) {
|
|
85
|
+
try {
|
|
86
|
+
const payload = verifyJwt(token)
|
|
87
|
+
if (payload) return payload
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const apiKey = extractApiKey(req)
|
|
92
|
+
if (!apiKey) return null
|
|
93
|
+
return resolveApiKeyAuth(apiKey)
|
|
94
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Resend } from 'resend'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
export type SendEmailOptions = {
|
|
5
|
+
to: string
|
|
6
|
+
subject: string
|
|
7
|
+
react: React.ReactElement
|
|
8
|
+
from?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function sendEmail({ to, subject, react, from }: SendEmailOptions) {
|
|
12
|
+
const apiKey = process.env.RESEND_API_KEY
|
|
13
|
+
if (!apiKey) throw new Error('RESEND_API_KEY is not set')
|
|
14
|
+
const resend = new Resend(apiKey)
|
|
15
|
+
const fromAddr = from || process.env.EMAIL_FROM || 'no-reply@localhost'
|
|
16
|
+
await resend.emails.send({ to, subject, from: fromAddr, react })
|
|
17
|
+
}
|
|
18
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import React, { createContext, useContext, useMemo } from 'react'
|
|
3
|
+
import type { Locale } from './config'
|
|
4
|
+
|
|
5
|
+
export type Dict = Record<string, string>
|
|
6
|
+
|
|
7
|
+
export type I18nContextValue = {
|
|
8
|
+
locale: Locale
|
|
9
|
+
t: (key: string, params?: Record<string, string | number>) => string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const I18nContext = createContext<I18nContextValue | null>(null)
|
|
13
|
+
|
|
14
|
+
function format(template: string, params?: Record<string, string | number>) {
|
|
15
|
+
if (!params) return template
|
|
16
|
+
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function I18nProvider({ children, locale, dict }: { children: React.ReactNode; locale: Locale; dict: Dict }) {
|
|
20
|
+
const value = useMemo<I18nContextValue>(() => ({
|
|
21
|
+
locale,
|
|
22
|
+
t: (key, params) => format(dict[key] ?? key, params),
|
|
23
|
+
}), [locale, dict])
|
|
24
|
+
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useT() {
|
|
28
|
+
const ctx = useContext(I18nContext)
|
|
29
|
+
if (!ctx) throw new Error('useT must be used within I18nProvider')
|
|
30
|
+
return ctx.t
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useLocale() {
|
|
34
|
+
const ctx = useContext(I18nContext)
|
|
35
|
+
if (!ctx) throw new Error('useLocale must be used within I18nProvider')
|
|
36
|
+
return ctx.locale
|
|
37
|
+
}
|
|
38
|
+
|