@eox/pages-theme-eox 2.0.0-alpha.1 → 2.0.0-alpha.10

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 +4 -1
  2. package/README.md +75 -11
  3. package/cypress/support/mocks/authors.data.js +14 -0
  4. package/cypress/support/mocks/helpers.js +65 -4
  5. package/cypress.config.js +9 -0
  6. package/package.json +3 -1
  7. package/src/Layout.vue +14 -3
  8. package/src/components/BlogList.vue +74 -55
  9. package/src/components/BlogPost.vue +46 -24
  10. package/src/components/CTASection.vue +18 -4
  11. package/src/components/FeatureCard/Index.vue +113 -26
  12. package/src/components/FeatureCard/Poster.vue +25 -3
  13. package/src/components/FeatureSection.cy.js +16 -0
  14. package/src/components/FeatureSection.vue +3 -8
  15. package/src/components/FeaturesGallery.vue +34 -2
  16. package/src/components/Footer.cy.js +52 -2
  17. package/src/components/Footer.vue +17 -5
  18. package/src/components/HeroSection.cy.js +20 -0
  19. package/src/components/HeroSection.vue +139 -24
  20. package/src/components/ImageCarousel.vue +11 -2
  21. package/src/components/ImageElement.vue +16 -4
  22. package/src/components/ImageGallery.vue +29 -4
  23. package/src/components/LogoSection.vue +26 -3
  24. package/src/components/MobileNavDropdown.vue +13 -3
  25. package/src/components/NavBar.cy.js +15 -0
  26. package/src/components/NavBar.vue +149 -21
  27. package/src/components/NavDropdown.vue +12 -2
  28. package/src/components/OpenPositions.vue +292 -0
  29. package/src/components/TagList.vue +32 -14
  30. package/src/components/TeamPage.vue +34 -20
  31. package/src/components/TestimonialsSection.cy.js +2 -5
  32. package/src/components/TestimonialsSection.vue +119 -16
  33. package/src/components/TextSection.cy.js +115 -0
  34. package/src/components/TextSection.vue +396 -0
  35. package/src/helpers.js +182 -37
  36. package/src/helpers.test.js +181 -0
  37. package/src/index.js +4 -0
  38. package/src/pages/author/[author].md +1 -0
  39. package/src/pages/author/[author].paths.js +2 -2
  40. package/src/pages/blog.md +1 -0
  41. package/src/pages/tag/[tag].md +1 -0
  42. package/src/pages/tag.md +1 -0
  43. package/src/pages/team.md +2 -1
  44. package/src/posts.data.js +39 -1
  45. package/src/style.css +232 -11
  46. package/src/tagUtils.js +5 -5
  47. package/src/vitepressConfig.mjs +6 -3
