@eox/pages-theme-eox 0.11.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.gitlab-ci.yml +30 -0
  2. package/.release-it.json +15 -0
  3. package/CHANGELOG.md +19 -0
  4. package/README.md +274 -0
  5. package/cypress/support/commands.js +25 -0
  6. package/cypress/support/component-index.html +11 -0
  7. package/cypress/support/component.js +22 -0
  8. package/cypress/support/mocks/features.data.js +9 -0
  9. package/cypress/support/mocks/helpers.js +63 -0
  10. package/cypress/support/mocks/vitepress.js +83 -0
  11. package/cypress.config.js +46 -0
  12. package/package.json +12 -2
  13. package/src/components/CTASection.cy.js +72 -0
  14. package/src/components/CookieBanner.cy.js +49 -0
  15. package/src/components/CookieBanner.vue +19 -6
  16. package/src/components/CookieSettings.cy.js +73 -0
  17. package/src/components/CookieSettings.vue +35 -27
  18. package/src/components/DataTable.cy.js +58 -0
  19. package/src/components/DataTable.vue +5 -1
  20. package/src/components/FeatureCard.cy.js +83 -0
  21. package/src/components/FeatureCard.vue +11 -5
  22. package/src/components/FeatureSection.cy.js +63 -0
  23. package/src/components/FeatureSection.vue +8 -4
  24. package/src/components/FeaturesGallery.cy.js +45 -0
  25. package/src/components/FeaturesGallery.vue +6 -3
  26. package/src/components/Footer.cy.js +77 -0
  27. package/src/components/Footer.vue +35 -14
  28. package/src/components/HeroSection.cy.js +66 -0
  29. package/src/components/LogoSection.cy.js +53 -0
  30. package/src/components/MobileNavDropdown.cy.js +67 -0
  31. package/src/components/MobileNavDropdown.vue +48 -0
  32. package/src/components/NavBar.cy.js +143 -0
  33. package/src/components/NavBar.vue +144 -8
  34. package/src/components/NavDropdown.cy.js +71 -0
  35. package/src/components/NavDropdown.vue +62 -0
  36. package/src/components/NewsBanner.cy.js +23 -0
  37. package/src/components/NewsBanner.vue +0 -1
  38. package/src/components/NotFound.cy.js +16 -0
  39. package/src/components/NotFound.vue +8 -2
  40. package/src/components/PricingTable.cy.js +91 -0
  41. package/src/components/PricingTable.vue +24 -7
  42. package/src/components/Tutorial.cy.js +85 -0
  43. package/src/components/Tutorial.vue +162 -0
  44. package/src/helpers.js +78 -0
  45. package/src/index.js +3 -0
  46. package/src/style.css +121 -4
  47. package/src/vitepressConfig.mjs +141 -95
@@ -34,16 +34,48 @@
34
34
  </nav>
35
35
  <ul class="list border large-space">
36
36
  <li v-for="item in theme.nav.filter((item) => !item.action)">
37
+ <details v-if="item.items">
38
+ <summary>
39
+ <span class="max">{{ t(item.text, theme.i18n) }}</span>
40
+ <i class="mdi mdi-chevron-down"></i>
41
+ </summary>
42
+ <ul class="list">
43
+ <MobileNavDropdown :items="item.items" />
44
+ </ul>
45
+ </details>
37
46
  <a
47
+ v-else
38
48
  data-ui="#mobile-menu"
39
49
  :href="withBase(item.link)"
40
50
  :target="item.target"
41
51
  :rel="item.rel"
42
52
  :class="item.action ? 'button large medium-elevate cta' : ''"
43
53
  >
44
- <span>{{ item.text }}</span>
54
+ <span>{{ t(item.text, theme.i18n) }}</span>
45
55
  </a>
46
56
  </li>
