@oxy.so/services 13.5.0 → 13.7.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.
- package/lib/commonjs/ui/components/AccountLanguageBoundary.js +35 -0
- package/lib/commonjs/ui/components/AccountLanguageBoundary.js.map +1 -0
- package/lib/commonjs/ui/components/LanguageBridge.js +37 -34
- package/lib/commonjs/ui/components/LanguageBridge.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +19 -26
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js +8 -2
- package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +19 -2
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/hooks/useEntryLink.js +97 -0
- package/lib/commonjs/ui/hooks/useEntryLink.js.map +1 -0
- package/lib/commonjs/ui/hooks/useLanguageManagement.js +45 -14
- package/lib/commonjs/ui/hooks/useLanguageManagement.js.map +1 -1
- package/lib/commonjs/ui/screens/AccountMembersScreen.js +8 -1
- package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/commonjs/ui/utils/entryLink.js +54 -0
- package/lib/commonjs/ui/utils/entryLink.js.map +1 -0
- package/lib/module/ui/components/AccountLanguageBoundary.js +32 -0
- package/lib/module/ui/components/AccountLanguageBoundary.js.map +1 -0
- package/lib/module/ui/components/LanguageBridge.js +38 -35
- package/lib/module/ui/components/LanguageBridge.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +18 -27
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/components/signIn/OxySignUpPanel.js +8 -2
- package/lib/module/ui/components/signIn/OxySignUpPanel.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +19 -2
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/hooks/useEntryLink.js +94 -0
- package/lib/module/ui/hooks/useEntryLink.js.map +1 -0
- package/lib/module/ui/hooks/useLanguageManagement.js +45 -14
- package/lib/module/ui/hooks/useLanguageManagement.js.map +1 -1
- package/lib/module/ui/screens/AccountMembersScreen.js +8 -1
- package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/module/ui/utils/entryLink.js +49 -0
- package/lib/module/ui/utils/entryLink.js.map +1 -0
- package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts +8 -0
- package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts +7 -17
- package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +5 -0
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts +15 -0
- package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts +5 -1
- package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +4 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/entryLink.d.ts +8 -0
- package/lib/typescript/commonjs/ui/utils/entryLink.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts +8 -0
- package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/LanguageBridge.d.ts +7 -17
- package/lib/typescript/module/ui/components/LanguageBridge.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +5 -0
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useEntryLink.d.ts +15 -0
- package/lib/typescript/module/ui/hooks/useEntryLink.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts +5 -1
- package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +4 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/entryLink.d.ts +8 -0
- package/lib/typescript/module/ui/utils/entryLink.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/ui/components/AccountLanguageBoundary.tsx +35 -0
- package/src/ui/components/LanguageBridge.tsx +43 -39
- package/src/ui/components/OxyProvider.tsx +18 -24
- package/src/ui/components/__tests__/AccountLanguageBoundary.test.tsx +87 -0
- package/src/ui/components/__tests__/LanguageBridge.test.tsx +80 -60
- package/src/ui/components/signIn/OxySignUpPanel.tsx +9 -1
- package/src/ui/context/OxyContext.tsx +21 -1
- package/src/ui/context/oxyContextTypes.ts +5 -0
- package/src/ui/hooks/useEntryLink.ts +103 -0
- package/src/ui/hooks/useLanguageManagement.ts +53 -18
- package/src/ui/screens/AccountMembersScreen.tsx +8 -1
- package/src/ui/types/navigation.ts +4 -0
- package/src/ui/utils/__tests__/entryLink.test.ts +30 -0
- package/src/ui/utils/entryLink.ts +61 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Linking, Platform } from 'react-native';
|
|
3
|
+
import type { CampaignAttribution } from '@oxy.so/contracts';
|
|
4
|
+
import type { StorageInterface } from '../utils/storageHelpers';
|
|
5
|
+
import { readCampaignAttribution, readEntryLink } from '../utils/entryLink';
|
|
6
|
+
|
|
7
|
+
/** One receiver for initial and warm native links and web entry parameters. */
|
|
8
|
+
export function useEntryLink({
|
|
9
|
+
storage,
|
|
10
|
+
storageKey,
|
|
11
|
+
captureCampaign,
|
|
12
|
+
onError,
|
|
13
|
+
}: {
|
|
14
|
+
storage: StorageInterface | null;
|
|
15
|
+
storageKey: string;
|
|
16
|
+
captureCampaign: boolean;
|
|
17
|
+
onError: (message: string, error?: unknown) => void;
|
|
18
|
+
}) {
|
|
19
|
+
const [entry, setEntry] = useState<{
|
|
20
|
+
url: string | null | undefined;
|
|
21
|
+
ready: boolean;
|
|
22
|
+
firstCampaign?: CampaignAttribution;
|
|
23
|
+
}>({ url: undefined, ready: false });
|
|
24
|
+
const [campaignAttribution, setCampaignAttribution] = useState<CampaignAttribution>();
|
|
25
|
+
const [campaignReady, setCampaignReady] = useState(false);
|
|
26
|
+
const captures = useRef(Promise.resolve());
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let active = true;
|
|
30
|
+
const subscription = Linking.addEventListener('url', ({ url }) => {
|
|
31
|
+
setEntry((current) => ({
|
|
32
|
+
...current,
|
|
33
|
+
url,
|
|
34
|
+
firstCampaign: current.firstCampaign ?? readEntryLink(url).campaignAttribution,
|
|
35
|
+
}));
|
|
36
|
+
});
|
|
37
|
+
const initialUrl =
|
|
38
|
+
Platform.OS === 'web' && typeof window !== 'undefined'
|
|
39
|
+
? Promise.resolve(window.location.href)
|
|
40
|
+
: Linking.getInitialURL();
|
|
41
|
+
initialUrl
|
|
42
|
+
.then((initial) => {
|
|
43
|
+
if (active)
|
|
44
|
+
setEntry((current) => ({
|
|
45
|
+
url: current.url === undefined ? initial : current.url,
|
|
46
|
+
ready: true,
|
|
47
|
+
firstCampaign:
|
|
48
|
+
readEntryLink(initial ?? null).campaignAttribution ?? current.firstCampaign,
|
|
49
|
+
}));
|
|
50
|
+
})
|
|
51
|
+
.catch((error: unknown) => {
|
|
52
|
+
onError('Failed to read the initial entry link', error);
|
|
53
|
+
if (active) setEntry((current) => ({ ...current, url: current.url ?? null, ready: true }));
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
active = false;
|
|
57
|
+
subscription.remove();
|
|
58
|
+
};
|
|
59
|
+
}, [onError]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!storage || !entry.ready) return;
|
|
63
|
+
let active = true;
|
|
64
|
+
async function capture() {
|
|
65
|
+
if (!captureCampaign) {
|
|
66
|
+
if (active) setCampaignReady(true);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let stored: CampaignAttribution | undefined;
|
|
70
|
+
const raw = await storage?.getItem(storageKey);
|
|
71
|
+
if (raw) {
|
|
72
|
+
try {
|
|
73
|
+
stored = readCampaignAttribution(JSON.parse(raw));
|
|
74
|
+
} catch {
|
|
75
|
+
onError('Stored campaign attribution is malformed');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const selected = stored ?? entry.firstCampaign;
|
|
79
|
+
if (selected && !stored) await storage?.setItem(storageKey, JSON.stringify(selected));
|
|
80
|
+
if (active) {
|
|
81
|
+
setCampaignAttribution(selected);
|
|
82
|
+
setCampaignReady(true);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
captures.current = captures.current.then(capture).catch((error: unknown) => {
|
|
86
|
+
onError('Failed to persist campaign attribution', error);
|
|
87
|
+
if (active) {
|
|
88
|
+
setCampaignAttribution(entry.firstCampaign);
|
|
89
|
+
setCampaignReady(true);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return () => {
|
|
93
|
+
active = false;
|
|
94
|
+
};
|
|
95
|
+
}, [captureCampaign, entry.ready, entry.firstCampaign, onError, storage, storageKey]);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
url: entry.url,
|
|
99
|
+
ready: entry.ready,
|
|
100
|
+
campaignAttribution,
|
|
101
|
+
campaignReady: !captureCampaign || campaignReady,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -12,11 +12,15 @@ import {
|
|
|
12
12
|
normalizeLocale,
|
|
13
13
|
} from '@oxy.so/core';
|
|
14
14
|
import type { StorageInterface } from '../utils/storageHelpers';
|
|
15
|
+
import { readEntryLink } from '../utils/entryLink';
|
|
15
16
|
import { extractErrorMessage } from '../utils/errorHandlers';
|
|
16
17
|
|
|
17
18
|
export interface UseLanguageManagementOptions {
|
|
18
19
|
storage: StorageInterface | null;
|
|
19
20
|
languageKey: string;
|
|
21
|
+
entryUrl?: string | null;
|
|
22
|
+
entryLinkReady?: boolean;
|
|
23
|
+
legacyLanguageKeys?: readonly string[];
|
|
20
24
|
/**
|
|
21
25
|
* The active account, when signed in. When present, the account's primary
|
|
22
26
|
* locale (`languages[0]`) is authoritative for the app's UI locale; when
|
|
@@ -34,6 +38,7 @@ export interface UseLanguageManagementResult {
|
|
|
34
38
|
* locally-chosen guest override, otherwise the device locale.
|
|
35
39
|
*/
|
|
36
40
|
currentLanguage: string;
|
|
41
|
+
isLanguageReady: boolean;
|
|
37
42
|
/**
|
|
38
43
|
* The ordered account locales (primary first) when signed in, or the locally
|
|
39
44
|
* persisted selection when signed out. Empty only before the guest override loads
|
|
@@ -145,6 +150,9 @@ function resolveDeviceLocale(): string {
|
|
|
145
150
|
export const useLanguageManagement = ({
|
|
146
151
|
storage,
|
|
147
152
|
languageKey,
|
|
153
|
+
entryUrl = null,
|
|
154
|
+
entryLinkReady = true,
|
|
155
|
+
legacyLanguageKeys,
|
|
148
156
|
user,
|
|
149
157
|
onError,
|
|
150
158
|
logger,
|
|
@@ -156,6 +164,8 @@ export const useLanguageManagement = ({
|
|
|
156
164
|
// Until it loads, the UI uses the device locale.
|
|
157
165
|
const [guestLanguages, setGuestLanguages] = useState<string[]>([]);
|
|
158
166
|
const selectionVersion = useRef(0);
|
|
167
|
+
const [isLanguageReady, setLanguageReady] = useState(false);
|
|
168
|
+
const legacyKeysSignature = JSON.stringify(legacyLanguageKeys ?? []);
|
|
159
169
|
|
|
160
170
|
const loadLanguageFromStorage = useCallback(async (): Promise<void> => {
|
|
161
171
|
if (!storage) {
|
|
@@ -164,24 +174,45 @@ export const useLanguageManagement = ({
|
|
|
164
174
|
|
|
165
175
|
const version = selectionVersion.current;
|
|
166
176
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
function parseStored(raw: string | null): string[] {
|
|
178
|
+
let stored: unknown = raw;
|
|
179
|
+
if (raw?.startsWith('[') || raw?.startsWith('"')) {
|
|
180
|
+
try {
|
|
181
|
+
stored = JSON.parse(raw);
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const entries = Array.isArray(stored) ? stored : [stored];
|
|
187
|
+
return [
|
|
188
|
+
...new Set(
|
|
189
|
+
entries.flatMap((entry) => {
|
|
190
|
+
const locale = typeof entry === 'string' ? normalizeLocale(entry) : undefined;
|
|
191
|
+
return locale ? [locale] : [];
|
|
192
|
+
}),
|
|
193
|
+
),
|
|
194
|
+
];
|
|
172
195
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
196
|
+
let normalized = parseStored(await storage.getItem(languageKey));
|
|
197
|
+
let migratedKey: string | undefined;
|
|
198
|
+
const legacyKeys: string[] = JSON.parse(legacyKeysSignature);
|
|
199
|
+
if (!normalized.length) {
|
|
200
|
+
for (const key of legacyKeys) {
|
|
201
|
+
normalized = parseStored(await storage.getItem(key));
|
|
202
|
+
if (normalized.length) {
|
|
203
|
+
migratedKey = key;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const linkedLanguage = readEntryLink(entryUrl).language;
|
|
209
|
+
const selected = linkedLanguage ? [linkedLanguage] : normalized;
|
|
210
|
+
if (version !== selectionVersion.current) return;
|
|
211
|
+
if (linkedLanguage || migratedKey) {
|
|
212
|
+
await storage.setItem(languageKey, JSON.stringify(selected));
|
|
213
|
+
if (migratedKey) await storage.removeItem(migratedKey);
|
|
184
214
|
}
|
|
215
|
+
if (selected.length && version === selectionVersion.current) setGuestLanguages(selected);
|
|
185
216
|
} catch (error) {
|
|
186
217
|
const message = extractErrorMessage(error, 'Failed to load language preference');
|
|
187
218
|
onError?.({
|
|
@@ -194,16 +225,19 @@ export const useLanguageManagement = ({
|
|
|
194
225
|
} else if (__DEV__) {
|
|
195
226
|
console.warn('Failed to load language preference:', error);
|
|
196
227
|
}
|
|
228
|
+
} finally {
|
|
229
|
+
setLanguageReady(true);
|
|
197
230
|
}
|
|
198
|
-
}, [languageKey, logger, onError, storage]);
|
|
231
|
+
}, [entryUrl, languageKey, legacyKeysSignature, logger, onError, storage]);
|
|
199
232
|
|
|
200
233
|
useEffect(() => {
|
|
234
|
+
if (!entryLinkReady) return;
|
|
201
235
|
loadLanguageFromStorage().catch((error) => {
|
|
202
236
|
if (logger) {
|
|
203
237
|
logger('Unexpected error loading language', error);
|
|
204
238
|
}
|
|
205
239
|
});
|
|
206
|
-
}, [loadLanguageFromStorage, logger]);
|
|
240
|
+
}, [entryLinkReady, loadLanguageFromStorage, logger]);
|
|
207
241
|
|
|
208
242
|
const setLanguages = useCallback(
|
|
209
243
|
async (locales: readonly string[]): Promise<void> => {
|
|
@@ -268,6 +302,7 @@ export const useLanguageManagement = ({
|
|
|
268
302
|
|
|
269
303
|
return {
|
|
270
304
|
currentLanguage,
|
|
305
|
+
isLanguageReady,
|
|
271
306
|
languages,
|
|
272
307
|
metadata,
|
|
273
308
|
languageName,
|
|
@@ -111,7 +111,14 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
|
|
|
111
111
|
);
|
|
112
112
|
const memberProfilesQuery = useQuery({
|
|
113
113
|
queryKey: ['users', 'by-ids', memberUserIds],
|
|
114
|
-
|
|
114
|
+
// `lookupMany`, not `getMany`: a chunk that failed is unknown, not absent.
|
|
115
|
+
// Failing the query lets React Query retry it, where a resolved empty
|
|
116
|
+
// answer would be cached as "no handle" for the whole stale window.
|
|
117
|
+
queryFn: async () => {
|
|
118
|
+
const lookup = await oxyServices.users.lookupMany(memberUserIds);
|
|
119
|
+
if (lookup.failed.length > 0) throw lookup.error ?? new Error('Member profiles unavailable');
|
|
120
|
+
return lookup.found;
|
|
121
|
+
},
|
|
115
122
|
enabled: canUsePrivateApi && memberUserIds.length > 0,
|
|
116
123
|
});
|
|
117
124
|
const memberLabelByUserId = useMemo(() => {
|
|
@@ -182,9 +182,13 @@ export interface OxyProviderProps {
|
|
|
182
182
|
* manages it independently of the account/device locale.
|
|
183
183
|
*/
|
|
184
184
|
language?: OxyLanguageConfig;
|
|
185
|
+
/** Persist bounded first-touch link labels and attach them only to a new account. */
|
|
186
|
+
campaignAttribution?: boolean;
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
export interface OxyLanguageConfig {
|
|
190
|
+
/** Previous app-owned storage keys, migrated once without replacing a current SDK choice. */
|
|
191
|
+
legacyStorageKeys?: readonly string[];
|
|
188
192
|
/** The exact locales this app ships a translation catalog for. */
|
|
189
193
|
supportedLocales: readonly string[];
|
|
190
194
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readEntryLink, readCampaignAttribution } from '../entryLink';
|
|
2
|
+
|
|
3
|
+
it.each([
|
|
4
|
+
['https://mention.earth/?lang=es', 'es-ES'],
|
|
5
|
+
['mention://compose?lang=fr-BE', 'fr-FR'],
|
|
6
|
+
['https://mention.earth/?lang=zz', undefined],
|
|
7
|
+
['https://mention.earth/?lang=en&lang=es', undefined],
|
|
8
|
+
['not a url', undefined],
|
|
9
|
+
])('resolves supported entry locale %s', (url, expected) => {
|
|
10
|
+
expect(readEntryLink(url).language).toBe(expected);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('captures only campaign fields without retaining the URL, a referrer or unrelated private query data', () => {
|
|
14
|
+
expect(
|
|
15
|
+
readEntryLink(
|
|
16
|
+
'https://mention.earth/write?utm_source=meta&utm_campaign=Spain&fbclid=click&email=private@example.test',
|
|
17
|
+
),
|
|
18
|
+
).toEqual({
|
|
19
|
+
language: undefined,
|
|
20
|
+
campaignAttribution: { utmSource: 'meta', utmCampaign: 'Spain', fbclid: 'click' },
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('drops empty, duplicate, overlong and control-character campaign values', () => {
|
|
25
|
+
const url = `mention://?utm_source=one&utm_source=two&utm_medium=&utm_campaign=${'x'.repeat(257)}&utm_content=bad%00value`;
|
|
26
|
+
expect(readEntryLink(url).campaignAttribution).toBeUndefined();
|
|
27
|
+
expect(
|
|
28
|
+
readCampaignAttribution({ fbclid: 'x'.repeat(1025), utmSource: ' meta ', private: 'secret' }),
|
|
29
|
+
).toEqual({ utmSource: 'meta' });
|
|
30
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { SUPPORTED_LANGUAGES, normalizeLocale } from '@oxy.so/core';
|
|
2
|
+
import {
|
|
3
|
+
CAMPAIGN_LABEL_MAX_LENGTH,
|
|
4
|
+
isLanguageTag,
|
|
5
|
+
hasCampaignControlCharacters,
|
|
6
|
+
CAMPAIGN_CLICK_ID_MAX_LENGTH,
|
|
7
|
+
type CampaignAttribution,
|
|
8
|
+
} from '@oxy.so/contracts/client';
|
|
9
|
+
|
|
10
|
+
const CAMPAIGN_PARAMETERS = {
|
|
11
|
+
utmSource: 'utm_source',
|
|
12
|
+
utmMedium: 'utm_medium',
|
|
13
|
+
utmCampaign: 'utm_campaign',
|
|
14
|
+
utmContent: 'utm_content',
|
|
15
|
+
utmTerm: 'utm_term',
|
|
16
|
+
fbclid: 'fbclid',
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export function readCampaignAttribution(value: unknown): CampaignAttribution | undefined {
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
21
|
+
const result: CampaignAttribution = {};
|
|
22
|
+
for (const field of Object.keys(CAMPAIGN_PARAMETERS) as (keyof CampaignAttribution)[]) {
|
|
23
|
+
const raw = Reflect.get(value, field);
|
|
24
|
+
if (typeof raw !== 'string') continue;
|
|
25
|
+
const normalized = raw.trim();
|
|
26
|
+
const limit = field === 'fbclid' ? CAMPAIGN_CLICK_ID_MAX_LENGTH : CAMPAIGN_LABEL_MAX_LENGTH;
|
|
27
|
+
if (!normalized || normalized.length > limit || hasCampaignControlCharacters(normalized))
|
|
28
|
+
continue;
|
|
29
|
+
result[field] = normalized;
|
|
30
|
+
}
|
|
31
|
+
return Object.keys(result).length ? result : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Consumes only explicit locale/campaign parameters, never the URL or referrer itself. */
|
|
35
|
+
export function readEntryLink(url: string | null): {
|
|
36
|
+
language?: string;
|
|
37
|
+
campaignAttribution?: CampaignAttribution;
|
|
38
|
+
} {
|
|
39
|
+
if (!url) return {};
|
|
40
|
+
let parsed: URL;
|
|
41
|
+
try {
|
|
42
|
+
parsed = new URL(url);
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
const languages = parsed.searchParams.getAll('lang');
|
|
47
|
+
const requestedLanguage = languages.length === 1 ? languages[0] : undefined;
|
|
48
|
+
const language =
|
|
49
|
+
requestedLanguage && isLanguageTag(requestedLanguage)
|
|
50
|
+
? (normalizeLocale(requestedLanguage) ??
|
|
51
|
+
SUPPORTED_LANGUAGES.find(
|
|
52
|
+
(candidate) => candidate.language === requestedLanguage.split('-')[0]?.toLowerCase(),
|
|
53
|
+
)?.code)
|
|
54
|
+
: undefined;
|
|
55
|
+
const fields: Record<string, string> = {};
|
|
56
|
+
for (const [field, query] of Object.entries(CAMPAIGN_PARAMETERS)) {
|
|
57
|
+
const values = parsed.searchParams.getAll(query);
|
|
58
|
+
if (values.length === 1) fields[field] = values[0];
|
|
59
|
+
}
|
|
60
|
+
return { language, campaignAttribution: readCampaignAttribution(fields) };
|
|
61
|
+
}
|