@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,945 @@
1
+ export type CartItemId = string | number;
2
+ export type CurrencyCode = Uppercase<string>;
3
+
4
+ export interface CartItem {
5
+ id: CartItemId;
6
+ price: number;
7
+ quantity: number;
8
+ name?: string;
9
+ currency?: CurrencyCode;
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ export interface AddCartItem {
14
+ id: CartItemId;
15
+ price: number;
16
+ quantity?: number;
17
+ name?: string;
18
+ currency?: string;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export interface Coupon {
23
+ code: string;
24
+ discountPercent?: number;
25
+ discountAmount?: number;
26
+ meta?: Record<string, unknown>;
27
+ }
28
+
29
+ export interface CartTotals {
30
+ subtotal: number;
31
+ discount: number;
32
+ tax: number;
33
+ shipping: number;
34
+ total: number;
35
+ taxes: Record<string, number>;
36
+ }
37
+
38
+ export interface CartState<TItem extends CartItem = CartItem> {
39
+ items: TItem[];
40
+ coupon: Coupon | null;
41
+ meta: Record<string, unknown>;
42
+ currency: CurrencyCode;
43
+ }
44
+
45
+ export interface CurrencyRates {
46
+ base: CurrencyCode;
47
+ date?: string;
48
+ rates: Record<string, number>;
49
+ }
50
+
51
+ export interface CartCalculationContext<TItem extends CartItem = CartItem> {
52
+ state: CartState<TItem>;
53
+ totals: CartTotals;
54
+ currency: {
55
+ base: CurrencyCode;
56
+ selected: CurrencyCode;
57
+ rate: number;
58
+ rates: Record<string, number>;
59
+ };
60
+ }
61
+
62
+ export type CartStrategy<TItem extends CartItem = CartItem> = (
63
+ ctx: CartCalculationContext<TItem>,
64
+ ) => Partial<CartTotals> | void | Promise<Partial<CartTotals> | void>;
65
+
66
+ export type CartHook<TItem extends CartItem = CartItem> = (
67
+ ctx: CartCalculationContext<TItem>,
68
+ ) => void | Promise<void>;
69
+
70
+ export interface CartConfig {
71
+ baseCurrency?: CurrencyCode;
72
+ rates?: Record<string, number>;
73
+ autoFetchRates?: boolean;
74
+ rateEndpoint?: string;
75
+ taxPercentMetaKey?: string | null;
76
+ shippingAmountMetaKey?: string | null;
77
+ shippingCurrencyMetaKey?: string | null;
78
+ }
79
+
80
+ export const cartBroadcastKeys = [
81
+ 'items',
82
+ 'coupon',
83
+ 'meta',
84
+ 'currency',
85
+ ] as const;
86
+
87
+ interface PrivacyPreferences {
88
+ functional?: boolean;
89
+ status?: string;
90
+ }
91
+
92
+ interface CartStoreState extends CartState {
93
+ config: Required<CartConfig>;
94
+ exchangeRates: Record<string, number>;
95
+ ratesDate: string | null;
96
+ ratesLoaded: boolean;
97
+ ratesPending: boolean;
98
+ ratesError: string | null;
99
+ totalsValue: CartTotals;
100
+ }
101
+
102
+ interface CartRuntime {
103
+ strategies: Map<string, CartStrategy>;
104
+ beforeHooks: CartHook[];
105
+ afterHooks: CartHook[];
106
+ stopSubscription: (() => void) | null;
107
+ isApplyingState: boolean;
108
+ isCalculating: boolean;
109
+ isCalculationQueued: boolean;
110
+ }
111
+
112
+ const DEFAULT_BASE_CURRENCY: CurrencyCode = 'USD';
113
+
114
+ const DEFAULT_USD_RATES: Record<CurrencyCode, number> = {
115
+ USD: 1,
116
+ EUR: 0.87621,
117
+ PLN: 3.7645,
118
+ GBP: 0.75527,
119
+ JPY: 162.16,
120
+ CAD: 1.4222,
121
+ AUD: 1.4528,
122
+ CHF: 0.80847,
123
+ CNY: 6.7904,
124
+ SEK: 9.7227,
125
+ NOK: 9.9281,
126
+ DKK: 6.5546,
127
+ CZK: 21.28,
128
+ HUF: 311.5,
129
+ RON: 4.5983,
130
+ BGN: 1.7139,
131
+ TRY: 46.651,
132
+ UAH: 44.835,
133
+ RUB: 77.746,
134
+ BRL: 5.1746,
135
+ MXN: 17.4775,
136
+ INR: 94.57,
137
+ KRW: 1546.49,
138
+ SGD: 1.2943,
139
+ HKD: 7.8508,
140
+ NZD: 1.7691,
141
+ ZAR: 16.4045,
142
+ AED: 3.6725,
143
+ ILS: 2.9832,
144
+ BYN: 2.93,
145
+ };
146
+
147
+ const emptyTotals = (): CartTotals => ({
148
+ subtotal: 0,
149
+ discount: 0,
150
+ tax: 0,
151
+ shipping: 0,
152
+ total: 0,
153
+ taxes: {},
154
+ });
155
+
156
+ const defaultCartState = (currency = DEFAULT_BASE_CURRENCY): CartState => ({
157
+ items: [],
158
+ coupon: null,
159
+ meta: {},
160
+ currency: normalizeCurrency(currency),
161
+ });
162
+
163
+ const normalizeCurrency = (currency: string): CurrencyCode =>
164
+ currency.trim().toUpperCase() as CurrencyCode;
165
+
166
+ const roundMoney = (value: number) => Math.round(value * 100) / 100;
167
+
168
+ const normalizeRates = (
169
+ rates: Record<string, number>,
170
+ base: CurrencyCode,
171
+ ): Record<CurrencyCode, number> => ({
172
+ ...(Object.fromEntries(
173
+ Object.entries(rates)
174
+ .filter(([, rate]) => Number.isFinite(rate) && rate > 0)
175
+ .map(([currency, rate]) => [normalizeCurrency(currency), rate]),
176
+ ) as Record<CurrencyCode, number>),
177
+ [base]: 1,
178
+ });
179
+
180
+ const rebaseRates = (
181
+ rates: Record<CurrencyCode, number>,
182
+ fromBase: CurrencyCode,
183
+ toBase: CurrencyCode,
184
+ ) => {
185
+ if (fromBase === toBase) return normalizeRates(rates, toBase);
186
+
187
+ const baseRate = rates[toBase];
188
+ if (!baseRate) return normalizeRates(rates, fromBase);
189
+
190
+ return normalizeRates(
191
+ Object.fromEntries(
192
+ Object.entries(rates).map(([currency, rate]) => [
193
+ currency,
194
+ rate / baseRate,
195
+ ]),
196
+ ),
197
+ toBase,
198
+ );
199
+ };
200
+
201
+ const convertAmountValue = (
202
+ amount: number,
203
+ from: string | undefined,
204
+ to: string | undefined,
205
+ rates: Record<string, number>,
206
+ fallbackCurrency: CurrencyCode,
207
+ ) => {
208
+ const source = normalizeCurrency(from || fallbackCurrency);
209
+ const target = normalizeCurrency(to || fallbackCurrency);
210
+ const sourceRate = rates[source];
211
+ const targetRate = rates[target];
212
+
213
+ if (!sourceRate || !targetRate) {
214
+ throw new Error(`Cannot convert "${source}" to "${target}".`);
215
+ }
216
+
217
+ return roundMoney((amount / sourceRate) * targetRate);
218
+ };
219
+
220
+ const getItemBasePriceValue = (
221
+ item: CartItem,
222
+ rates: Record<string, number>,
223
+ baseCurrency: CurrencyCode,
224
+ ) =>
225
+ item.currency
226
+ ? convertAmountValue(
227
+ item.price,
228
+ item.currency,
229
+ baseCurrency,
230
+ rates,
231
+ baseCurrency,
232
+ )
233
+ : item.price;
234
+
235
+ const toJsonSafe = <T>(value: T): T => {
236
+ const seen = new WeakSet<object>();
237
+
238
+ const normalize = (input: unknown): unknown => {
239
+ const raw = toRaw(input);
240
+
241
+ if (raw === null || typeof raw !== 'object') return raw;
242
+ if (raw instanceof Date) return raw.toISOString();
243
+ if (seen.has(raw)) return undefined;
244
+
245
+ seen.add(raw);
246
+
247
+ if (Array.isArray(raw)) {
248
+ return raw.map(normalize).filter((item) => item !== undefined);
249
+ }
250
+
251
+ return Object.fromEntries(
252
+ Object.entries(raw)
253
+ .map(([key, item]) => [key, normalize(item)])
254
+ .filter(([, item]) => item !== undefined),
255
+ );
256
+ };
257
+
258
+ return normalize(value) as T;
259
+ };
260
+
261
+ const normalizeCartState = (
262
+ state: Partial<CartState> | null | undefined,
263
+ fallbackCurrency = DEFAULT_BASE_CURRENCY,
264
+ ): CartState => {
265
+ const source = state || defaultCartState(fallbackCurrency);
266
+
267
+ return {
268
+ items: toJsonSafe(source.items || []).map((item) => ({
269
+ ...item,
270
+ id: item.id,
271
+ price: Number(item.price),
272
+ quantity: Math.max(1, Math.floor(Number(item.quantity || 1))),
273
+ currency: item.currency
274
+ ? normalizeCurrency(String(item.currency))
275
+ : undefined,
276
+ })),
277
+ coupon: source.coupon ? toJsonSafe(source.coupon) : null,
278
+ meta: toJsonSafe(source.meta || {}),
279
+ currency: normalizeCurrency(source.currency || fallbackCurrency),
280
+ };
281
+ };
282
+
283
+ const parseCookieObject = <T>(value: unknown): T | null => {
284
+ if (!value) return null;
285
+ if (typeof value === 'object') return value as T;
286
+
287
+ try {
288
+ return JSON.parse(decodeURIComponent(String(value))) as T;
289
+ } catch {
290
+ return null;
291
+ }
292
+ };
293
+
294
+ const hasFunctionalCookieConsent = () => {
295
+ const preferences = useCookie<string | PrivacyPreferences | null>(
296
+ 'privacy-preferences',
297
+ );
298
+ const parsed = parseCookieObject<PrivacyPreferences>(preferences.value);
299
+
300
+ return parsed?.status === 'accepted' && parsed.functional === true;
301
+ };
302
+
303
+ const shouldPersistCartState = (state: CartState) =>
304
+ Array.isArray(state.items) && state.items.length > 0;
305
+
306
+ const readCartCookie = () => {
307
+ if (!hasFunctionalCookieConsent()) return null;
308
+
309
+ const cookie = useCookie<CartState | string | null>('cart');
310
+ return parseCookieObject<CartState>(cookie.value);
311
+ };
312
+
313
+ const cartRuntime = new WeakMap<object, CartRuntime>();
314
+
315
+ const getCartRuntime = (store: object): CartRuntime => {
316
+ const existing = cartRuntime.get(store);
317
+ if (existing) return existing;
318
+
319
+ const runtime: CartRuntime = {
320
+ strategies: new Map(),
321
+ beforeHooks: [],
322
+ afterHooks: [],
323
+ stopSubscription: null,
324
+ isApplyingState: false,
325
+ isCalculating: false,
326
+ isCalculationQueued: false,
327
+ };
328
+ cartRuntime.set(store, runtime);
329
+
330
+ return runtime;
331
+ };
332
+
333
+ const serializeError = (error: unknown) =>
334
+ error instanceof Error ? error.message : String(error || 'Unknown error');
335
+
336
+ const readNumberMeta = (
337
+ meta: Record<string, unknown>,
338
+ key: string | null | undefined,
339
+ ) => {
340
+ if (!key) return 0;
341
+
342
+ const value = Number(meta[key]);
343
+ return Number.isFinite(value) ? value : 0;
344
+ };
345
+
346
+ const readStringMeta = (
347
+ meta: Record<string, unknown>,
348
+ key: string | null | undefined,
349
+ ) => {
350
+ if (!key) return '';
351
+
352
+ const value = meta[key];
353
+ return typeof value === 'string' ? value : '';
354
+ };
355
+
356
+ export const useCartStore = defineStore('cart', {
357
+ state: (): CartStoreState => {
358
+ const initial = normalizeCartState(readCartCookie());
359
+
360
+ return {
361
+ ...initial,
362
+ config: {
363
+ baseCurrency: DEFAULT_BASE_CURRENCY,
364
+ rates: DEFAULT_USD_RATES,
365
+ autoFetchRates: true,
366
+ rateEndpoint: '/api/exchange-rates',
367
+ taxPercentMetaKey: null,
368
+ shippingAmountMetaKey: null,
369
+ shippingCurrencyMetaKey: null,
370
+ },
371
+ exchangeRates: { ...DEFAULT_USD_RATES },
372
+ ratesDate: null,
373
+ ratesLoaded: false,
374
+ ratesPending: false,
375
+ ratesError: null,
376
+ totalsValue: emptyTotals(),
377
+ };
378
+ },
379
+
380
+ getters: {
381
+ baseCurrency: (state): CurrencyCode => state.config.baseCurrency,
382
+
383
+ rate: (state): number => state.exchangeRates[state.currency] ?? 1,
384
+
385
+ availableCurrencies: (state): string[] =>
386
+ Object.keys(state.exchangeRates),
387
+
388
+ totalItems: (state): number =>
389
+ state.items.reduce((sum, item) => sum + item.quantity, 0),
390
+
391
+ subtotal: (state): number => {
392
+ const baseCurrency = state.config.baseCurrency;
393
+ const rates = state.exchangeRates;
394
+
395
+ return roundMoney(
396
+ state.items.reduce(
397
+ (sum, item) =>
398
+ sum +
399
+ getItemBasePriceValue(item, rates, baseCurrency) *
400
+ item.quantity,
401
+ 0,
402
+ ),
403
+ );
404
+ },
405
+
406
+ totals: (state): CartTotals => state.totalsValue,
407
+
408
+ convertedTotals: (state): CartTotals => {
409
+ const rate = state.exchangeRates[state.currency] ?? 1;
410
+
411
+ return {
412
+ subtotal: roundMoney(state.totalsValue.subtotal * rate),
413
+ discount: roundMoney(state.totalsValue.discount * rate),
414
+ tax: roundMoney(state.totalsValue.tax * rate),
415
+ shipping: roundMoney(state.totalsValue.shipping * rate),
416
+ total: roundMoney(state.totalsValue.total * rate),
417
+ taxes: Object.fromEntries(
418
+ Object.entries(state.totalsValue.taxes).map(
419
+ ([key, value]) => [key, roundMoney(value * rate)],
420
+ ),
421
+ ),
422
+ };
423
+ },
424
+
425
+ discount: (state): number =>
426
+ roundMoney(
427
+ state.totalsValue.discount *
428
+ (state.exchangeRates[state.currency] ?? 1),
429
+ ),
430
+
431
+ tax: (state): number =>
432
+ roundMoney(
433
+ state.totalsValue.tax *
434
+ (state.exchangeRates[state.currency] ?? 1),
435
+ ),
436
+
437
+ shipping: (state): number =>
438
+ roundMoney(
439
+ state.totalsValue.shipping *
440
+ (state.exchangeRates[state.currency] ?? 1),
441
+ ),
442
+
443
+ grandTotal: (state): number =>
444
+ roundMoney(
445
+ state.totalsValue.total *
446
+ (state.exchangeRates[state.currency] ?? 1),
447
+ ),
448
+ },
449
+
450
+ actions: {
451
+ stateSnapshot(): CartState {
452
+ return normalizeCartState(
453
+ {
454
+ items: this.items,
455
+ coupon: this.coupon,
456
+ meta: this.meta,
457
+ currency: this.currency,
458
+ },
459
+ this.baseCurrency,
460
+ );
461
+ },
462
+
463
+ initCart() {
464
+ const runtime = getCartRuntime(this);
465
+ if (
466
+ this.config.autoFetchRates &&
467
+ !this.ratesLoaded &&
468
+ !this.ratesPending
469
+ ) {
470
+ void this.fetchExchangeRates().catch(() => {});
471
+ }
472
+ if (runtime.stopSubscription) return runtime.stopSubscription;
473
+
474
+ this.hydrateFromClientStorage();
475
+ void this.calculateTotals();
476
+ this.persist();
477
+
478
+ const stopWatch = watch(
479
+ () =>
480
+ [
481
+ this.items,
482
+ this.coupon,
483
+ this.meta,
484
+ this.currency,
485
+ ] as const,
486
+ () => {
487
+ if (runtime.isApplyingState) return;
488
+ if (runtime.isCalculating) {
489
+ runtime.isCalculationQueued = true;
490
+ return;
491
+ }
492
+ void this.calculateTotals();
493
+ this.persist();
494
+ },
495
+ { deep: true, flush: 'post' },
496
+ );
497
+
498
+ runtime.stopSubscription = () => {
499
+ stopWatch();
500
+ runtime.stopSubscription = null;
501
+ };
502
+
503
+ return runtime.stopSubscription;
504
+ },
505
+
506
+ persist() {
507
+ const state = this.stateSnapshot();
508
+ const cartCookie = useCookie<CartState | null>('cart', {
509
+ path: '/',
510
+ maxAge: 30 * 86400,
511
+ });
512
+
513
+ if (!shouldPersistCartState(state)) {
514
+ cartCookie.value = null;
515
+
516
+ if (import.meta.client) {
517
+ localStorage.removeItem('cart');
518
+ }
519
+ return;
520
+ }
521
+
522
+ if (hasFunctionalCookieConsent()) {
523
+ cartCookie.value = state;
524
+ return;
525
+ }
526
+
527
+ cartCookie.value = null;
528
+
529
+ if (import.meta.client) {
530
+ localStorage.setItem('cart', JSON.stringify(state));
531
+ }
532
+ },
533
+
534
+ hydrateFromClientStorage() {
535
+ if (!import.meta.client || hasFunctionalCookieConsent()) return;
536
+
537
+ try {
538
+ const raw = localStorage.getItem('cart');
539
+ if (!raw) return;
540
+ this.applyState(JSON.parse(raw) as CartState);
541
+ } catch {
542
+ localStorage.removeItem('cart');
543
+ }
544
+ },
545
+
546
+ applyState(state: Partial<CartState> | null | undefined) {
547
+ const runtime = getCartRuntime(this);
548
+ const next = normalizeCartState(state, this.baseCurrency);
549
+
550
+ runtime.isApplyingState = true;
551
+ try {
552
+ this.items = next.items;
553
+ this.coupon = next.coupon;
554
+ this.meta = next.meta;
555
+ this.currency = this.exchangeRates[next.currency]
556
+ ? next.currency
557
+ : this.baseCurrency;
558
+ } finally {
559
+ runtime.isApplyingState = false;
560
+ }
561
+ void this.calculateTotals();
562
+ this.persist();
563
+ },
564
+
565
+ convertAmount(amount: number, from?: string, to?: string) {
566
+ return convertAmountValue(
567
+ amount,
568
+ from,
569
+ to,
570
+ this.exchangeRates,
571
+ this.baseCurrency,
572
+ );
573
+ },
574
+
575
+ getItemBasePrice(item: CartItem) {
576
+ return getItemBasePriceValue(
577
+ item,
578
+ this.exchangeRates,
579
+ this.baseCurrency,
580
+ );
581
+ },
582
+
583
+ calculationContext(
584
+ state: CartState,
585
+ totals: CartTotals,
586
+ ): CartCalculationContext {
587
+ return {
588
+ state,
589
+ totals,
590
+ currency: {
591
+ base: this.baseCurrency,
592
+ selected: this.currency,
593
+ rate: this.rate,
594
+ rates: { ...this.exchangeRates },
595
+ },
596
+ };
597
+ },
598
+
599
+ configureCart(options: CartConfig) {
600
+ const nextBase = normalizeCurrency(
601
+ options.baseCurrency || this.config.baseCurrency,
602
+ );
603
+ const shouldRebaseRates =
604
+ nextBase !== this.config.baseCurrency && !options.rates;
605
+ const nextRates = options.rates
606
+ ? normalizeRates(options.rates, nextBase)
607
+ : shouldRebaseRates
608
+ ? rebaseRates(
609
+ this.exchangeRates,
610
+ this.config.baseCurrency,
611
+ nextBase,
612
+ )
613
+ : this.exchangeRates;
614
+
615
+ this.config = {
616
+ ...this.config,
617
+ ...options,
618
+ baseCurrency: nextBase,
619
+ rates: nextRates,
620
+ };
621
+
622
+ this.exchangeRates = nextRates;
623
+ if (options.rates) this.ratesLoaded = true;
624
+ if (!this.exchangeRates[this.currency]) this.currency = nextBase;
625
+ void this.calculateTotals();
626
+ },
627
+
628
+ setCurrency(code: string) {
629
+ const next = normalizeCurrency(code);
630
+ if (!this.exchangeRates[next]) {
631
+ throw new Error(
632
+ `Currency "${next}" is not available in cart rates.`,
633
+ );
634
+ }
635
+
636
+ this.currency = next;
637
+ void this.calculateTotals();
638
+ this.persist();
639
+ },
640
+
641
+ addItem(item: AddCartItem) {
642
+ const quantity = Math.max(1, Number(item.quantity ?? 1));
643
+ const existing = this.items.find(
644
+ (cartItem) => cartItem.id === item.id,
645
+ );
646
+ const itemCurrency = item.currency
647
+ ? normalizeCurrency(item.currency)
648
+ : undefined;
649
+
650
+ if (itemCurrency && !this.exchangeRates[itemCurrency]) {
651
+ throw new Error(
652
+ `Currency "${itemCurrency}" is not available in cart rates.`,
653
+ );
654
+ }
655
+
656
+ if (existing) {
657
+ existing.quantity += quantity;
658
+ } else {
659
+ this.items.push(
660
+ toJsonSafe({
661
+ ...item,
662
+ currency: itemCurrency,
663
+ quantity,
664
+ price: Number(item.price),
665
+ }),
666
+ );
667
+ }
668
+
669
+ void this.calculateTotals();
670
+ this.persist();
671
+ },
672
+
673
+ removeItem(id: CartItemId) {
674
+ this.items = this.items.filter((item) => item.id !== id);
675
+ void this.calculateTotals();
676
+ this.persist();
677
+ },
678
+
679
+ updateQuantity(id: CartItemId, quantity: number) {
680
+ const item = this.items.find((cartItem) => cartItem.id === id);
681
+ if (!item) return;
682
+
683
+ const nextQuantity = Math.max(0, Math.floor(quantity));
684
+ if (nextQuantity === 0) {
685
+ this.removeItem(id);
686
+ return;
687
+ }
688
+
689
+ item.quantity = nextQuantity;
690
+ void this.calculateTotals();
691
+ this.persist();
692
+ },
693
+
694
+ incrementItem(id: CartItemId, step = 1) {
695
+ const item = this.items.find((cartItem) => cartItem.id === id);
696
+ if (item) this.updateQuantity(id, item.quantity + step);
697
+ },
698
+
699
+ clearCart() {
700
+ this.items = [];
701
+ this.coupon = null;
702
+ this.meta = {};
703
+ this.currency = this.baseCurrency;
704
+ this.totalsValue = emptyTotals();
705
+ this.persist();
706
+ },
707
+
708
+ applyCoupon(
709
+ couponOrCode: Coupon | string,
710
+ discountPercent?: number,
711
+ discountAmount?: number,
712
+ ) {
713
+ this.coupon =
714
+ typeof couponOrCode === 'string'
715
+ ? { code: couponOrCode, discountPercent, discountAmount }
716
+ : toJsonSafe(couponOrCode);
717
+
718
+ void this.calculateTotals();
719
+ this.persist();
720
+ },
721
+
722
+ removeCoupon() {
723
+ this.coupon = null;
724
+ void this.calculateTotals();
725
+ this.persist();
726
+ },
727
+
728
+ async setMeta(key: string, value: unknown) {
729
+ this.meta = { ...this.meta, [key]: toJsonSafe(value) };
730
+ await this.calculateTotals();
731
+ this.persist();
732
+ },
733
+
734
+ async patchMeta(value: Record<string, unknown>) {
735
+ this.meta = { ...this.meta, ...toJsonSafe(value) };
736
+ await this.calculateTotals();
737
+ this.persist();
738
+ },
739
+
740
+ getMeta<T = unknown>(key: string): T | undefined {
741
+ return this.meta[key] as T | undefined;
742
+ },
743
+
744
+ resetStorage() {
745
+ const cartCookie = useCookie<CartState | null>('cart', {
746
+ path: '/',
747
+ });
748
+ cartCookie.value = null;
749
+
750
+ if (import.meta.client) {
751
+ localStorage.removeItem('cart');
752
+ }
753
+ },
754
+
755
+ addStrategy(name: string, strategy: CartStrategy) {
756
+ getCartRuntime(this).strategies.set(name, strategy);
757
+ void this.calculateTotals();
758
+ },
759
+
760
+ removeStrategy(name: string) {
761
+ getCartRuntime(this).strategies.delete(name);
762
+ void this.calculateTotals();
763
+ },
764
+
765
+ setExchangeRate(currencyCode: string, nextRate: number) {
766
+ const nextCurrency = normalizeCurrency(currencyCode);
767
+ if (!Number.isFinite(nextRate) || nextRate <= 0) {
768
+ throw new Error(`Invalid exchange rate for "${nextCurrency}".`);
769
+ }
770
+
771
+ this.exchangeRates = {
772
+ ...this.exchangeRates,
773
+ [nextCurrency]: nextRate,
774
+ [this.baseCurrency]: 1,
775
+ };
776
+ void this.calculateTotals();
777
+ },
778
+
779
+ setExchangeRates(rates: Record<string, number>, base?: string) {
780
+ const nextBase = normalizeCurrency(base || this.baseCurrency);
781
+ this.config.baseCurrency = nextBase;
782
+ this.exchangeRates = normalizeRates(rates, nextBase);
783
+ this.ratesLoaded = true;
784
+ if (!this.exchangeRates[this.currency]) this.currency = nextBase;
785
+ void this.calculateTotals();
786
+ },
787
+
788
+ async fetchExchangeRates(currencies?: string[]) {
789
+ const requestedCurrencies = currencies || this.availableCurrencies;
790
+
791
+ this.ratesPending = true;
792
+ this.ratesError = null;
793
+
794
+ try {
795
+ const response = await $fetch<CurrencyRates>(
796
+ this.config.rateEndpoint,
797
+ {
798
+ query: {
799
+ base: this.baseCurrency,
800
+ currencies: requestedCurrencies.join(','),
801
+ },
802
+ },
803
+ );
804
+
805
+ this.setExchangeRates(response.rates, response.base);
806
+ this.ratesDate = response.date || null;
807
+ return response;
808
+ } catch (error) {
809
+ this.ratesError = serializeError(error);
810
+ throw error;
811
+ } finally {
812
+ this.ratesPending = false;
813
+ }
814
+ },
815
+
816
+ onBeforeCalculate(fn: CartHook) {
817
+ const runtime = getCartRuntime(this);
818
+ runtime.beforeHooks.push(fn);
819
+ return () => {
820
+ const index = runtime.beforeHooks.indexOf(fn);
821
+ if (index >= 0) runtime.beforeHooks.splice(index, 1);
822
+ };
823
+ },
824
+
825
+ onAfterCalculate(fn: CartHook) {
826
+ const runtime = getCartRuntime(this);
827
+ runtime.afterHooks.push(fn);
828
+ return () => {
829
+ const index = runtime.afterHooks.indexOf(fn);
830
+ if (index >= 0) runtime.afterHooks.splice(index, 1);
831
+ };
832
+ },
833
+
834
+ async calculateTotals() {
835
+ const runtime = getCartRuntime(this);
836
+ if (runtime.isCalculating) {
837
+ runtime.isCalculationQueued = true;
838
+ return;
839
+ }
840
+
841
+ runtime.isCalculating = true;
842
+ try {
843
+ do {
844
+ runtime.isCalculationQueued = false;
845
+ const state = this.stateSnapshot();
846
+ const base = emptyTotals();
847
+
848
+ base.subtotal = roundMoney(
849
+ state.items.reduce(
850
+ (sum, item) =>
851
+ sum +
852
+ getItemBasePriceValue(
853
+ item,
854
+ this.exchangeRates,
855
+ this.baseCurrency,
856
+ ) *
857
+ item.quantity,
858
+ 0,
859
+ ),
860
+ );
861
+
862
+ const beforeContext = this.calculationContext(state, {
863
+ ...base,
864
+ });
865
+ for (const hook of runtime.beforeHooks) {
866
+ await hook(beforeContext);
867
+ }
868
+
869
+ if (state.coupon?.discountAmount != null) {
870
+ base.discount = Math.min(
871
+ state.coupon.discountAmount,
872
+ base.subtotal,
873
+ );
874
+ } else if (state.coupon?.discountPercent != null) {
875
+ base.discount = Math.min(
876
+ (base.subtotal * state.coupon.discountPercent) /
877
+ 100,
878
+ base.subtotal,
879
+ );
880
+ }
881
+
882
+ const taxPercent = readNumberMeta(
883
+ state.meta,
884
+ this.config.taxPercentMetaKey,
885
+ );
886
+ const shippingAmount = readNumberMeta(
887
+ state.meta,
888
+ this.config.shippingAmountMetaKey,
889
+ );
890
+ const shippingCurrency =
891
+ readStringMeta(
892
+ state.meta,
893
+ this.config.shippingCurrencyMetaKey,
894
+ ) ||
895
+ state.currency ||
896
+ this.baseCurrency;
897
+
898
+ if (taxPercent > 0) {
899
+ base.tax +=
900
+ ((base.subtotal - base.discount) * taxPercent) /
901
+ 100;
902
+ }
903
+
904
+ if (shippingAmount > 0) {
905
+ base.shipping += this.convertAmount(
906
+ shippingAmount,
907
+ shippingCurrency,
908
+ this.baseCurrency,
909
+ );
910
+ }
911
+
912
+ for (const strategy of runtime.strategies.values()) {
913
+ const result = await strategy(
914
+ this.calculationContext(state, { ...base }),
915
+ );
916
+ if (result) Object.assign(base, result);
917
+ }
918
+
919
+ base.discount = roundMoney(Math.max(0, base.discount));
920
+ base.tax = roundMoney(Math.max(0, base.tax));
921
+ base.shipping = roundMoney(Math.max(0, base.shipping));
922
+ base.total = roundMoney(
923
+ Math.max(
924
+ 0,
925
+ base.subtotal -
926
+ base.discount +
927
+ base.tax +
928
+ base.shipping,
929
+ ),
930
+ );
931
+ this.totalsValue = base;
932
+
933
+ const afterContext = this.calculationContext(state, {
934
+ ...this.totalsValue,
935
+ });
936
+ for (const hook of runtime.afterHooks) {
937
+ await hook(afterContext);
938
+ }
939
+ } while (runtime.isCalculationQueued);
940
+ } finally {
941
+ runtime.isCalculating = false;
942
+ }
943
+ },
944
+ },
945
+ });