57
+ <li v-if="langs && langs.length > 1">
58
+ <details>
59
+ <summary>
60
+ <i class="mdi mdi-translate small"></i>
61
+ <span class="max">{{
62
+ langs.find((l) => l.active)?.label
63
+ }}</span>
64
+ <i class="mdi mdi-chevron-down small"></i>
65
+ </summary>
66
+ <ul class="list">
67
+ <li
68
+ v-for="lang in langs.filter((l) => !l.active)"
69
+ :key="lang.link"
70
+ :class="{ active: lang.active }"
71
+ >
72
+ <a :href="withBase(lang.link)" data-ui="#mobile-menu">
73
+ <span>{{ lang.label }}</span>
74
+ </a>
75
+ </li>
76
+ </ul>
77
+ </details>
78
+ </li>
47
79
  </ul>
48
80
  <div class="grid">
49
81
  <div class="s12">
@@ -59,7 +91,7 @@
59
91
  : 'primary-text'
60
92
  "
61
93
  >
62
- <span>{{ item.text }}</span>
94
+ <span>{{ t(item.text, theme.i18n) }}</span>
63
95
  <i
64
96
  v-if="
65
97
  item.action === 'primary' || item.action === 'secondary'
@@ -70,6 +102,20 @@
70
102
  </nav>
71
103
  </div>
72
104
  </div>
105
+ <div class="row center-align" v-if="theme.socialLinks">
106
+ <ul class="no-margin social-links">
107
+ <li v-for="link in theme.socialLinks">
108
+ <a
109
+ :href="link.link"
110
+ target="_blank"
111
+ rel="noopener"
112
+ class="button circle transparent"
113
+ >
114
+ <i :class="`mdi mdi-${link.icon}`"></i>
115
+ </a>
116
+ </li>
117
+ </ul>
118
+ </div>
73
119
  </dialog>
74
120
  </nav>
75
121
  <nav class="large-padding no-margin center-align row holder nav-desktop">
@@ -90,12 +136,25 @@
90
136
  <nav>
91
137
  <ul class="left-align no-margin">
92
138
  <li v-for="item in theme.nav.filter((i) => !i.action)">
139
+ <button
140
+ v-if="item.items"
141
+ class="button text"
142
+ :class="{ active: isActive(item, route.path) }"
143
+ >
144
+ <span>{{ t(item.text, theme.i18n) }}</span>
145
+ <i class="mdi mdi-chevron-down"></i>
146
+ <menu class="no-wrap surface-container-lowest">
147
+ <NavDropdown :items="item.items" />
148
+ </menu>
149
+ </button>
93
150
  <a
151
+ v-else
94
152
  class="button text"
153
+ :class="{ active: isActive(item, route.path) }"
95
154
  :href="withBase(item.link)"
96
155
  :target="item.target"
97
156
  :rel="item.rel"
98
- >{{ item.text }}</a
157
+ >{{ t(item.text, theme.i18n) }}</a
99
158
  >
100
159
  </li>
101
160
  </ul>
@@ -116,7 +175,7 @@
116
175
  :rel="item.rel"
117
176
  @click="trackEvent(['CTA', 'Click', 'Nav', item.text])"
118
177
  >
119
- <span>{{ item.text }}</span>
178
+ <span>{{ t(item.text, theme.i18n) }}</span>
120
179
  <i
121
180
  v-if="item.action === 'primary' || item.action === 'secondary'"
122
181
  class="mdi mdi-arrow-right"
@@ -124,16 +183,90 @@
124
183
  </a>
125
184
  </li>
126
185
  </ul>
186
+ <ul class="left-align no-margin social-links" v-if="theme.socialLinks">
187
+ <li v-for="link in theme.socialLinks">
188
+ <a
189
+ :href="link.link"
190
+ target="_blank"
191
+ rel="noopener"
192
+ class="button circle transparent"
193
+ >
194
+ <i :class="`mdi mdi-${link.icon}`"></i>
195
+ </a>
196
+ </li>
197
+ </ul>
198
+ <ul class="left-align no-margin" v-if="langs && langs.length > 1">
199
+ <li>
200
+ <button class="button text">
201
+ <i class="mdi mdi-translate small"></i>
202
+ <i class="mdi mdi-chevron-down small"></i>
203
+ <menu class="no-wrap surface-container-lowest">
204
+ <li
205
+ v-for="lang in langs"
206
+ :key="lang.link"
207
+ :class="{ active: lang.active }"
208
+ >
209
+ <a :href="withBase(lang.link)">
210
+ <span>{{ lang.label }}</span>
211
+ </a>
212
+ </li>
213
+ </menu>
214
+ </button>
215
+ </li>
216
+ </ul>
127
217
  </nav>
