@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,184 @@
1
+ <template>
2
+ <div
3
+ data-nosnippet
4
+ :class="[
5
+ !isMounted
6
+ ? 'opacity-0 select-none pointer-events-none'
7
+ : 'opacity-100',
8
+ ]"
9
+ class="transition-opacity duration-300"
10
+ >
11
+ <slot
12
+ :days="timeLeft.days"
13
+ :hours="timeLeft.hours"
14
+ :minutes="timeLeft.minutes"
15
+ :seconds="timeLeft.seconds"
16
+ :daysLabel="labels.days"
17
+ :hoursLabel="labels.hours"
18
+ :minutesLabel="labels.minutes"
19
+ :secondsLabel="labels.seconds"
20
+ >
21
+ <div class="countdown-default">
22
+ {{ timeLeft.days }} {{ labels.days }} {{ timeLeft.hours }}:{{
23
+ timeLeft.minutes
24
+ }}:{{ timeLeft.seconds }}
25
+ </div>
26
+ </slot>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ const props = defineProps<{
32
+ targetDate: string | Date;
33
+ timezone?: string;
34
+ }>();
35
+
36
+ const emit = defineEmits(["finish"]);
37
+
38
+ const { t } = useMultiLang(["times"]);
39
+ const pinia = useNuxtApp().$pinia;
40
+ const settingsStore = useSettingsStore(pinia);
41
+
42
+ const isMounted = ref(false);
43
+ const timeLeft = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 });
44
+ let timer: any = null;
45
+
46
+ const getLabel = (value: number, key: string) => {
47
+ const locale = settingsStore.currentLocale || "en";
48
+ try {
49
+ const pr = new Intl.PluralRules(locale);
50
+ const rule = pr.select(value);
51
+ let suffix = "5";
52
+
53
+ if (rule === "one") {
54
+ suffix = "1";
55
+ } else if (rule === "few") {
56
+ suffix = "";
57
+ } else if (rule === "many") {
58
+ suffix = "5";
59
+ } else if (locale === "en" && rule === "other") {
60
+ suffix = "2";
61
+ }
62
+ return t(`times.${key}${suffix}`);
63
+ } catch (e) {
64
+ console.error("Pluralization error", e);
65
+ return "";
66
+ }
67
+ };
68
+
69
+ const labels = computed(() => ({
70
+ days: getLabel(timeLeft.value.days, "day"),
71
+ hours: getLabel(timeLeft.value.hours, "hour"),
72
+ minutes: getLabel(timeLeft.value.minutes, "minute"),
73
+ seconds: getLabel(timeLeft.value.seconds, "second"),
74
+ }));
75
+
76
+ const getTargetTime = () => {
77
+ if (props.targetDate instanceof Date) return props.targetDate.getTime();
78
+
79
+ const parts = props.targetDate.match(
80
+ /^(\d{2})[./-](\d{2})[./-](\d{4})(?:\s+(\d{2}):(\d{2}))?/,
81
+ );
82
+ if (!parts) return new Date(props.targetDate).getTime();
83
+
84
+ const [_, d, m, y, h, min] = parts;
85
+
86
+ const date = new Date(
87
+ Date.UTC(
88
+ Number(y),
89
+ Number(m) - 1,
90
+ Number(d),
91
+ Number(h || 0),
92
+ Number(min || 0),
93
+ ),
94
+ );
95
+
96
+ if (props.timezone) {
97
+ try {
98
+ const formatter = new Intl.DateTimeFormat("en-US", {
99
+ timeZone: props.timezone,
100
+ year: "numeric",
101
+ month: "numeric",
102
+ day: "numeric",
103
+ hour: "numeric",
104
+ minute: "numeric",
105
+ second: "numeric",
106
+ hour12: false,
107
+ });
108
+
109
+ const p = formatter.formatToParts(date);
110
+ const getP = (type: string) =>
111
+ parseInt(p.find((x) => x.type === type)?.value || "0");
112
+
113
+ const tzDate = new Date(
114
+ Date.UTC(
115
+ getP("year"),
116
+ getP("month") - 1,
117
+ getP("day"),
118
+ getP("hour"),
119
+ getP("minute"),
120
+ getP("second"),
121
+ ),
122
+ );
123
+
124
+ return date.getTime() - (tzDate.getTime() - date.getTime());
125
+ } catch (e) {
126
+ console.warn("Invalid timezone, falling back to local");
127
+ }
128
+ }
129
+
130
+ return new Date(
131
+ Number(y),
132
+ Number(m) - 1,
133
+ Number(d),
134
+ Number(h || 0),
135
+ Number(min || 0),
136
+ ).getTime();
137
+ };
138
+
139
+ const updateCountdown = () => {
140
+ const target = getTargetTime();
141
+ const now = Date.now();
142
+ const distance = target - now;
143
+
144
+ if (distance <= 0) {
145
+ timeLeft.value = { days: 0, hours: 0, minutes: 0, seconds: 0 };
146
+ if (timer) clearInterval(timer);
147
+ emit("finish");
148
+ return;
149
+ }
150
+
151
+ timeLeft.value = {
152
+ days: Math.floor(distance / (1000 * 60 * 60 * 24)),
153
+ hours: Math.floor(
154
+ (distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
155
+ ),
156
+ minutes: Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)),
157
+ seconds: Math.floor((distance % (1000 * 60)) / 1000),
158
+ };
159
+ };
160
+
161
+ onMounted(() => {
162
+ updateCountdown();
163
+ timer = setInterval(updateCountdown, 1000);
164
+ setTimeout(() => {
165
+ isMounted.value = true;
166
+ }, 50);
167
+ });
168
+
169
+ onBeforeUnmount(() => {
170
+ if (timer) clearInterval(timer);
171
+ });
172
+
173
+ watch(
174
+ [() => props.targetDate, () => settingsStore.currentLocale],
175
+ updateCountdown,
176
+ );
177
+ </script>
178
+
179
+ <style scoped>
180
+ .countdown-default {
181
+ font-feature-settings: "tnum";
182
+ font-variant-numeric: tabular-nums;
183
+ }
184
+ </style>
@@ -0,0 +1,143 @@
1
+ <template>
2
+ <section
3
+ class="relative w-full overflow-hidden rounded-2xl isolate shadow-2xl group"
4
+ :class="[
5
+ backgroundImage
6
+ ? 'bg-gray-900'
7
+ : 'bg-(--ui-bg) ring-1 ring-(--ui-text)/10',
8
+ ]"
9
+ >
10
+ <div v-if="backgroundImage" class="absolute inset-0 z-0">
11
+ <img
12
+ :src="backgroundImage"
13
+ alt=""
14
+ class="w-full h-full object-cover transition-transform duration-700 will-change-transform group-hover:scale-105"
15
+ />
16
+ <div
17
+ class="absolute inset-0 transition-opacity duration-500"
18
+ :class="{
19
+ 'bg-linear-to-r from-black/90 via-black/50 to-transparent':
20
+ align === 'left',
21
+ 'bg-linear-to-l from-black/90 via-black/50 to-transparent':
22
+ align === 'right',
23
+ 'bg-linear-to-t from-black/90 via-black/50 to-black/20':
24
+ align === 'center',
25
+ }"
26
+ ></div>
27
+ </div>
28
+
29
+ <div v-else class="absolute inset-0 z-0 overflow-hidden">
30
+ <div
31
+ class="absolute inset-0 opacity-[0.05]"
32
+ style="
33
+ background-image: radial-gradient(
34
+ var(--ui-text) 1px,
35
+ transparent 1px
36
+ );
37
+ background-size: 24px 24px;
38
+ "
39
+ ></div>
40
+
41
+ <div
42
+ class="absolute -top-24 -right-24 w-96 h-96 bg-(--ui-text)/5 rounded-full blur-3xl"
43
+ ></div>
44
+ <div
45
+ class="absolute -bottom-24 -left-24 w-96 h-96 bg-(--ui-text)/5 rounded-full blur-3xl"
46
+ ></div>
47
+ </div>
48
+
49
+ <div
50
+ class="relative z-10 flex flex-col px-6 py-12 md:px-12 md:py-20"
51
+ :class="{
52
+ 'items-start text-left': align === 'left',
53
+ 'items-center text-center': align === 'center',
54
+ 'items-end text-right': align === 'right',
55
+ 'text-white': backgroundImage,
56
+ 'text-(--ui-text)': !backgroundImage,
57
+ }"
58
+ >
59
+ <div class="max-w-4xl space-y-4">
60
+ <slot name="title">
61
+ <h2
62
+ class="text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl leading-[1.2]"
63
+ :class="{ 'drop-shadow-md': backgroundImage }"
64
+ >
65
+ {{ title }}
66
+ </h2>
67
+ </slot>
68
+
69
+ <slot name="description">
70
+ <p
71
+ v-if="description"
72
+ class="text-base md:text-lg opacity-90 leading-relaxed max-w-2xl"
73
+ :class="{
74
+ 'mr-auto': align === 'left',
75
+ 'mx-auto': align === 'center',
76
+ 'ml-auto': align === 'right',
77
+ }"
78
+ >
79
+ {{ description }}
80
+ </p>
81
+ </slot>
82
+ </div>
83
+
84
+ <div
85
+ class="flex flex-col w-full gap-4 mt-8 sm:flex-row sm:w-auto"
86
+ :class="{
87
+ 'sm:self-start': align === 'left',
88
+ 'sm:self-center': align === 'center',
89
+ 'sm:self-end': align === 'right',
90
+ }"
91
+ >
92
+ <slot name="actions">
93
+ <NuxtLink
94
+ v-if="primaryText"
95
+ :to="primaryLink"
96
+ class="inline-flex items-center justify-center px-6 py-3.5 text-sm font-bold uppercase tracking-wider rounded-lg transition-all duration-200 shadow-lg"
97
+ :class="[
98
+ backgroundImage
99
+ ? 'bg-white text-black hover:bg-gray-100'
100
+ : 'bg-(--ui-text) text-(--ui-bg) hover:opacity-90',
101
+ ]"
102
+ >
103
+ {{ primaryText }}
104
+ </NuxtLink>
105
+
106
+ <NuxtLink
107
+ v-if="secondaryText"
108
+ :to="secondaryLink"
109
+ class="inline-flex items-center justify-center px-6 py-3.5 text-sm font-bold uppercase tracking-wider rounded-lg transition-all duration-200 border-2 hover:bg-white/10"
110
+ :class="[
111
+ backgroundImage
112
+ ? 'border-white/30 text-white'
113
+ : 'border-(--ui-text)/20 text-(--ui-text)',
114
+ ]"
115
+ >
116
+ {{ secondaryText }}
117
+ </NuxtLink>
118
+ </slot>
119
+ </div>
120
+ </div>
121
+ </section>
122
+ </template>
123
+
124
+ <script setup lang="ts">
125
+ interface Props {
126
+ title?: string;
127
+ description?: string;
128
+ primaryText?: string;
129
+ primaryLink?: string;
130
+ secondaryText?: string;
131
+ secondaryLink?: string;
132
+ backgroundImage?: string;
133
+ align?: "left" | "center" | "right";
134
+ }
135
+
136
+ const props = withDefaults(defineProps<Props>(), {
137
+ title: "Ready to start?",
138
+ description: "",
139
+ primaryLink: "/",
140
+ secondaryLink: "/",
141
+ align: "left",
142
+ });
143
+ </script>
@@ -0,0 +1,254 @@
1
+ <template>
2
+ <div
3
+ ref="selectWrapper"
4
+ class="ui-currency-select"
5
+ :class="[
6
+ containerClass,
7
+ {
8
+ 'is-open': isOpen,
9
+ 'drop-up': isDropUp,
10
+ 'drop-right': isDropRight,
11
+ },
12
+ ]"
13
+ @mousedown.stop
14
+ >
15
+ <button
16
+ type="button"
17
+ class="currency-trigger"
18
+ :class="triggerClass"
19
+ @click.stop="toggleDropdown"
20
+ >
21
+ <slot name="trigger">
22
+ <span class="currency-content">
23
+ <span class="currency-symbol">{{
24
+ getSymbol(cart.currency)
25
+ }}</span>
26
+ <span class="currency-code">{{ cart.currency }}</span>
27
+ </span>
28
+ </slot>
29
+ <span class="currency-arrow"></span>
30
+ </button>
31
+
32
+ <div v-if="isOpen" class="currency-dropdown" :class="dropdownClass">
33
+ <div class="currency-dropdown-scroll">
34
+ <button
35
+ v-for="c in currencies"
36
+ :key="c"
37
+ type="button"
38
+ class="currency-option"
39
+ :class="[
40
+ optionClass,
41
+ { 'is-selected': cart.currency === c },
42
+ ]"
43
+ @click="selectCurrency(c)"
44
+ >
45
+ <span class="currency-symbol-mini">{{ getSymbol(c) }}</span>
46
+ <span class="currency-code">{{ c }}</span>
47
+ </button>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </template>
52
+
53
+ <script setup lang="ts">
54
+ const props = defineProps<{
55
+ containerClass?: string;
56
+ triggerClass?: string;
57
+ dropdownClass?: string;
58
+ optionClass?: string;
59
+ }>();
60
+
61
+ const pinia = useNuxtApp().$pinia;
62
+ const cart = useCartStore(pinia);
63
+
64
+ const currencySymbols: Record<string, string> = {
65
+ USD: "$",
66
+ EUR: "€",
67
+ GBP: "£",
68
+ JPY: "¥",
69
+ CHF: "₣",
70
+ CNY: "¥",
71
+ RUB: "₽",
72
+ PLN: "zł",
73
+ UAH: "₴",
74
+ KZT: "₸",
75
+ BYN: "Br",
76
+ CZK: "Kč",
77
+ HUF: "Ft",
78
+ RON: "L",
79
+ TRY: "₺",
80
+ AED: "د.إ",
81
+ ILS: "₪",
82
+ CAD: "C$",
83
+ AUD: "A$",
84
+ INR: "₹",
85
+ KRW: "₩",
86
+ BRL: "R$",
87
+ THB: "฿",
88
+ GEL: "₾",
89
+ AMD: "֏",
90
+ };
91
+
92
+ const getSymbol = (code: string) => currencySymbols[code] || "";
93
+
94
+ const currencies = computed(() => Object.keys(cart.exchangeRates || {}));
95
+
96
+ const isOpen = ref(false);
97
+ const isDropUp = ref(false);
98
+ const isDropRight = ref(false);
99
+ const selectWrapper = ref<HTMLElement | null>(null);
100
+
101
+ const toggleDropdown = () => {
102
+ if (!isOpen.value) checkPosition();
103
+ isOpen.value = !isOpen.value;
104
+ };
105
+
106
+ const checkPosition = () => {
107
+ if (!selectWrapper.value) return;
108
+ const rect = selectWrapper.value.getBoundingClientRect();
109
+ isDropUp.value = window.innerHeight - rect.bottom < 200;
110
+ isDropRight.value = window.innerWidth - rect.left < 150;
111
+ };
112
+
113
+ const selectCurrency = (currency: string) => {
114
+ cart.setCurrency(currency);
115
+ isOpen.value = false;
116
+ };
117
+
118
+ const close = () => {
119
+ isOpen.value = false;
120
+ };
121
+
122
+ onMounted(() => {
123
+ window.addEventListener("click", close);
124
+ window.addEventListener("resize", close);
125
+ });
126
+
127
+ onBeforeUnmount(() => {
128
+ window.removeEventListener("click", close);
129
+ window.removeEventListener("resize", close);
130
+ });
131
+ </script>
132
+
133
+ <style scoped>
134
+ .ui-currency-select {
135
+ position: relative;
136
+ display: inline-block;
137
+ --bg: var(--ui-bg);
138
+ --brd: var(--ui-border);
139
+ --txt: var(--ui-text);
140
+ --ui-hover: rgba(128, 128, 128, 0.1);
141
+ min-width: 6rem;
142
+ }
143
+
144
+ .currency-trigger {
145
+ cursor: pointer;
146
+ background: var(--bg);
147
+ border: 1px solid var(--brd);
148
+ color: var(--txt);
149
+ padding: 8px 14px;
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 10px;
153
+ text-transform: uppercase;
154
+ font-family: inherit;
155
+ transition: background 0.2s;
156
+ min-width: 6rem;
157
+ }
158
+
159
+ .currency-content {
160
+ display: flex;
161
+ gap: 6px;
162
+ align-items: center;
163
+ }
164
+
165
+ .currency-code,
166
+ .currency-symbol,
167
+ .currency-symbol-mini {
168
+ font-size: 1rem;
169
+ line-height: 1;
170
+ font-weight: 400;
171
+ }
172
+
173
+ .currency-symbol {
174
+ opacity: 0.6;
175
+ }
176
+
177
+ .currency-symbol-mini {
178
+ width: 20px;
179
+ display: inline-block;
180
+ opacity: 0.5;
181
+ }
182
+
183
+ .currency-trigger:hover {
184
+ background: var(--ui-hover);
185
+ }
186
+
187
+ .currency-arrow {
188
+ width: 6px;
189
+ height: 6px;
190
+ border-right: 1px solid var(--txt);
191
+ border-bottom: 1px solid var(--txt);
192
+ transform: rotate(45deg) translateY(-2px);
193
+ opacity: 0.6;
194
+ }
195
+
196
+ .is-open .currency-arrow {
197
+ transform: rotate(-135deg) translateY(1px);
198
+ }
199
+
200
+ .currency-dropdown {
201
+ position: absolute;
202
+ top: calc(100% + 4px);
203
+ left: 0;
204
+ z-index: 1000;
205
+ background: var(--bg);
206
+ border: 1px solid var(--brd);
207
+ min-width: 100%;
208
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
209
+ }
210
+
211
+ .drop-right .currency-dropdown {
212
+ left: auto;
213
+ right: 0;
214
+ }
215
+ .drop-up .currency-dropdown {
216
+ top: auto;
217
+ bottom: calc(100% + 4px);
218
+ }
219
+
220
+ .currency-option {
221
+ width: 100%;
222
+ padding: 12px 14px;
223
+ border: none;
224
+ background: transparent;
225
+ color: var(--txt);
226
+ text-align: left;
227
+ cursor: pointer;
228
+ display: flex;
229
+ align-items: center;
230
+ gap: 8px;
231
+ text-transform: uppercase;
232
+ transition: all 0.2s;
233
+ }
234
+
235
+ .currency-option:hover {
236
+ background: var(--ui-hover);
237
+ }
238
+
239
+ .currency-option.is-selected {
240
+ background: var(--txt);
241
+ color: var(--bg);
242
+ }
243
+
244
+ .currency-option.is-selected .currency-symbol-mini {
245
+ opacity: 1;
246
+ color: inherit;
247
+ }
248
+
249
+ @media (prefers-color-scheme: light) {
250
+ .ui-currency-select {
251
+ --ui-hover: rgba(0, 0, 0, 0.05);
252
+ }
253
+ }
254
+ </style>
@@ -0,0 +1,142 @@
1
+ <script setup lang="ts">
2
+ import { NuxtLink } from "#components";
3
+
4
+ interface FeatureItem {
5
+ icon?: string;
6
+ title?: string;
7
+ subtitle?: string;
8
+ description?: string;
9
+ to?: string;
10
+ iconClass?: string;
11
+ }
12
+
13
+ interface Props {
14
+ items: FeatureItem[];
15
+ columns?: 2 | 3 | 4;
16
+ vertical?: boolean;
17
+ iconSize?: number;
18
+ descAlign?: "left" | "center" | "right" | "justify";
19
+ defaultIconClass?: string;
20
+ }
21
+
22
+ const props = withDefaults(defineProps<Props>(), {
23
+ columns: 3,
24
+ vertical: false,
25
+ iconSize: 42,
26
+ descAlign: "left",
27
+ defaultIconClass: "text-primary",
28
+ });
29
+
30
+ const gridColsClass = computed(() => {
31
+ switch (props.columns) {
32
+ case 2:
33
+ return "md:grid-cols-2";
34
+ case 4:
35
+ return "md:grid-cols-4";
36
+ default:
37
+ return "md:grid-cols-3";
38
+ }
39
+ });
40
+
41
+ const shadowStyles = `
42
+ shadow-[0px_0px_30px_0px_rgba(0,0,0,0.05)]
43
+ hover:shadow-[0px_0px_30px_0px_rgba(0,0,0,0.15)]
44
+ dark:shadow-none
45
+ dark:hover:shadow-[0px_0px_15px_0px_rgba(255,255,255,0.05)]
46
+ transition-all duration-300 ease-linear
47
+ `;
48
+
49
+ const cardClass = `
50
+ relative flex flex-col h-full
51
+ bg-white dark:bg-neutral-900
52
+ rounded-xl border border-transparent dark:border-neutral-800
53
+ overflow-hidden
54
+ ${shadowStyles}
55
+ `;
56
+
57
+ const descAlignClass = computed(() => {
58
+ switch (props.descAlign) {
59
+ case "center":
60
+ return "text-center";
61
+ case "right":
62
+ return "text-right";
63
+ case "justify":
64
+ return "text-justify";
65
+ default:
66
+ return "text-left";
67
+ }
68
+ });
69
+ </script>
70
+
71
+ <template>
72
+ <div class="w-full">
73
+ <div class="grid grid-cols-1 gap-6" :class="gridColsClass">
74
+ <component
75
+ :is="item.to ? NuxtLink : 'div'"
76
+ v-for="(item, index) in items"
77
+ :key="index"
78
+ :to="item.to"
79
+ :class="cardClass"
80
+ class="group p-6"
81
+ >
82
+ <div
83
+ v-if="item.icon || item.title"
84
+ class="w-full"
85
+ :class="
86
+ vertical
87
+ ? 'flex flex-col items-center text-center gap-3'
88
+ : 'grid grid-cols-[auto_1fr] items-center gap-4 text-left'
89
+ "
90
+ >
91
+ <div
92
+ v-if="item.icon"
93
+ class="flex items-center justify-center leading-none"
94
+ :class="item.iconClass || defaultIconClass"
95
+ :style="{
96
+ width: vertical ? 'auto' : `${iconSize}px`,
97
+ height: `${iconSize}px`,
98
+ }"
99
+ >
100
+ <UiIcon
101
+ :name="item.icon"
102
+ :size="iconSize"
103
+ class="block"
104
+ />
105
+ </div>
106
+
107
+ <div
108
+ v-if="item.title"
109
+ class="flex flex-col justify-center min-w-0"
110
+ :class="vertical ? 'items-center' : 'items-start'"
111
+ >
112
+ <p
113
+ class="text-lg font-semibold text-neutral-900 dark:text-white leading-none pb-0.5"
114
+ >
115
+ {{ item.title }}
116
+ </p>
117
+
118
+ <p
119
+ v-if="item.subtitle"
120
+ class="text-sm text-neutral-500 dark:text-neutral-400 mt-1.5 leading-none font-medium"
121
+ >
122
+ {{ item.subtitle }}
123
+ </p>
124
+ </div>
125
+ </div>
126
+
127
+ <hr
128
+ v-if="(item.icon || item.title) && item.description"
129
+ class="w-[80%] mx-auto my-5 border-t border-neutral-100 dark:border-neutral-800 group-hover:border-neutral-200 dark:group-hover:border-neutral-700 transition-colors duration-300"
130
+ />
131
+
132
+ <div
133
+ v-if="item.description"
134
+ class="text-sm text-neutral-500 dark:text-neutral-400 leading-relaxed grow"
135
+ :class="descAlignClass"
136
+ >
137
+ {{ item.description }}
138
+ </div>
139
+ </component>
140
+ </div>
141
+ </div>
142
+ </template>