@lovalingo/lovalingo 0.3.2 → 0.3.4

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.
@@ -9,6 +9,7 @@ import { processPath } from '../utils/pathNormalizer';
9
9
  import { LanguageSwitcher } from './LanguageSwitcher';
10
10
  const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
11
11
  const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
12
+ const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
12
13
  const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
13
14
  export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://leuskvkajliuzalrlwhw.supabase.co', routing = 'query', // Default to query mode (backward compatible)
14
15
  autoPrefixLinks = true, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
@@ -73,6 +74,10 @@ navigateRef, // For path mode routing
73
74
  apiRef.current = new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig);
74
75
  }, [apiBase, enhancedPathConfig, resolvedApiKey]);
75
76
  const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
77
+ const lastPageviewRef = useRef("");
78
+ const historyPatchedRef = useRef(false);
79
+ const originalHistoryRef = useRef(null);
80
+ const onNavigateRef = useRef(() => undefined);
76
81
  const retryTimeoutRef = useRef(null);
77
82
  const isNavigatingRef = useRef(false);
78
83
  const isInternalNavigationRef = useRef(false);
@@ -80,6 +85,21 @@ navigateRef, // For path mode routing
80
85
  const exclusionsCacheRef = useRef(null);
81
86
  const domRulesCacheRef = useRef(new Map());
