@myelophone/nuxt 0.18.2

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 (259) hide show
  1. package/.devcontainer/Dockerfile +7 -0
  2. package/.devcontainer/devcontainer.json +21 -0
  3. package/.editorconfig +43 -0
  4. package/.github/workflows/package.yml +73 -0
  5. package/.nuxtignore +6 -0
  6. package/.releaserc.js +132 -0
  7. package/.yarn/releases/yarn-4.18.0.cjs +1000 -0
  8. package/.yarnrc.yml +8 -0
  9. package/CHANGELOG.md +141 -0
  10. package/LICENSE +77 -0
  11. package/README.md +3004 -0
  12. package/app/app.vue +455 -0
  13. package/app/assets/css/animation.css +81 -0
  14. package/app/assets/css/backgrounds.css +57 -0
  15. package/app/assets/css/base.css +182 -0
  16. package/app/assets/css/index.css +9 -0
  17. package/app/assets/css/links.css +12 -0
  18. package/app/assets/css/multimedia.css +102 -0
  19. package/app/assets/css/preloader.css +68 -0
  20. package/app/assets/css/print.css +40 -0
  21. package/app/assets/css/tailwind.css +20 -0
  22. package/app/assets/css/theme.css +29 -0
  23. package/app/components/consent/GoogleMap.vue +59 -0
  24. package/app/components/consent/Youtube.vue +27 -0
  25. package/app/components/cookie/Banner.vue +70 -0
  26. package/app/components/cookie/ConsentWrapper.vue +71 -0
  27. package/app/components/cookie/PrivacyPolicy.vue +179 -0
  28. package/app/components/cookie/SettingsModal.vue +387 -0
  29. package/app/components/grid/Col.vue +262 -0
  30. package/app/components/grid/Container.vue +236 -0
  31. package/app/components/grid/Row.vue +248 -0
  32. package/app/components/grid/Stack.vue +233 -0
  33. package/app/components/myelophone/Copyright.vue +5 -0
  34. package/app/components/myelophone/Welcome.vue +107 -0
  35. package/app/components/page/FullscreenPreloader.vue +177 -0
  36. package/app/components/page/Preloader.vue +18 -0
  37. package/app/components/page/ReadyLocalized.vue +19 -0
  38. package/app/components/ready/.gitkeep +0 -0
  39. package/app/components/safe/Email.vue +52 -0
  40. package/app/components/safe/ImgWithLoader.vue +180 -0
  41. package/app/components/safe/NuxtImg.vue +25 -0
  42. package/app/components/safe/NuxtPicture.vue +29 -0
  43. package/app/components/section/.gitkeep +0 -0
  44. package/app/components/seo/BrandInfo.vue +76 -0
  45. package/app/components/seo/ContentNoIndex.vue +35 -0
  46. package/app/components/seo/NoIndex.vue +22 -0
  47. package/app/components/seo/OrgInfo.vue +130 -0
  48. package/app/components/seo/ProfileInfo.vue +137 -0
  49. package/app/components/ui/Accordion.vue +163 -0
  50. package/app/components/ui/Alert.vue +179 -0
  51. package/app/components/ui/Avatar.vue +88 -0
  52. package/app/components/ui/Badge.vue +150 -0
  53. package/app/components/ui/Banner.vue +133 -0
  54. package/app/components/ui/Button.vue +175 -0
  55. package/app/components/ui/Card.vue +66 -0
  56. package/app/components/ui/Checkbox.vue +134 -0
  57. package/app/components/ui/Chip.vue +136 -0
  58. package/app/components/ui/CommandPalette.vue +434 -0
  59. package/app/components/ui/Console.vue +583 -0
  60. package/app/components/ui/CursorCreative.vue +148 -0
  61. package/app/components/ui/FeatureAccordion.vue +165 -0
  62. package/app/components/ui/FileUpload.vue +705 -0
  63. package/app/components/ui/GeoDependent.vue +72 -0
  64. package/app/components/ui/Heading.vue +51 -0
  65. package/app/components/ui/Icon.vue +42 -0
  66. package/app/components/ui/Icon8.vue +39 -0
  67. package/app/components/ui/Input.vue +88 -0
  68. package/app/components/ui/LangLink.vue +34 -0
  69. package/app/components/ui/LangVisible.vue +33 -0
  70. package/app/components/ui/LanguageSelect.vue +202 -0
  71. package/app/components/ui/LightBox.vue +155 -0
  72. package/app/components/ui/LocalTime.vue +170 -0
  73. package/app/components/ui/Modal.vue +161 -0
  74. package/app/components/ui/Popup.vue +200 -0
  75. package/app/components/ui/PostContent.server.vue +23 -0
  76. package/app/components/ui/ProtectedEmail.vue +52 -0
  77. package/app/components/ui/RelativeTime.vue +141 -0
  78. package/app/components/ui/ScheduledContent.vue +150 -0
  79. package/app/components/ui/ScrollToTop.client.vue +48 -0
  80. package/app/components/ui/SegmentedControl.vue +148 -0
  81. package/app/components/ui/SiteSearch.vue +1191 -0
  82. package/app/components/ui/Skeleton.vue +91 -0
  83. package/app/components/ui/SmartContrast.vue +74 -0
  84. package/app/components/ui/SnapContainer.vue +222 -0
  85. package/app/components/ui/SnapSection.vue +294 -0
  86. package/app/components/ui/SonnerStyle.vue +7 -0
  87. package/app/components/ui/SplitSection.vue +99 -0
  88. package/app/components/ui/Steps.vue +858 -0
  89. package/app/components/ui/StickyWrapper.vue +46 -0
  90. package/app/components/ui/Table.vue +251 -0
  91. package/app/components/ui/Tabs.vue +451 -0
  92. package/app/components/ui/TextColumn.vue +36 -0
  93. package/app/components/ui/Textarea.vue +251 -0
  94. package/app/components/ui/Toggler.vue +40 -0
  95. package/app/components/ui/Tooltip.vue +524 -0
  96. package/app/components/ui/TruncateText.vue +105 -0
  97. package/app/components/ui/ViewportSpacer.vue +72 -0
  98. package/app/components/view/AnimatedCounter.vue +121 -0
  99. package/app/components/view/AnnouncementBar.vue +104 -0
  100. package/app/components/view/AvatarGroup.vue +104 -0
  101. package/app/components/view/Breadcrumbs.vue +97 -0
  102. package/app/components/view/ContentWithToc.vue +152 -0
  103. package/app/components/view/Copyright.vue +68 -0
  104. package/app/components/view/CountdownTimer.vue +184 -0
  105. package/app/components/view/CreativeCTA.vue +143 -0
  106. package/app/components/view/CurrencySelect.vue +254 -0
  107. package/app/components/view/FeatureBoxGrid.vue +142 -0
  108. package/app/components/view/HorizontalMenu.vue +97 -0
  109. package/app/components/view/ImageCompare.vue +141 -0
  110. package/app/components/view/ImgSlider.vue +333 -0
  111. package/app/components/view/InfiniteMarquee.vue +147 -0
  112. package/app/components/view/InfoBar.vue +86 -0
  113. package/app/components/view/LogoGrid.vue +106 -0
  114. package/app/components/view/LogoSlider.vue +83 -0
  115. package/app/components/view/NotFound.vue +81 -0
  116. package/app/components/view/PartialBackground.vue +313 -0
  117. package/app/components/view/QuoteBig.vue +111 -0
  118. package/app/components/view/ResponsiveMenu.vue +219 -0
  119. package/app/components/view/ReviewsSlider.vue +230 -0
  120. package/app/components/view/SocialBar.vue +78 -0
  121. package/app/components/view/StickyHeadingContent.vue +158 -0
  122. package/app/components/view/TallyForm.vue +295 -0
  123. package/app/components/view/WarningOutdated.vue +113 -0
  124. package/app/composables/useApi.ts +88 -0
  125. package/app/composables/useAppSeo.ts +78 -0
  126. package/app/composables/useBreadcrumbs.ts +106 -0
  127. package/app/composables/useChangeUrl.ts +73 -0
  128. package/app/composables/useCommand.ts +28 -0
  129. package/app/composables/useCookieControl.ts +111 -0
  130. package/app/composables/useCookieModal.ts +9 -0
  131. package/app/composables/useDevice.ts +109 -0
  132. package/app/composables/useGeoFetch.ts +139 -0
  133. package/app/composables/useLoadScript.ts +36 -0
  134. package/app/composables/useModalState.ts +37 -0
  135. package/app/composables/useMultiLang.ts +278 -0
  136. package/app/composables/useNetwork.ts +49 -0
  137. package/app/composables/useNuxtVersion.ts +5 -0
  138. package/app/composables/usePiniaStore.ts +5 -0
  139. package/app/composables/usePopupControl.ts +57 -0
  140. package/app/composables/useQuery.ts +307 -0
  141. package/app/composables/useReadyPage.ts +182 -0
  142. package/app/composables/useSafeList.ts +90 -0
  143. package/app/composables/useScriptLoader.ts +221 -0
  144. package/app/composables/useSiteSearch.ts +32 -0
  145. package/app/composables/useSnapScroll.ts +24 -0
  146. package/app/composables/useStatic.ts +5 -0
  147. package/app/composables/useStorage.ts +252 -0
  148. package/app/composables/useStoreBroadcast.ts +146 -0
  149. package/app/composables/useThemeSync.ts +81 -0
  150. package/app/constants/layout.ts +109 -0
  151. package/app/constants/predefinedCookieScripts.ts +1222 -0
  152. package/app/constants/ui-events.ts +1 -0
  153. package/app/error.vue +169 -0
  154. package/app/layouts/default-blog.vue +5 -0
  155. package/app/layouts/default.vue +3 -0
  156. package/app/locales/en/commands.json +5 -0
  157. package/app/locales/en/cookies.json +182 -0
  158. package/app/locales/en/error.json +4 -0
  159. package/app/locales/en/interface.json +33 -0
  160. package/app/locales/en/myelophone.json +8 -0
  161. package/app/locales/en/outdated.json +5 -0
  162. package/app/locales/en/times.json +18 -0
  163. package/app/locales/pl/commands.json +5 -0
  164. package/app/locales/pl/cookies.json +182 -0
  165. package/app/locales/pl/error.json +4 -0
  166. package/app/locales/pl/interface.json +33 -0
  167. package/app/locales/pl/myelophone.json +8 -0
  168. package/app/locales/pl/outdated.json +5 -0
  169. package/app/locales/pl/times.json +18 -0
  170. package/app/locales/ru/commands.json +5 -0
  171. package/app/locales/ru/cookies.json +182 -0
  172. package/app/locales/ru/error.json +4 -0
  173. package/app/locales/ru/interface.json +33 -0
  174. package/app/locales/ru/myelophone.json +8 -0
  175. package/app/locales/ru/outdated.json +5 -0
  176. package/app/locales/ru/times.json +18 -0
  177. package/app/middleware/blog-layout.ts +28 -0
  178. package/app/middleware/i18n.global.ts +17 -0
  179. package/app/middleware/not-found-status.ts +11 -0
  180. package/app/modules/default-blog/index.ts +39 -0
  181. package/app/modules/default-blog/posts.ts +80 -0
  182. package/app/modules/default-sitemap/index.ts +116 -0
  183. package/app/modules/default-sitemap/runtime/sitemap.get.ts +42 -0
  184. package/app/modules/fullscreen-preloader/module.ts +43 -0
  185. package/app/modules/htaccess/.htaccess +38 -0
  186. package/app/modules/htaccess/index.ts +23 -0
  187. package/app/modules/i18n-shaker.mjs +221 -0
  188. package/app/modules/multi18n/module.ts +71 -0
  189. package/app/modules/myelophone-config/module.ts +241 -0
  190. package/app/pages/[...all].vue +9 -0
  191. package/app/pages/index.vue +3 -0
  192. package/app/pages/post/[...slug].vue +30 -0
  193. package/app/plugins/bfcache-refresh.client.ts +9 -0
  194. package/app/plugins/canonical.ts +30 -0
  195. package/app/plugins/csp-nonce.server.ts +41 -0
  196. package/app/plugins/multi18n.client.ts +21 -0
  197. package/app/plugins/multi18n.server.ts +15 -0
  198. package/app/plugins/nuxt-version.client.ts +4 -0
  199. package/app/plugins/restore.client.ts +21 -0
  200. package/app/plugins/reveal.ts +89 -0
  201. package/app/plugins/url-helper.client.ts +66 -0
  202. package/app/server/api/exchange-rates.get.ts +216 -0
  203. package/app/server/api/healthz.ts +1 -0
  204. package/app/server/api/logout.post.ts +6 -0
  205. package/app/server/middleware/normalize-url.ts +14 -0
  206. package/app/server/middleware/security-headers.ts +147 -0
  207. package/app/server/middleware/url-helper.ts +43 -0
  208. package/app/server/plugins/clean-html-spaces.ts +15 -0
  209. package/app/server/plugins/clean-styles.ts +94 -0
  210. package/app/server/plugins/compression.ts +122 -0
  211. package/app/server/plugins/css-preload.ts +26 -0
  212. package/app/server/plugins/early-hints.ts +31 -0
  213. package/app/server/plugins/force-noindex.ts +43 -0
  214. package/app/server/plugins/html-one-line.ts +17 -0
  215. package/app/server/plugins/inline-style.ts +7 -0
  216. package/app/server/routes/healthz.get.ts +1 -0
  217. package/app/server/tsconfig.json +3 -0
  218. package/app/stores/cart.ts +945 -0
  219. package/app/stores/commands.ts +43 -0
  220. package/app/stores/device.ts +156 -0
  221. package/app/stores/querySync.ts +5 -0
  222. package/app/stores/settings.ts +145 -0
  223. package/app/stores/user.ts +607 -0
  224. package/app/types/cookie.ts +89 -0
  225. package/app/types/myelophone.d.ts +87 -0
  226. package/app/types/site-search.ts +110 -0
  227. package/app/utils/asyncRefresh.ts +4 -0
  228. package/app/utils/blog.server.ts +142 -0
  229. package/app/utils/jsonLd.ts +56 -0
  230. package/app/utils/myelophoneConfig.ts +4 -0
  231. package/app/utils/sortBy.ts +18 -0
  232. package/app/utils/transliterate.ts +75 -0
  233. package/app/utils/ytVideo.ts +37 -0
  234. package/app/workers/site-search.worker.ts +661 -0
  235. package/biome.json +94 -0
  236. package/commitlint.config.js +3 -0
  237. package/nuxt.config.ts +539 -0
  238. package/package.json +101 -0
  239. package/playground/content/.gitkeep +0 -0
  240. package/playground/locales/.gitkeep +0 -0
  241. package/playground/myelophone.dev.ts +45 -0
  242. package/playground/nuxt.config.ts +105 -0
  243. package/playground/package.json +5 -0
  244. package/playground/pages/.gitkeep +0 -0
  245. package/playground/tsconfig.json +3 -0
  246. package/playwright.config.ts +23 -0
  247. package/public/browsers/chrome.svg +77 -0
  248. package/public/browsers/edge.svg +98 -0
  249. package/public/browsers/firefox.svg +300 -0
  250. package/public/favicon.ico +0 -0
  251. package/public/myelophone_eng.png +0 -0
  252. package/public/myelophone_eng_white.png +0 -0
  253. package/spa-loading-template.html +72 -0
  254. package/tests/home.spec.ts +9 -0
  255. package/tests/utils.ts +23 -0
  256. package/tsconfig.json +16 -0
  257. package/vite/plugins/clean-css.ts +190 -0
  258. package/vite/plugins/postcss-add-viewportunits.ts +33 -0
  259. package/vite/plugins/postcss-viewport-fallback.ts +49 -0