@@ -0,0 +1,292 @@
1
+ <template>
2
+ <div class="open-positions-container">
3
+ <h2 v-if="title" id="open-positions" class="open-positions-title">
4
+ {{ title }}
5
+ </h2>
6
+
7
+ <!-- List of Open Positions if available -->
8
+ <div v-if="positions && positions.length > 0" class="positions-list">
9
+ <div
10
+ v-for="(position, index) in positions"
11
+ :key="index"
12
+ class="position-item"
13
+ >
14
+ <h3 class="position-title">{{ position.title }}</h3>
15
+
16
+ <div v-if="position.type || position.location" class="position-meta">
17
+ <span v-if="position.type" class="meta-item">
18
+ <i class="mdi mdi-calendar-blank-outline"></i>
19
+ {{ position.type }}
20
+ </span>
21
+ <span v-if="position.location" class="meta-item">
22
+ <i class="mdi mdi-map-marker-outline"></i>
23
+ {{ position.location }}
24
+ </span>
25
+ </div>
26
+
27
+ <p class="position-description">
28
+ {{ position.description || position.content }}
29
+ </p>
30
+
31
+ <a
32
+ :href="resolveApplyLink(position)"
33
+ :target="
34
+ isExternal(resolveApplyLink(position)) ? '_blank' : undefined
35
+ "
36
+ :rel="
37
+ isExternal(resolveApplyLink(position))
38
+ ? 'noopener noreferrer'
39
+ : undefined
40
+ "
41
+ class="position-apply"
42
+ >
43
+ <span>{{ position.linkText || "Apply for this position" }}</span>
44
+ <i class="mdi mdi-arrow-right"></i>
45
+ </a>
46
+ </div>
47
+ </div>
48
+
49
+ <!-- Fallback when no positions are open -->
50
+ <div v-else class="empty-positions">
51
+ <slot name="empty">
52
+ <p class="empty-title">
53
+ {{ emptyTitle }}
54
+ </p>
55
+ <p class="empty-description">
56
+ {{ emptyText }}
57
+ </p>
58
+ <p v-if="showDevjobs" class="empty-devjobs">
59
+ Also feel free to explore our Team page on
60
+ <a :href="devjobsGerman" target="_blank" rel="noopener noreferrer"
61
+ >Devjobs.at (German)</a
62
+ >
63
+ or in
64
+ <a :href="devjobsEnglish" target="_blank" rel="noopener noreferrer"
65
+ >English</a
66
+ >.
67
+ </p>
68
+ </slot>
69
+ </div>
70
+
71
+ <!-- Always display "Send us your CV" button -->
72
+ <div v-if="showCvButton" class="cv-action">
73
+ <a :href="`mailto:${email}`" class="cv-button">
74
+ <span>{{ cvButtonText }}</span>
75
+ <i class="mdi mdi-email-outline"></i>
76
+ </a>
77
+ </div>
78
+ </div>
79
+ </template>
80
+
81
+ <script setup>
82
+ import { withBase } from "vitepress";
83
+
84
+ const props = defineProps({
85
+ title: {
86
+ type: String,
87
+ default: "",
88
+ },
89
+ positions: {
90
+ type: Array,
91
+ default: () => [],
92
+ },
93
+ email: {
94
+ type: String,
95
+ default: "office@eox.at",
96
+ },
97
+ cvButtonText: {
98
+ type: String,
99
+ default: "Send us your CV",
100
+ },
101
+ showCvButton: {
102
+ type: Boolean,
103
+ default: true,
104
+ },
105
+ showDevjobs: {
106
+ type: Boolean,
107
+ default: true,
108
+ },
109
+ devjobsGerman: {
110
+ type: String,
111
+ default: "https://devjobs.at/team/eox",
112
+ },
113
+ devjobsEnglish: {
114
+ type: String,
115
+ default: "https://en.devjobs.at/team/eox",
116
+ },
117
+ emptyTitle: {
118
+ type: String,
119
+ default: "Great to see you are interested in joining us!",
120
+ },
121
+ emptyText: {
122
+ type: String,
123
+ default:
124
+ "Currently, we are not looking to fill a particular position, but you're welcome to send us your CV and tell us why you'd be a good fit for our team.",
125
+ },
126
+ });
127
+
128
+ function resolveApplyLink(position) {
129
+ if (position.link) {
130
+ if (
131
+ position.link.startsWith("http://") ||
132
+ position.link.startsWith("https://") ||
133
+ position.link.startsWith("mailto:")
134
+ ) {
135
+ return position.link;
136
+ }
137
+ return withBase(
138
+ position.link.startsWith("/") ? position.link : "/" + position.link,
139
+ );
140
+ }
141
+ return `mailto:${props.email}?subject=Application for ${encodeURIComponent(position.title || "Open Position")}`;
142
+ }
143
+
144
+ function isExternal(url) {
145
+ return (
146
+ typeof url === "string" &&
147
+ (url.startsWith("http://") || url.startsWith("https://"))
148
+ );
149
+ }
150
+ </script>
151
+
152
+ <style scoped>
153
+ .open-positions-container {
154
+ margin-top: 1.5rem;
155
+ margin-bottom: 2.5rem;
156
+ }
157
+
158
+ .open-positions-title {
159
+ margin-bottom: 1.5rem !important;
160
+ }
161
+
162
+ .positions-list {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 2.5rem;
166
+ margin-bottom: 2rem;
167
+ }
168
+
169
+ .position-item {
170
+ display: flex;
171
+ flex-direction: column;
172
+ align-items: flex-start;
173
+ }
174
+
175
+ .position-item:not(:last-child) {
176
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
177
+ padding-bottom: 2rem;
178
+ }
179
+
180
+ .position-title {
181
+ font-size: 1.35rem !important;
182
+ font-weight: 700 !important;
183
+ color: var(--on-surface, #0f172a) !important;
184
+ margin: 0 0 0.5rem 0 !important;
185
+ border-top: none !important;
186
+ padding-top: 0 !important;
187
+ line-height: 1.3 !important;
188
+ }
189
+
190
+ .position-meta {
191
+ display: flex;
192
+ flex-wrap: wrap;
193
+ align-items: center;
194
+ gap: 1.5rem;
195
+ color: var(--on-surface-variant, #475569);
196
+ font-size: 0.95rem;
197
+ margin-bottom: 1rem;
198
+ }
199
+
200
+ .meta-item {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ gap: 0.35rem;
204
+ }
205
+
206
+ .meta-item i {
207
+ font-size: 1.15rem;
208
+ line-height: 1;
209
+ }
210
+
211
+ .position-description {
212
+ font-size: 1rem !important;
213
+ line-height: 1.6 !important;
214
+ color: var(--on-surface-variant, #334155) !important;
215
+ margin: 0 0 1.25rem 0 !important;
216
+ }
217
+
218
+ .position-apply {
219
+ display: inline-flex;
220
+ align-items: center;
221
+ gap: 0.4rem;
222
+ font-weight: 700;
223
+ color: var(--primary, #004170) !important;
224
+ text-decoration: none !important;
225
+ font-size: 1rem;
226
+ transition:
227
+ gap 0.2s ease,
228
+ color 0.2s ease;
229
+ }
230
+
231
+ .position-apply:hover {
232
+ gap: 0.65rem;
233
+ text-decoration: underline !important;
234
+ }
235
+
236
+ .position-apply i {
237
+ font-size: 1.1rem;
238
+ transition: transform 0.2s ease;
239
+ }
240
+
241
+ .empty-positions {
242
+ margin-bottom: 1.5rem;
243
+ }
244
+
245
+ .empty-title {
246
+ font-weight: 700 !important;
247
+ font-size: 1.15rem !important;
248
+ color: var(--on-surface, #0f172a) !important;
249
+ margin: 0 0 0.75rem 0 !important;
250
+ }
251
+
252
+ .empty-description,
253
+ .empty-devjobs {
254
+ font-size: 1rem !important;
255
+ line-height: 1.6 !important;
256
+ color: var(--on-surface-variant, #334155) !important;
257
+ margin: 0 0 1rem 0 !important;
258
+ }
259
+
260
+ .cv-action {
261
+ margin-top: 1.5rem;
262
+ }
263
+
264
+ .cv-button {
265
+ display: inline-flex;
266
+ align-items: center;
267
+ gap: 0.6rem;
268
+ background-color: var(--primary, #004170);
269
+ color: #ffffff !important;
270
+ font-weight: 600;
271
+ font-size: 0.95rem;
272
+ padding: 0.7rem 1.5rem;
273
+ border-radius: 9999px;
274
+ text-decoration: none !important;
275
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
276
+ transition:
277
+ transform 0.2s ease,
278
+ box-shadow 0.2s ease,
279
+ background-color 0.2s ease;
280
+ }
281
+
282
+ .cv-button:hover {
283
+ transform: translateY(-2px);
284
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
285
+ background-color: color-mix(in srgb, var(--primary, #004170) 88%, black);
286
+ }
287
+
288
+ .cv-button i {
289
+ font-size: 1.15rem;
290
+ line-height: 1;
291
+ }
292
+ </style>
@@ -1,9 +1,8 @@
1
1
  <template>
2
- <div class="tags-page">
3
- <header class="tags-header">
4
- <h1 class="tags-title">
5
- Blog Tags
6
- </h1>
2
+ <div class="tags-page" :class="{ 'has-hero': Boolean(frontmatter.hero) }">
3
+ <!-- Header (shown only when hero is not present) -->
4
+ <header v-if="!frontmatter.hero" class="tags-header">
5
+ <h1 class="tags-title">Blog Tags</h1>
7
6
  <p class="tags-subtitle">
8
7
  Explore our blog posts by topics and technology pillars.
9
8
  </p>
@@ -15,7 +14,7 @@
15
14
  <a
16
15
  v-for="tag in allTagsList"
17
16
  :key="tag.name"
18
- :href="`/tag/${tag.name}/`"
17
+ :href="withBase(`/tag/${tag.name}/`)"
19
18
  :style="getTagStyle(tag.name)"
20
19
  class="tag-item"
21
20
  >
@@ -60,7 +59,9 @@
60
59
  </ul>
61
60
  <p class="legend-note">
62
61
  For more detailed information on our technology solutions, visit the
63
- <a href="/software-products" class="legend-link">Solutions & Services</a>
62
+ <a :href="withBase('/solutions')" class="legend-link"
63
+ >Solutions & Services</a
64
+ >
64
65
  page.
65
66
  </p>
66
67
  </footer>
@@ -69,9 +70,12 @@
69
70
 
70
71
  <script setup>
71
72
  import { computed } from "vue";
73
+ import { useData, withBase } from "vitepress";
72
74
  import { data as posts } from "../posts.data.js";
73
75
  import { getTagColor, getTagStyle } from "../tagUtils.js";
74
76
 
77
+ const { frontmatter } = useData();
78
+
75
79
  const allTagsList = computed(() => {
76
80
  // Aggregate all tag occurrences
77
81
  const tagCounts = {};
@@ -110,6 +114,10 @@ const allTagsList = computed(() => {
110
114
  box-sizing: border-box;
111
115
  }
112
116
 
117
+ .tags-page.has-hero {
118
+ padding-top: 2rem;
119
+ }
120
+
113
121
  @media (min-width: 640px) {
114
122
  .tags-page {
115
123
  padding-left: 1.5rem;
@@ -117,6 +125,9 @@ const allTagsList = computed(() => {
117
125
  padding-top: calc(var(--vp-nav-height) + 40px);
118
126
  padding-bottom: 64px;
119
127
  }
128
+ .tags-page.has-hero {
129
+ padding-top: 2.5rem;
130
+ }
120
131
  }
121
132
 
122
133
  @media (min-width: 960px) {
@@ -126,6 +137,9 @@ const allTagsList = computed(() => {
126
137
  padding-top: calc(var(--vp-nav-height) + 58px);
127
138
  padding-bottom: 96px;
128
139
  }
140
+ .tags-page.has-hero {
141
+ padding-top: 3rem;
142
+ }
129
143
  }
130
144
 
131
145
  /* Page Header */
@@ -133,10 +147,7 @@ const allTagsList = computed(() => {
133
147
  margin-bottom: 2rem;
134
148
  border-bottom: 1px solid #f1f5f9; /* slate-100 */
135
149
  padding-bottom: 1.5rem;
136
- text-align: center;
137
- max-width: 42rem; /* 2xl equivalent */
138
- margin-left: auto;
139
- margin-right: auto;
150
+ text-align: left;
140
151
  }
141
152
 
142
153
  .tags-title {
@@ -180,7 +191,9 @@ const allTagsList = computed(() => {
180
191
  padding: 2.5rem 2rem;
181
192
  border-radius: 1rem;
182
193
  border: 1px solid rgba(226, 232, 240, 0.5); /* border-slate-100/50 */
183
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.05); /* shadow-sm */
194
+ box-shadow:
195
+ 0 1px 3px 0 rgba(0, 0, 0, 0.05),
196
+ 0 1px 2px -1px rgba(0, 0, 0, 0.05); /* shadow-sm */
184
197
  margin-bottom: 3rem;
185
198
  }
186
199
 
@@ -202,14 +215,19 @@ const allTagsList = computed(() => {
202
215
  border-radius: 4px; /* small rounding to distinguish from full-rounded buttons */
203
216
  text-decoration: none !important;
204
217
  line-height: 1.2;
205
- transition: opacity 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
218
+ transition:
219
+ opacity 0.2s ease,
220
+ transform 0.15s ease,
221
+ box-shadow 0.2s ease;
206
222
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
207
223
  }
208
224
 
209
225
  .tag-item:hover {
210
226
  opacity: 0.85;
211
227
  transform: translateY(-1px);
212
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
228
+ box-shadow:
229
+ 0 4px 6px -1px rgba(0, 0, 0, 0.05),
230
+ 0 2px 4px -2px rgba(0, 0, 0, 0.05);
213
231
  }
214
232
 
215
233
  .tag-count {
@@ -1,7 +1,7 @@
1
1
  <template>
2
- <div class="team-page">
3
- <!-- Header -->
4
- <header class="team-header">
2
+ <div class="team-page" :class="{ 'has-hero': Boolean(frontmatter.hero) }">
3
+ <!-- Header (shown only when hero is not present) -->
4
+ <header v-if="!frontmatter.hero" class="team-header">
5
5
  <h1 class="team-title">
6
6
  {{ frontmatter.title || "Our Team" }}
7
7
  </h1>
@@ -15,23 +15,31 @@
15
15
  <section class="team-photo-section">
16
16
  <img
17
17
  :src="withBase('/images/team/team.jpg')"
18
- alt="EOX Team Photo"
18
+ alt="Our Team Photo"
19
19
  class="team-photo"
20
20
  />
21
21
  </section>
22
22
 
23
- <!-- Team Grid - max 3 columns on large screens -->
23
+ <!-- Team Grid - up to 4 columns on large screens -->
24
24
  <div class="team-grid">
25
25
  <FeatureCard
26
26
  v-for="member in sortedTeamMembers"
27
27
  :key="member.id"
28
28
  :title="member.name"
29
- :image="member.image ? withBase(member.image) : withBase('/images/team/new_member.jpg')"
30
- :link="memberHasPosts(member.id) ? {
31
- text: 'View blog posts',
32
- href: `/author/${member.id}/`,
33
- target: '_self'
34
- } : undefined"
29
+ :image="
30
+ member.image
31
+ ? withBase(member.image)
32
+ : withBase('/images/team/new_member.jpg')
33
+ "
34
+ :link="
35
+ memberHasPosts(member.id)
36
+ ? {
37
+ text: 'View blog posts',
38
+ href: `/author/${member.id}/`,
39
+ target: '_self',
40
+ }
41
+ : undefined
42
+ "
35
43
  variant="cover"
36
44
  class="bg-surface-container-low team-member-card"
37
45
  >
@@ -43,11 +51,7 @@
43
51
  <span class="member-divider"></span>
44
52
 
45
53
  <!-- Description / Bio -->
46
- <span
47
- v-if="member.bio"
48
- class="member-bio"
49
- v-html="member.bio"
50
- ></span>
54
+ <span v-if="member.bio" class="member-bio" v-html="member.bio"></span>
51
55
 
52
56
  <!-- Social Links row at the bottom of slot -->
53
57
  <span class="member-footer">
@@ -125,14 +129,18 @@ const memberHasPosts = (memberId) => {
125
129
  max-width: 1200px !important;
126
130
  margin-left: auto !important;
127
131
  margin-right: auto !important;
128
- padding-left: 1rem !important;
129
- padding-right: 1rem !important;
132
+ padding-left: 1.5rem !important;
133
+ padding-right: 1.5rem !important;
130
134
  box-sizing: border-box !important;
131
135
  padding-top: calc(var(--vp-nav-height) + 28px) !important;
132
136
  padding-bottom: 48px !important;
133
137
  word-break: break-word;
134
138
  }
135
139
 
140
+ .team-page.has-hero {
141
+ padding-top: 2rem !important;
142
+ }
143
+
136
144
  @media (min-width: 640px) {
137
145
  .team-page {
138
146
  padding-left: 1.5rem !important;
@@ -140,15 +148,21 @@ const memberHasPosts = (memberId) => {
140
148
  padding-top: calc(var(--vp-nav-height) + 40px) !important;
141
149
  padding-bottom: 64px !important;
142
150
  }
151
+ .team-page.has-hero {
152
+ padding-top: 4.5rem !important;
153
+ }
143
154
  }
144
155
 
145
156
  @media (min-width: 960px) {
146
157
  .team-page {
147
- padding-left: 2rem !important;
148
- padding-right: 2rem !important;
158
+ padding-left: 1.5rem !important;
159
+ padding-right: 1.5rem !important;
149
160
  padding-top: calc(var(--vp-nav-height) + 58px) !important;
150
161
  padding-bottom: 96px !important;
151
162
  }
163
+ .team-page.has-hero {
164
+ padding-top: 8rem !important;
165
+ }
152
166
  }
153
167
 
154
168
  /* Page Header */
@@ -5,10 +5,7 @@ describe("<TestimonialsSection />", () => {
5
5
  cy.mount(TestimonialsSection);
6
6
 
7
7
  // Verify title
8
- cy.get(".section-display-title").should(
9
- "contain",
10
- "What our customers are saying",
11
- );
8
+ cy.get(".headline h2").should("contain", "What our customers are saying");
12
9
 
13
10
  // Verify first testimonial quote
14
11
  cy.get(".quote-text").should(
@@ -61,7 +58,7 @@ describe("<TestimonialsSection />", () => {
61
58
  });
62
59
 
63
60
  // Check title
64
- cy.get(".section-display-title").should("contain", customTitle);
61
+ cy.get(".headline h2").should("contain", customTitle);
65
62
 
66
63
  // Check first custom testimonial
67
64
  cy.get(".quote-text").should("contain", "This is a custom quote");