128
218
  </nav>
129
219
  </div>
130
220
  </template>
131
221
 
132
222
  <script setup>
133
- import { useData, withBase } from "vitepress";
134
- import { trackEvent } from "../helpers";
223
+ import { computed } from "vue";
224
+ import { useData, useRoute, withBase } from "vitepress";
225
+ import { t, trackEvent, isActive } from "../helpers";
226
+ import NavDropdown from "./NavDropdown.vue";
227
+ import MobileNavDropdown from "./MobileNavDropdown.vue";
228
+
229
+ const { site, theme, localeIndex } = useData();
230
+ const route = useRoute();
231
+
232
+ const langs = computed(() => {
233
+ const locales = site.value.locales;
234
+ if (!locales || Object.keys(locales).length <= 1) {
235
+ return [];
236
+ }
237
+ return Object.entries(locales).map(([id, locale]) => {
238
+ const currentPath = route.path;
239
+ const base = withBase("");
240
+ let pathRelativeToBase = currentPath;
241
+ if (base !== "/" && currentPath.startsWith(base)) {
242
+ pathRelativeToBase = currentPath.substring(base.length);
243
+ }
135
244
 
136
- const { site, theme } = useData();
245
+ const currentLocaleId = localeIndex.value;
246
+ const currentLocalePrefix =
247
+ currentLocaleId === "root" ? "" : `/${currentLocaleId}`;
248
+
249
+ let pathWithoutLocale = pathRelativeToBase;
250
+ if (
251
+ currentLocalePrefix &&
252
+ pathRelativeToBase.startsWith(currentLocalePrefix)
253
+ ) {
254
+ pathWithoutLocale = pathRelativeToBase.substring(
255
+ currentLocalePrefix.length,
256
+ );
257
+ }
258
+
259
+ const targetLocalePrefix = id === "root" ? "" : `/${id}`;
260
+ let link = `${targetLocalePrefix}${pathWithoutLocale}`.replace(/\/+/g, "/");
261
+ if (!link) link = "/";
262
+
263
+ return {
264
+ label: locale.label,
265
+ link: link,
266
+ active: id === currentLocaleId,
267
+ };
268
+ });
269
+ });
137
270
 