@@ -0,0 +1,78 @@
1
+ export const useAppSeo = (config: {
2
+ title?: any;
3
+ titleKey?: string;
4
+ params?: any;
5
+ description?: any;
6
+ descriptionKey?: string;
7
+ image?: string;
8
+ noIndex?: boolean;
9
+ }) => {
10
+ const { t } = useMultiLang(['seo', 'common']);
11
+ const route = useRoute();
12
+
13
+ const unpack = (data: any) => {
14
+ const raw = unref(data);
15
+ if (!raw) return {};
16
+ const result: Record<string, any> = {};
17
+ for (const key in raw) {
18
+ result[key] = unref(raw[key]);
19
+ }
20
+ return result;
21
+ };
22
+
23
+ const resolveValue = (value: any) => {
24
+ const unwrappedValue = unref(value);
25
+ if (typeof unwrappedValue === 'function') {
26
+ return unwrappedValue();
27
+ }
28
+ return unwrappedValue;
29
+ };
30
+
31
+ const seoTitle = computed(() => {
32
+ if (config.title) {
33
+ return resolveValue(config.title);
34
+ }
35
+ if (config.titleKey) {
36
+ const p = unpack(config.params);
37
+ const translated = t(config.titleKey, p);
38
+ return translated !== config.titleKey
39
+ ? translated
40
+ : config.titleKey;
41
+ }
42
+ return (route.meta.title as string) || '';
43
+ });
44
+
45
+ const seoDescription = computed(() => {
46
+ if (config.description) {
47
+ return resolveValue(config.description);
48
+ }
49
+ if (config.descriptionKey) {
50
+ const p = unpack(config.params);
51
+ const translated = t(config.descriptionKey, p);
52
+ return translated !== config.descriptionKey
53
+ ? translated
54
+ : config.descriptionKey;
55
+ }
56
+ return '';
57
+ });
58
+
59
+ // TODO: add ogImage
60
+
61
+ useSeoMeta({
62
+ title: () => seoTitle.value,
63
+ ogTitle: () => seoTitle.value,
64
+ description: () => seoDescription.value,
65
+ ogDescription: () => seoDescription.value,
66
+ robots: config.noIndex ? 'noindex, nofollow' : 'index, follow',
67
+ });
68
+
69
+ watch(
70
+ seoTitle,
71
+ (val) => {
72
+ if (val) route.meta.breadcrumbTitle = val;
73
+ },
74
+ { immediate: true },
75
+ );
76
+
77
+ return { title: seoTitle, description: seoDescription };
78
+ };
@@ -0,0 +1,106 @@
1
+ interface BreadcrumbItem {
2
+ title: string;
3
+ path: string;
4
+ }
5
+
6
+ export const useBreadcrumbs = () => {
7
+ const route = useRoute();
8
+ const config = useMyelophoneConfig();
9
+ const { t } = useMultiLang(['common', 'menu']);
10
+
11
+ const breadcrumbs = useState<BreadcrumbItem[]>(
12
+ 'shared-breadcrumbs',
13
+ () => [],
14
+ );
15
+
16
+ const supportedLocales = (config.multi18n?.locales ?? []).map((l: string) =>
17
+ l.toLowerCase(),
18
+ );
19
+
20
+ const detectedLocale = computed(() => {
21
+ const firstSegment = route.path.split('/').filter(Boolean)[0];
22
+ return firstSegment &&
23
+ supportedLocales.includes(firstSegment.toLowerCase())
24
+ ? firstSegment
25
+ : null;
26
+ });
27
+
28
+ const homePath = computed(() =>
29
+ detectedLocale.value ? `/${detectedLocale.value}` : '/',
30
+ );
31
+
32
+ const homeLabel = computed(() => {
33
+ const key = 'common.homepage';
34
+ const res = t(key);
35
+ return res === key ? 'Home' : res;
36
+ });
37
+
38
+ const truncate = (text: string, max: number) => {
39
+ if (!text || text.length <= max) return text;
40
+ const subString = text.substring(0, max);
41
+ const lastSpace = subString.lastIndexOf(' ');
42
+ return (
43
+ (lastSpace > 0 ? subString.substring(0, lastSpace) : subString) +
44
+ '...'
45
+ );
46
+ };
47
+
48
+ const updateBreadcrumbs = () => {
49
+ const pathSegments = route.path.split('/').filter((p) => p !== '');
50
+ const segments = detectedLocale.value
51
+ ? pathSegments.slice(1)
52
+ : pathSegments;
53
+
54
+ const list = segments.map((path, index) => {
55
+ const basePath = detectedLocale.value
56
+ ? `/${detectedLocale.value}`
57
+ : '';
58
+ const fullPath =
59
+ basePath + '/' + segments.slice(0, index + 1).join('/');
60
+
61
+ const routeMatch = route.matched.find(
62
+ (m) =>
63
+ m.path.replace(/\/$/, '') === fullPath.replace(/\/$/, ''),
64
+ );
65
+
66
+ const isLast = index === segments.length - 1;
67
+ let title = '';
68
+
69
+ if (isLast && route.meta.breadcrumbTitle) {
70
+ title = route.meta.breadcrumbTitle as string;
71
+ } else {
72
+ title = (routeMatch?.meta?.breadcrumbTitle ||
73
+ routeMatch?.meta?.title ||
74
+ '') as string;
75
+ }
76
+
77
+ if (!title) {
78
+ const translationKey = `menu.${path.replace(/-/g, '_')}`;
79
+ const translated = t(translationKey);
80
+ title = translated === translationKey ? path : translated;
81
+ }
82
+
83
+ return {
84
+ title: String(title),
85
+ path: fullPath,
86
+ };
87
+ });
88
+
89
+ breadcrumbs.value = list;
90
+ };
91
+
92
+ watch(
93
+ [() => route.path, () => route.meta.breadcrumbTitle],
94
+ () => {
95
+ updateBreadcrumbs();
96
+ },
97
+ { immediate: true, deep: true },
98
+ );
99
+
100
+ return {
101
+ breadcrumbs,
102
+ homePath,
103
+ homeLabel,
104
+ truncate,
105
+ };
106
+ };
@@ -0,0 +1,73 @@
1
+ import type { LocationQuery, RouteLocationAsPathGeneric } from 'vue-router';
2
+
3
+ export interface ChangeUrlOptions {
4
+ replace?: boolean;
5
+ mergeQuery?: boolean;
6
+ }
7
+
8
+ export const useChangeUrl = () => {
9
+ const router = useRouter();
10
+ const route = useRoute();
11
+ const requestUrl = useRequestURL();
12
+
13
+ const changeUrl = async (
14
+ target:
15
+ string | { path?: string; query?: LocationQuery; hash?: string },
16
+ options: ChangeUrlOptions = {},
17
+ ) => {
18
+ const { replace = true, mergeQuery = true } = options;
19
+
20
+ let nextPath = route.path;
21
+ let nextQuery: LocationQuery = mergeQuery ? { ...route.query } : {};
22
+ let nextHash = route.hash;
23
+
24
+ if (typeof target === 'string') {
25
+ const url = new URL(target, requestUrl.origin);
26
+ nextPath = url.pathname;
27
+ nextHash = url.hash;
28
+
29
+ const searchParams = Object.fromEntries(url.searchParams.entries());
30
+ nextQuery = mergeQuery
31
+ ? { ...route.query, ...searchParams }
32
+ : searchParams;
33
+ } else {
34
+ if (target.path !== undefined) nextPath = target.path;
35
+ if (target.hash !== undefined) nextHash = target.hash;
36
+ if (target.query !== undefined) {
37
+ nextQuery = mergeQuery
38
+ ? { ...route.query, ...target.query }
39
+ : target.query;
40
+ }
41
+ }
42
+
43
+ const to: RouteLocationAsPathGeneric = {
44
+ path: nextPath,
45
+ query: nextQuery,
46
+ hash: nextHash,
47
+ };
48
+
49
+ if (import.meta.client) {
50
+ return replace ? await router.replace(to) : await router.push(to);
51
+ }
52
+ };
53
+
54
+ const updateQuery = async (
55
+ newQuery: Record<string, string | number | boolean | null | undefined>,
56
+ options: ChangeUrlOptions = {},
57
+ ) => {
58
+ const cleanedQuery: LocationQuery = {};
59
+
60
+ Object.entries(newQuery).forEach(([key, val]) => {
61
+ if (val !== null && val !== undefined) {
62
+ cleanedQuery[key] = String(val);
63
+ }
64
+ });
65
+
66
+ return await changeUrl({ query: cleanedQuery }, options);
67
+ };
68
+
69
+ return {
70
+ changeUrl,
71
+ updateQuery,
72
+ };
73
+ };
@@ -0,0 +1,28 @@
1
+ export const useCommand = (commandData: CommandInput) => {
2
+ const { $pinia } = useNuxtApp();
3
+ const store = useCommandStore($pinia);
4
+
5
+ let registeredId: string | null = null;
6
+
7
+ const register = () => {
8
+ if (!registeredId) {
9
+ registeredId = store.register(commandData);
10
+ }
11
+ };
12
+
13
+ const unregister = () => {
14
+ if (registeredId) {
15
+ store.unregister(registeredId);
16
+ registeredId = null;
17
+ }
18
+ };
19
+
20
+ if (getCurrentInstance()) {
21
+ onMounted(register);
22
+ onUnmounted(unregister);
23
+ } else {
24
+ register();
25
+ }
26
+
27
+ return { register, unregister };
28
+ };
@@ -0,0 +1,111 @@
1
+ import {
2
+ type CookiePreferences,
3
+ type CookieSelection,
4
+ COOKIE_CATEGORIES,
5
+ } from '~/types/cookie';
6
+
7
+ export const useCookieControl = () => {
8
+ const store = useSettingsStore();
9
+ const { cookiePreferences, isCookieBannerVisible } = storeToRefs(store);
10
+
11
+ const config = useMyelophoneConfig();
12
+ const isEnabled = config.cookieControl?.enabled ?? true;
13
+
14
+ const nuxtCookie = useCookie<CookiePreferences | undefined>(
15
+ 'privacy-preferences',
16
+ {
17
+ maxAge: 60 * 60 * 24 * 365,
18
+ },
19
+ );
20
+
21
+ const initCookieConsent = () => {
22
+ if (!isEnabled) {
23
+ store.isCookieBannerVisible = false;
24
+ return;
25
+ }
26
+
27
+ if (nuxtCookie.value && nuxtCookie.value.status) {
28
+ store.setCookieConsent(nuxtCookie.value);
29
+ }
30
+
31
+ const { status, timestamp } = store.cookiePreferences;
32
+
33
+ if (status === 'pending') {
34
+ store.isCookieBannerVisible = true;
35
+ } else if (status === 'declined') {
36
+ const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
37
+ if (Date.now() - timestamp > thirtyDaysMs) {
38
+ store.isCookieBannerVisible = true;
39
+ } else {
40
+ store.isCookieBannerVisible = false;
41
+ }
42
+ } else {
43
+ store.isCookieBannerVisible = false;
44
+ }
45
+ };
46
+
47
+ if (import.meta.client) {
48
+ watch(
49
+ () => store.cookiePreferences,
50
+ (newVal) => {
51
+ nuxtCookie.value = newVal;
52
+ },
53
+ { deep: true },
54
+ );
55
+ }
56
+
57
+ const acceptAll = () => {
58
+ const allAccepted = COOKIE_CATEGORIES.reduce((acc, cat) => {
59
+ acc[cat] = true;
60
+ return acc;
61
+ }, {} as CookieSelection);
62
+
63
+ store.setCookieConsent({
64
+ ...allAccepted,
65
+ timestamp: Date.now(),
66
+ status: 'accepted',
67
+ });
68
+ store.isCookieBannerVisible = false;
69
+ };
70
+
71
+ const declineAll = () => {
72
+ const allDeclined = COOKIE_CATEGORIES.reduce((acc, cat) => {
73
+ acc[cat] = cat === 'necessary';
74
+ return acc;
75
+ }, {} as CookieSelection);
76
+
77
+ store.setCookieConsent({
78
+ ...allDeclined,
79
+ timestamp: Date.now(),
80
+ status: 'declined',
81
+ });
82
+ store.isCookieBannerVisible = false;
83
+ };
84
+
85
+ const savePreferences = (prefs: Partial<CookieSelection>) => {
86
+ store.setCookieConsent({
87
+ ...store.cookiePreferences,
88
+ ...prefs,
89
+ necessary: true,
90
+ timestamp: Date.now(),
91
+ status: 'accepted',
92
+ });
93
+ store.isCookieBannerVisible = false;
94
+ };
95
+
96
+ const checkConsent = (category: keyof CookiePreferences) => {
97
+ if (!store.cookiePreferences) return false;
98
+ return store.cookiePreferences[category] === true;
99
+ };
100
+
101
+ return {
102
+ cookiePreferences,
103
+ isBannerVisible: isCookieBannerVisible,
104
+
105
+ initCookieConsent,
106
+ acceptAll,
107
+ declineAll,
108
+ savePreferences,
109
+ checkConsent,
110
+ };
111
+ };
@@ -0,0 +1,9 @@
1
+ export const useCookieModal = () => {
2
+ const isOpen = useState('cookie-settings-modal-open', () => false);
3
+
4
+ const open = () => (isOpen.value = true);
5
+ const close = () => (isOpen.value = false);
6
+ const toggle = () => (isOpen.value = !isOpen.value);
7
+
8
+ return { isOpen, open, close, toggle };
9
+ };
@@ -0,0 +1,109 @@
1
+ const batteryLevel = ref(1);
2
+ const isLowBattery = ref(false);
3
+ let isBatteryListenerActive = false;
4
+
5
+ const initBatteryListener = async () => {
6
+ if (!import.meta.client || isBatteryListenerActive) return;
7
+
8
+ if (
9
+ 'getBattery' in navigator &&
10
+ typeof (navigator as any).getBattery === 'function'
11
+ ) {
12
+ try {
13
+ const batt: any = await (navigator as any).getBattery();
14
+ isBatteryListenerActive = true;
15
+
16
+ const updateStatus = () => {
17
+ batteryLevel.value = batt.level;
18
+ isLowBattery.value = batt.level <= 0.2 && !batt.charging;
19
+ };
20
+
21
+ updateStatus();
22
+
23
+ if (typeof batt.addEventListener === 'function') {
24
+ batt.addEventListener('levelchange', updateStatus);
25
+ batt.addEventListener('chargingchange', updateStatus);
26
+ } else {
27
+ batt.onlevelchange = updateStatus;
28
+ batt.onchargingchange = updateStatus;
29
+ }
30
+ } catch (e) {
31
+ console.warn('Battery API error:', e);
32
+ }
33
+ }
34
+ };
35
+
36
+ export const useDevice = () => {
37
+ initBatteryListener();
38
+
39
+ const headers = useRequestHeaders(['user-agent']);
40
+
41
+ const getUA = () => {
42
+ if (import.meta.server) return headers['user-agent'] || '';
43
+ return navigator.userAgent || '';
44
+ };
45
+
46
+ const isOutdated = computed(() => {
47
+ if (import.meta.server) return false;
48
+ const ua = getUA();
49
+ if (!ua) return false;
50
+
51
+ const chromeMatch = ua.match(/(?:Chrome|CriOS)\/(\d+)\./);
52
+ if (chromeMatch?.[1] && parseInt(chromeMatch[1], 10) < 85) return true;
53
+
54
+ const safariMatch = ua.match(/Version\/(\d+)\..*Safari/);
55
+ if (safariMatch?.[1] && parseInt(safariMatch[1], 10) < 14) return true;
56
+
57
+ const firefoxMatch = ua.match(/Firefox\/(\d+)\./);
58
+ if (firefoxMatch?.[1] && parseInt(firefoxMatch[1], 10) < 80)
59
+ return true;
60
+
61
+ if (/MSIE|Trident/i.test(ua)) return true;
62
+
63
+ if (import.meta.client) {
64
+ const supportsNuxt4 =
65
+ 'noModule' in HTMLScriptElement.prototype &&
66
+ typeof Promise?.allSettled === 'function' &&
67
+ typeof BigInt !== 'undefined' &&
68
+ 'replaceAll' in String.prototype;
69
+
70
+ if (!supportsNuxt4) return true;
71
+ }
72
+ return false;
73
+ });
74
+
75
+ const isBot = computed(() => {
76
+ return /bot|googlebot|crawler|spider|robot|crawling|lighthouse|inspect/i.test(
77
+ getUA(),
78
+ );
79
+ });
80
+
81
+ const isMobile = computed(() => {
82
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
83
+ getUA(),
84
+ );
85
+ });
86
+
87
+ const prefersReducedMotion = computed(() => {
88
+ if (import.meta.server) return false;
89
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
90
+ });
91
+
92
+ const isStaticMode = computed(
93
+ () =>
94
+ isBot.value ||
95
+ prefersReducedMotion.value ||
96
+ isLowBattery.value ||
97
+ isOutdated.value,
98
+ );
99
+
100
+ return {
101
+ isBot,
102
+ isOutdated,
103
+ isMobile,
104
+ isStaticMode,
105
+ isLowBattery,
106
+ batteryLevel,
107
+ prefersReducedMotion,
108
+ };
109
+ };
@@ -0,0 +1,139 @@
1
+ interface GeoState {
2
+ country: string;
3
+ city: string;
4
+ resolved: boolean;
5
+ }
6
+
7
+ interface GeoCacheData {
8
+ country: string;
9
+ city: string;
10
+ timestamp: number;
11
+ }
12
+
13
+ interface WpFormsResponse {
14
+ country_iso?: string;
15
+ country_code?: string;
16
+ city?: string;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ interface CountryIsResponse {
21
+ country?: string;
22
+ [key: string]: unknown;
23
+ }
24
+
25
+ const CACHE_KEY = 'user-geo-data';
26
+ const CACHE_TTL = 30 * 60 * 1000;
27
+ const TIMEOUT_MS = 1000;
28
+
29
+ export const useGeoFetch = () => {
30
+ const geo = useState<GeoState>('geo-data', () => ({
31
+ country: '',
32
+ city: '',
33
+ resolved: false,
34
+ }));
35
+
36
+ const loadFromCache = (): GeoCacheData | null => {
37
+ try {
38
+ const item = sessionStorage.getItem(CACHE_KEY);
39
+ if (!item) return null;
40
+
41
+ const parsed = JSON.parse(item) as GeoCacheData;
42
+ if (Date.now() - parsed.timestamp < CACHE_TTL) {
43
+ return parsed;
44
+ }
45
+ return null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ };
50
+
51
+ const saveToCache = (country: string, city: string) => {
52
+ try {
53
+ const data: GeoCacheData = {
54
+ country,
55
+ city,
56
+ timestamp: Date.now(),
57
+ };
58
+ sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
59
+ } catch {}
60
+ };
61
+
62
+ const fetchWithTimeout = async <T>(url: string): Promise<T> => {
63
+ const controller = new AbortController();
64
+ const id = setTimeout(() => controller.abort(), TIMEOUT_MS);
65
+ try {
66
+ const res = await fetch(url, { signal: controller.signal });
67
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
68
+ return (await res.json()) as T;
69
+ } finally {
70
+ clearTimeout(id);
71
+ }
72
+ };
73
+
74
+ const resolve = async () => {
75
+ if (import.meta.server || geo.value.resolved) return;
76
+
77
+ const cached = loadFromCache();
78
+ if (cached) {
79
+ geo.value = {
80
+ country: cached.country,
81
+ city: cached.city,
82
+ resolved: true,
83
+ };
84
+ return;
85
+ }
86
+
87
+ onNuxtReady(async () => {
88
+ let countryCode = '';
89
+ let cityName = '';
90
+ let isSuccess = false;
91
+
92
+ try {
93
+ const data = await fetchWithTimeout<WpFormsResponse>(
94
+ 'https://geo.wpforms.com/v3/geolocate/json',
95
+ );
96
+
97
+ const code = data.country_iso || data.country_code;
98
+
99
+ if (code) {
100
+ countryCode = code.toUpperCase();
101
+ cityName = data.city || '';
102
+ isSuccess = true;
103
+ }
104
+ } catch {
105
+ try {
106
+ const fallbackData =
107
+ await fetchWithTimeout<CountryIsResponse>(
108
+ 'https://api.country.is/',
109
+ );
110
+
111
+ if (fallbackData.country) {
112
+ countryCode = fallbackData.country.toUpperCase();
113
+ cityName = '';
114
+ isSuccess = true;
115
+ }
116
+ } catch {
117
+ isSuccess = false;
118
+ }
119
+ }
120
+
121
+ if (isSuccess && countryCode) {
122
+ saveToCache(countryCode, cityName);
123
+
124
+ geo.value = {
125
+ country: countryCode,
126
+ city: cityName,
127
+ resolved: true,
128
+ };
129
+ } else {
130
+ geo.value = {
131
+ ...geo.value,
132
+ resolved: true,
133
+ };
134
+ }
135
+ });
136
+ };
137
+
138
+ return { geo, resolve };
139
+ };
@@ -0,0 +1,36 @@
1
+ export function useLoadScript() {
2
+ const loadedScripts = new Set<string>();
3
+
4
+ const loadScript = (
5
+ src: string,
6
+ options: { async?: boolean; defer?: boolean; cache?: boolean } = {},
7
+ ) => {
8
+ return new Promise<void>((resolve, reject) => {
9
+ if (import.meta.server || loadedScripts.has(src)) {
10
+ resolve();
11
+ return;
12
+ }
13
+
14
+ const s = document.createElement('script');
15
+ s.src =
16
+ options.cache === false
17
+ ? `${src}?${(Date.now() / 60000) | 0}`
18
+ : src;
19
+ s.async = options.async ?? true;
20
+ s.defer = options.defer ?? true;
21
+
22
+ s.onload = () => {
23
+ loadedScripts.add(src);
24
+ resolve();
25
+ };
26
+
27
+ s.onerror = (err) => {
28
+ reject(err);
29
+ };
30
+
31
+ document.body.appendChild(s);
32
+ });
33
+ };
34
+
35
+ return { loadScript };
36
+ }