82
87
  const loadingBgStorageKey = `${LOADING_BG_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
88
+ const brandingStorageKey = `${BRANDING_STORAGE_PREFIX}:${resolvedApiKey || "anonymous"}`;
89
+ const readBrandingCache = () => {
90
+ try {
91
+ const cached = (localStorage.getItem(brandingStorageKey) || "").trim();
92
+ if (cached === "0")
93
+ return false;
94
+ if (cached === "1")
95
+ return true;
96
+ }
97
+ catch {
98
+ // ignore
99
+ }
100
+ return true;
101
+ };
102
+ const [brandingEnabled, setBrandingEnabled] = useState(readBrandingCache);
83
103
  const prehideStateRef = useRef({
84
104
  active: false,
85
105
  timeoutId: null,
@@ -110,6 +130,30 @@ navigateRef, // For path mode routing
110
130
  // ignore
111
131
  }
112
132
  }, [loadingBgStorageKey]);
133
+ const setCachedBrandingEnabled = useCallback((enabled) => {
134
+ try {
135
+ localStorage.setItem(brandingStorageKey, enabled === false ? "0" : "1");
136
+ }
137
+ catch {
138
+ // ignore
139
+ }
140
+ }, [brandingStorageKey]);
141
+ useEffect(() => {
142
+ setBrandingEnabled(readBrandingCache());
143
+ // eslint-disable-next-line react-hooks/exhaustive-deps
144
+ }, [brandingStorageKey]);
145
+ useEffect(() => {
146
+ lastPageviewRef.current = "";
147
+ }, [resolvedApiKey]);
148
+ const trackPageviewOnce = useCallback((path) => {
149
+ const next = (path || "").toString();
150
+ if (!next)
151
+ return;
152
+ if (lastPageviewRef.current === next)
153
+ return;
154
+ lastPageviewRef.current = next;
155
+ apiRef.current.trackPageview(next);
156
+ }, []);
113
157
  const enablePrehide = useCallback((bgColor) => {
114
158
  if (typeof document === "undefined")
115
159
  return;
@@ -306,11 +350,19 @@ navigateRef, // For path mode routing
306
350
  setEntitlements(bootstrap.entitlements);
307
351
  if (bootstrap?.loading_bg_color)
308
352
  setCachedLoadingBgColor(bootstrap.loading_bg_color);
353
+ if (bootstrap?.entitlements?.brandingRequired) {
354
+ setBrandingEnabled(true);
355
+ setCachedBrandingEnabled(true);
356
+ }
357
+ else if (typeof bootstrap?.branding_enabled === "boolean") {
358
+ setBrandingEnabled(bootstrap.branding_enabled);
359
+ setCachedBrandingEnabled(bootstrap.branding_enabled);
360
+ }
309
361
  })();
310
362
  return () => {
311
363
  cancelled = true;
312
364
  };
313
- }, [defaultLocale, entitlements, locale, setCachedLoadingBgColor]);
365
+ }, [defaultLocale, entitlements, locale, setCachedBrandingEnabled, setCachedLoadingBgColor]);
314
366
  const applySeoBundle = useCallback((bundle, hreflangEnabled) => {
315
367
  try {
316
368
  const head = document.head;
@@ -535,6 +587,14 @@ navigateRef, // For path mode routing
535
587
  setCachedLoadingBgColor(bootstrap.loading_bg_color);
536
588
  enablePrehide(bootstrap.loading_bg_color);
537
589
  }
590
+ if ((bootstrap?.entitlements || nextEntitlements)?.brandingRequired) {
591
+ setBrandingEnabled(true);
592
+ setCachedBrandingEnabled(true);
593
+ }
594
+ else if (typeof bootstrap?.branding_enabled === "boolean") {
595
+ setBrandingEnabled(bootstrap.branding_enabled);
596
+ setCachedBrandingEnabled(bootstrap.branding_enabled);
597
+ }
538
598
  const exclusions = Array.isArray(bootstrap?.exclusions)
539
599
  ? bootstrap.exclusions
540
600
  .map((row) => {
@@ -630,18 +690,9 @@ navigateRef, // For path mode routing
630
690
  toTranslations,
631
691
  writeCriticalCache,
632
692
  ]);
633
- // SPA router hook-in: track History API navigations (React Router/Next/etc) without app changes.
634
693
  useEffect(() => {
635
- const historyObj = window.history;
636
- const originalPushState = historyObj.pushState.bind(historyObj);
637
- const originalReplaceState = historyObj.replaceState.bind(historyObj);
638
- const onNavigate = () => {
639
- try {
640
- apiRef.current.trackPageview(window.location.pathname + window.location.search);
641
- }
642
- catch {
643
- // ignore
644
- }
694
+ onNavigateRef.current = () => {
695
+ trackPageviewOnce(window.location.pathname + window.location.search);
645
696
  const nextLocale = detectLocale();
646
697
  if (nextLocale !== locale) {
647
698
  setLocaleState(nextLocale);
@@ -651,25 +702,50 @@ navigateRef, // For path mode routing
651
702
  applyActiveTranslations(document.body);
652
703
  }
653
704
  };
705
+ }, [defaultLocale, detectLocale, loadData, locale, mode, trackPageviewOnce]);
706
+ // SPA router hook-in: patch History API once (prevents stacked wrappers → request storms).
707
+ useEffect(() => {
708
+ if (typeof window === "undefined")
709
+ return;
710
+ if (historyPatchedRef.current)
711
+ return;
712
+ historyPatchedRef.current = true;
713
+ const historyObj = window.history;
714
+ const originalPushState = historyObj.pushState.bind(historyObj);
715
+ const originalReplaceState = historyObj.replaceState.bind(historyObj);
716
+ originalHistoryRef.current = { pushState: originalPushState, replaceState: originalReplaceState };
717
+ const safeOnNavigate = () => {
718
+ try {
719
+ onNavigateRef.current();
720
+ }
721
+ catch {
722
+ // ignore
723
+ }
724
+ };
654
725
  historyObj.pushState = ((...args) => {
655
726
  const ret = originalPushState(...args);
656
- onNavigate();
727
+ safeOnNavigate();
657
728
  return ret;
658
729
  });
659
730
  historyObj.replaceState = ((...args) => {
660
731
  const ret = originalReplaceState(...args);
661
- onNavigate();
732
+ safeOnNavigate();
662
733
  return ret;
663
734
  });
664
- window.addEventListener("popstate", onNavigate);
665
- window.addEventListener("hashchange", onNavigate);
735
+ window.addEventListener("popstate", safeOnNavigate);
736
+ window.addEventListener("hashchange", safeOnNavigate);
666
737
  return () => {
667
- historyObj.pushState = originalPushState;
668
- historyObj.replaceState = originalReplaceState;
669
- window.removeEventListener("popstate", onNavigate);
670
- window.removeEventListener("hashchange", onNavigate);
738
+ const originals = originalHistoryRef.current;
739
+ if (originals) {
740
+ historyObj.pushState = originals.pushState;
741
+ historyObj.replaceState = originals.replaceState;
742
+ }
743
+ window.removeEventListener("popstate", safeOnNavigate);
744
+ window.removeEventListener("hashchange", safeOnNavigate);
745
+ originalHistoryRef.current = null;
746
+ historyPatchedRef.current = false;
671
747
  };
672
- }, [defaultLocale, detectLocale, loadData, locale, mode]);
748
+ }, []);
673
749
  // Change locale
674
750
  const setLocale = useCallback((newLocale) => {
675
751
  void (async () => {
@@ -726,7 +802,7 @@ navigateRef, // For path mode routing
726
802
  useEffect(() => {
727
803
  const initialLocale = detectLocale();
728
804
  // Track initial page (fallback discovery for pages not present in the routes feed).
729
- apiRef.current.trackPageview(window.location.pathname + window.location.search);
805
+ trackPageviewOnce(window.location.pathname + window.location.search);
730
806
  // Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
731
807
  loadData(initialLocale);
732
808
  // Set up keyboard shortcut for edit mode
@@ -744,7 +820,7 @@ navigateRef, // For path mode routing
744
820
  clearTimeout(retryTimeoutRef.current);
745
821
  }
746
822
  };
747
- }, [detectLocale, loadData, editKey]);
823
+ }, [detectLocale, loadData, editKey, trackPageviewOnce]);
748
824
  // Auto-inject sitemap link tag
749
825
  useEffect(() => {
750
826
  if (sitemap && resolvedApiKey && isSeoActive()) {
@@ -1032,7 +1108,9 @@ navigateRef, // For path mode routing
1032
1108
  };
1033
1109
  return (React.createElement(LovalingoContext.Provider, { value: contextValue },
1034
1110
  children,
1035
- React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: entitlements?.brandingRequired
1036
- ? { required: true, href: "https://lovalingo.com" }
1037
- : undefined })));
1111
+ React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: {
1112
+ required: Boolean(entitlements?.brandingRequired),
1113
+ enabled: brandingEnabled,
1114
+ href: "https://lovalingo.com",
1115
+ } })));
1038
1116
  };
@@ -8,6 +8,7 @@ interface LanguageSwitcherProps {
8
8
  theme?: 'dark' | 'light';
9
9
  branding?: {
10
10
  required?: boolean;
11
+ enabled?: boolean;
11
12
  label?: string;
12
13
  href?: string;
13
14
  };
@@ -205,7 +205,7 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
205
205
  e.currentTarget.style.filter = 'brightness(1)';
206
206
  e.currentTarget.style.transform = 'scale(1)';
207
207
  }, "aria-label": `Switch to ${locale.toUpperCase()}`, title: locale.toUpperCase(), tabIndex: isOpen ? 0 : -1 }, LANGUAGE_FLAGS[locale] || '🏳️')))),
208
- branding?.required && (React.createElement("div", { style: badgeRowStyles, "aria-label": "Lovalingo branding" },
208
+ (branding?.required || branding?.enabled) && (React.createElement("div", { style: badgeRowStyles, "aria-label": "Lovalingo branding" },
209
209
  React.createElement("a", { href: branding.href || 'https://lovalingo.com', target: "_blank", rel: "noreferrer", style: badgeLinkStyles, tabIndex: isOpen ? 0 : -1, "aria-label": "Localized by Lovalingo", title: "Localized by Lovalingo" },
210
210
  React.createElement("span", { style: {
211
211
  width: '16px',
@@ -26,6 +26,7 @@ export type BootstrapResponse = {
26
26
  normalized_path?: string;
27
27
  routing_strategy?: string;
28
28
  loading_bg_color?: string | null;
29
+ branding_enabled?: boolean;
29
30
  seoEnabled?: boolean;
30
31
  entitlements?: ProjectEntitlements;
31
32
  alternates?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "React translation runtime with i18n routing, deterministic bundles + DOM rules, and zero-flash rendering.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",