138
271
  if (!import.meta.env.SSR) {
139
272
  const scrollListener = () => {
@@ -151,7 +284,7 @@ if (!import.meta.env.SSR) {
151
284
 
152
285
  <style>
153
286
  .top-nav {
154
- position: sticky;
287
+ position: fixed;
155
288
  z-index: 100;
156
289
  top: 0;
157
290
  background: var(--surface);
@@ -237,4 +370,7 @@ nav.nav-desktop {
237
370
  display: flex;
238
371
  }
239
372
  }
373
+ details summary i.mdi-translate {
374
+ transform: rotate(0deg) !important;
375
+ }
240
376
  </style>
@@ -0,0 +1,71 @@
1
+ import NavDropdown from "./NavDropdown.vue";
2
+ import { __setRouteMock } from "../../cypress/support/mocks/vitepress";
3
+
4
+ describe("<NavDropdown />", () => {
5
+ const items = [
6
+ { text: "Home", link: "/" },
7
+ { text: "About", link: "/about" },
8
+ {
9
+ text: "Products",
10
+ items: [
11
+ { text: "Product A", link: "/products/a" },
12
+ { text: "Product B", link: "/products/b" },
13
+ ],
14
+ },
15
+ ];
16
+
17
+ it("renders list of items", () => {
18
+ cy.mount(NavDropdown, {
19
+ props: {
20
+ items,
21
+ },
22
+ });
23
+
24
+ cy.contains("Home").should("exist");
25
+ cy.contains("About").should("exist");
26
+ cy.contains("Products").should("exist");
27
+ });
28
+
29
+ it("renders nested dropdowns", () => {
30
+ cy.mount(NavDropdown, {
31
+ props: {
32
+ items,
33
+ },
34
+ });
35
+
36
+ cy.contains("Product A").should("exist");
37
+ cy.contains("Product B").should("exist");
38
+ });
39
+
40
+ it("marks active item", () => {
41
+ __setRouteMock({ path: "/about" });
42
+
43
+ cy.mount(NavDropdown, {
44
+ props: {
45
+ items,
46
+ },
47
+ });
48
+
49
+ cy.contains("a", "About").should("have.class", "active");
50
+ cy.contains("a", "Home").should("not.have.class", "active");
51
+ });
52
+
53
+ it("marks parent active if child is active", () => {
54
+ __setRouteMock({ path: "/products/a" });
55
+
56
+ cy.mount(NavDropdown, {
57
+ props: {
58
+ items,
59
+ },
60
+ });
61
+
62
+ // The text 'Products' isn't an anchor tag in the dropdown logic when it has items,
63
+ // it depends on how NavDropdown relies on recursion.
64
+ // In NavDropdown.vue:
65
+ // <li v-if="item.items && item.text"> ... <a ... :class="{ active: isActive(item, route.path) }">
66
+
67
+ // So 'Products' (parent) should be active
68
+ // We target the closest 'a' or element with text Products that has class active
69
+ cy.contains("span", "Products").parent("a").should("have.class", "active");
70
+ });
71
+ });
@@ -0,0 +1,62 @@
1
+ <template>
2
+ <template v-for="item in items">
3
+ <li v-if="item.items && item.text">
4
+ <a
5
+ v-if="item.link"
6
+ :href="withBase(item.link)"
7
+ :target="item.target"
8
+ :rel="item.rel"
9
+ class="row max padding"
10
+ :class="{ active: isActive(item, route.path) }"
11
+ >
12
+ <span class="max">{{ t(item.text, theme.i18n) }}</span>
13
+ <i class="mdi mdi-chevron-right"></i>
14
+ </a>
15
+ <a
16
+ v-else
17
+ class="row max padding"
18
+ :class="{ active: isActive(item, route.path) }"
19
+ >
20
+ <span class="max">{{ t(item.text, theme.i18n) }}</span>
21
+ <i class="mdi mdi-chevron-right"></i>
22
+ </a>
23
+ <menu class="no-wrap surface-container-lowest">
24
+ <NavDropdown :items="item.items" />
25
+ </menu>
26
+ </li>
27
+ <NavDropdown v-else-if="item.items" :items="item.items" />
28
+ <li v-else>
29
+ <a
30
+ v-if="item.link"
31
+ :href="withBase(item.link)"
32
+ :target="item.target"
33
+ :rel="item.rel"
34
+ class="row"
35
+ :class="{ active: isActive(item, route.path) }"
36
+ >
37
+ <span>{{ t(item.text, theme.i18n) }}</span>
38
+ </a>
39
+ <span
40
+ v-else
41
+ class="row"
42
+ :class="{ active: isActive(item, route.path) }"
43
+ >{{ t(item.text, theme.i18n) }}</span
44
+ >
45
+ </li>
46
+ </template>
47
+ </template>
48
+
49
+ <script setup>
50
+ import { withBase, useRoute, useData } from "vitepress";
51
+ import { isActive, t } from "../helpers";
52
+
53
+ defineProps({
54
+ items: {
55
+ type: Array,
56
+ required: true,
57
+ },
58
+ });
59
+
60
+ const route = useRoute();
61
+ const { theme } = useData();
62
+ </script>
@@ -0,0 +1,23 @@
1
+ import NewsBanner from "./NewsBanner.vue";
2
+
3
+ describe("<NewsBanner />", () => {
4
+ it("renders content passed via prop", () => {
5
+ const content = "<strong>Important News:</strong> Something happened!";
6
+ cy.mount(NewsBanner, {
7
+ props: { content },
8
+ });
9
+
10
+ cy.get(".news-banner").should(
11
+ "contain.html",
12
+ "<strong>Important News:</strong>",
13
+ );
14
+ cy.contains("Something happened!").should("be.visible");
15
+ });
16
+
17
+ it("applies correct classes", () => {
18
+ cy.mount(NewsBanner, {
19
+ props: { content: "Test" },
20
+ });
21
+ cy.get(".news-banner").should("have.class", "primary");
22
+ });
23
+ });
@@ -6,7 +6,6 @@
6
6
  </template>
7
7
 
8
8
  <script setup>
9
- import { defineProps } from "vue";
10
9
  const props = defineProps(["content"]);
11
10
  </script>
12
11
 
@@ -0,0 +1,16 @@
1
+ import NotFound from "./NotFound.vue";
2
+
3
+ describe("<NotFound />", () => {
4
+ it("renders 404 title and message", () => {
5
+ cy.mount(NotFound);
6
+ cy.contains("h1", "404").should("be.visible");
7
+ cy.contains("p", "The page you requested was not found").should(
8
+ "be.visible",
9
+ );
10
+ });
11
+
12
+ it("renders back to home link", () => {
13
+ cy.mount(NotFound);
14
+ cy.get('a[href="/"]').should("contain.text", "Back to home");
15
+ });
16
+ });
@@ -1,14 +1,20 @@
1
1
  <template>
2
2
  <div class="VPPage">
3
3
  <h1>404</h1>
4
- <p>The page you requested was not found.</p>
4
+ <p>{{ t("The page you requested was not found.", theme.i18n) }}</p>
5
5
  <div class="small-space"></div>
6
6
  <nav>
7
7
  <a class="button responsive-mobile" href="/">
8
8
  <i class="mdi mdi-arrow-left"></i>
9
- <span>Back to home</span>
9
+ <span>{{ t("Back to home", theme.i18n) }}</span>
10
10
  </a>
11
11
  </nav>
12
12
  <div class="large-space"></div>
13
13
  </div>
14
14
  </template>
15
+
16
+ <script setup>
17
+ import { useData } from "vitepress";
18
+ import { t } from "../helpers";
19
+ const { theme } = useData();
20
+ </script>
@@ -0,0 +1,91 @@
1
+ import PricingTable from "./PricingTable.vue";
2
+
3
+ // Stub ClientOnly to render children immediately
4
+ const ClientOnly = {
5
+ template: "<div><slot></slot></div>",
6
+ };
7
+
8
+ describe("<PricingTable />", () => {
9
+ const plans = [
10
+ {
11
+ name: "Basic",
12
+ price: 10,
13
+ id: "basic",
14
+ details: { Storage: { text: "1GB" } },
15
+ },
16
+ {
17
+ name: "Pro",
18
+ price: 20,
19
+ id: "pro",
20
+ details: { Storage: { text: "10GB" } },
21
+ },
22
+ ];
23
+ const options = [
24
+ {
25
+ id: "opt1",
26
+ label: "Extra Support",
27
+ prices: { basic: 5, pro: 10 },
28
+ checked: false,
29
+ },
30
+ ];
31
+ const details = [
32
+ {
33
+ title: "Features",
34
+ plans: [
35
+ { name: "Storage", details: { Storage: "1GB" } },
36
+ { name: "Storage", details: { Storage: "10GB" } },
37
+ ],
38
+ },
39
+ ];
40
+
41
+ const config = {
42
+ title: "Pricing",
43
+ plans,
44
+ options,
45
+ details,
46
+ contactLink: "mailto:sales@example.com",
47
+ };
48
+
49
+ it("renders the table with plans", () => {
50
+ cy.mount(PricingTable, {
51
+ props: { config },
52
+ global: {
53
+ components: { ClientOnly },
54
+ },
55
+ });
56
+
57
+ cy.contains("Pricing").should("exist");
58
+ cy.contains("Basic").should("exist");
59
+ cy.contains("Pro").should("exist");
60
+ });
61
+
62
+ it("renders options checkboxes", () => {
63
+ cy.mount(PricingTable, {
64
+ props: { config },
65
+ global: {
66
+ components: { ClientOnly },
67
+ },
68
+ });
69
+
70
+ cy.contains("+ Extra Support").should("exist");
71
+ cy.get("input#opt1").should("exist");
72
+ });
73
+
74
+ it("renders details rows", () => {
75
+ cy.mount(PricingTable, {
76
+ props: { config },
77
+ global: {
78
+ components: { ClientOnly },
79
+ },
80
+ });
81
+
82
+ cy.contains("Storage").should("exist");
83
+ cy.contains("1GB").should("exist");
84
+ cy.contains("10GB").should("exist");
85
+ });
86
+
87
+ // Note: Testing actual calculation logic inside the component might require
88
+ // asserting on the DOM price elements if they were exposed/visible.
89
+ // The component seems to update prices internally but without clear price display in the simplified view
90
+ // we extracted, we check for presence mostly.
91
+ });
@@ -48,7 +48,7 @@
48
48
  :href="addPlanConfig(contactLink, plan)"
49
49
  class="button responsive bold margin-top-1 margin-bottom-2"
50
50
  >
51
- Contact us
51
+ {{ t("Contact us", theme.i18n) }}
52
52
  </a>
53
53
  </ClientOnly>
54
54
  </div>
@@ -58,7 +58,9 @@
58
58
  <!-- Main Plans Table -->
59
59
  <div class="wrapper" :style="gridStyle">
60
60
  <div class="cell orig-col-1 l top-margin">
61
- <h6 v-if="!localDetails.length" class="bold small">Plans:</h6>
61
+ <h6 v-if="!localDetails.length" class="bold small">
62
+ {{ t("Plans:", theme.i18n) }}
63
+ </h6>
62
64
  </div>
63
65
  <div
64
66
  v-for="(plan, index) in localPlans"
@@ -69,7 +71,9 @@
69
71
  <h6 class="primary-text bold top-margin">{{ plan.name }}</h6>
70
72
  </div>
71
73
 
72
- <div class="cell orig-col-1 l">Price (per month):</div>
74
+ <div class="cell orig-col-1 l">
75
+ {{ t("Price (per month):", theme.i18n) }}
76
+ </div>
73
77
  <div
74
78
  v-for="(plan, index) in localPlans"
75
79
  :key="'price-' + index"
@@ -142,7 +146,7 @@
142
146
  :class="`surface-container-low cell bottom-cell orig-col-${index + 2} center-align`"
143
147
  >
144
148
  <a v-if="plan.link" :href="plan.link"
145
- >See all features
149
+ >{{ t("See all features", theme.i18n) }}
146
150
  <svg
147
151
  style="width: 16px; height: 16px"
148
152
  xmlns="http://www.w3.org/2000/svg"
@@ -168,7 +172,7 @@
168
172
  v-if="showSales"
169
173
  :href="addPlanConfig(contactLink, plan)"
170
174
  class="button responsive bold"
171
- >Contact us</a
175
+ >{{ t("Contact us", theme.i18n) }}</a
172
176
  >
173
177
  </ClientOnly>
174
178
  </div>
@@ -185,7 +189,7 @@
185
189
 
186
190
  <div class="wrapper" :style="secondaryGridStyle">
187
191
  <div class="cell cell orig-col-1 l">
188
- <div class="">Additional price:</div>
192
+ <div class="">{{ t("Additional price:", theme.i18n) }}</div>
189
193
  </div>
190
194
  <div
191
195
  v-for="(plan, detailPlanIndex) in detail.plans"
@@ -231,15 +235,28 @@
231
235
  </template>
232
236
 
233
237
  <p class="small grey-text m-4" v-if="showVAT">
234
- * All prices are given excluding VAT. Prices are valid until
238
+ *
239
+ {{
240
+ t(
241
+ "All prices are given excluding VAT. Prices are valid until",
242
+ theme.i18n,
243
+ )
244
+ }}
235
245
  {{ showVAT }}.
236
246
  </p>
237
247
  </div>
238
248
  </template>
239
249
 
240
250
  <script>
251
+ import { t } from "../helpers.js";
252
+ import { useData } from "vitepress";
253
+
241
254
  export default {
242
255
  name: "PricingTable",
256
+ setup() {
257
+ const { theme } = useData();
258
+ return { theme, t };
259
+ },
243
260
  props: {
244
261
  config: {
245
262
  type: Object,