@xenterprises/nuxt-x-marketing 1.2.2 → 1.3.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.
package/app/app.vue CHANGED
@@ -1,17 +1,9 @@
1
1
  <template>
2
2
  <div class="min-h-screen flex flex-col">
3
- <!-- Navbar -->
4
- <XMarkNavbar
5
- :links="navLinks"
6
- :logo-light="logoLight"
7
- :logo-dark="logoDark"
8
- logo-alt="Company"
9
- >
10
- <template #actions>
11
- <UButton variant="ghost" color="neutral">Sign In</UButton>
12
- <UButton color="primary">Get Started</UButton>
13
- </template>
14
- </XMarkNavbar>
3
+ <!-- Default navbar — reads appConfig.xMarketing.header (logo, nav.links,
4
+ nav.buttons). Opt out with xMarketing.header.active: false or by
5
+ shipping your own app/app.vue. -->
6
+ <XHeaderNav v-if="headerActive" />
15
7
 
16
8
  <!-- Main Content -->
17
9
  <main class="flex-grow">
@@ -23,30 +15,20 @@
23
15
  </UApp>
24
16
  </main>
25
17
 
26
- <!-- Newsletter Section -->
27
- <XMarkSection bg="subtle" padding="lg">
28
- <XMarkNewsletter
29
- title="Stay in the loop"
30
- description="Get the latest updates, tips, and insights delivered to your inbox."
31
- variant="default"
32
- @submit="handleNewsletterSubmit"
33
- />
34
- </XMarkSection>
35
-
36
- <!-- Footer -->
37
- <XMarkFooter
38
- :logo="logoDark"
39
- logo-alt="Company"
40
- description="Building the future of modern software."
41
- :social="socialLinks"
42
- :columns="footerColumns"
43
- :legal-links="legalLinks"
44
- />
18
+ <!-- Default footer — reads appConfig.xMarketing.footer (logo, body,
19
+ socials, columns, bg). Shows the newsletter form when
20
+ xMarketing.config.emailMarketingNewsletters.organizationId is set.
21
+ Opt out with xMarketing.footer.active: false. -->
22
+ <XFooter v-if="footerActive" />
45
23
 
46
24
  <!-- Cookie consent banner — auto-shows on first visit and fires
47
25
  any tracking scripts configured in app.config.xMarketing.tracking
48
- once the visitor accepts. -->
49
- <XMarkPrivacyCookieConsent policy-url="/cookies" privacy-url="/privacy" />
26
+ once the visitor accepts. Opt out with xMarketing.consent.active: false. -->
27
+ <XMarkPrivacyCookieConsent
28
+ v-if="consentActive"
29
+ policy-url="/cookies"
30
+ privacy-url="/privacy"
31
+ />
50
32
  </div>
51
33
  </template>
52
34
  <script setup>
@@ -54,79 +36,28 @@ import { onMounted } from "vue";
54
36
  import markerSDK from "@marker.io/browser";
55
37
 
56
38
  const appConfig = useAppConfig();
39
+ const xMarketing = appConfig?.xMarketing;
57
40
 
58
- useSeoMeta({
59
- titleTemplate: `%s`,
60
- });
61
-
62
- // Logo configuration (replace with actual logos)
63
- const logoLight = "/logo-white.svg";
64
- const logoDark = "/logo-dark.svg";
41
+ const headerActive = computed(() => xMarketing?.header?.active !== false);
42
+ const footerActive = computed(() => xMarketing?.footer?.active !== false);
43
+ const consentActive = computed(() => xMarketing?.consent?.active !== false);
65
44
 
66
- // Navigation links
67
- const navLinks = [
68
- { label: "Features", to: "/#features" },
69
- { label: "Pricing", to: "/pricing" },
70
- { label: "Blog", to: "/blog" },
71
- { label: "About", to: "/about" },
72
- ];
45
+ // xMarketing.name/url feed the default SEO wiring: when `name` is set the
46
+ // title template becomes "<page> | <name>" and og:site_name is emitted;
47
+ // the shipped home page also reads `url` for og:url.
48
+ const siteName = computed(() => xMarketing?.name);
73
49
 
74
- // Social links
75
- const socialLinks = [
76
- { name: "Twitter", href: "https://twitter.com", icon: "i-lucide-twitter" },
77
- { name: "GitHub", href: "https://github.com", icon: "i-lucide-github" },
78
- { name: "LinkedIn", href: "https://linkedin.com", icon: "i-lucide-linkedin" },
79
- ];
80
-
81
- // Footer columns
82
- const footerColumns = [
83
- {
84
- title: "Product",
85
- links: [
86
- { label: "Features", to: "/#features" },
87
- { label: "Pricing", to: "/pricing" },
88
- { label: "Integrations", to: "/integrations" },
89
- { label: "Changelog", to: "/changelog" },
90
- ],
91
- },
92
- {
93
- title: "Company",
94
- links: [
95
- { label: "About", to: "/about" },
96
- { label: "Blog", to: "/blog" },
97
- { label: "Careers", to: "/careers" },
98
- { label: "Contact", to: "/contact" },
99
- ],
100
- },
101
- {
102
- title: "Resources",
103
- links: [
104
- { label: "Documentation", to: "/docs" },
105
- { label: "Help Center", to: "/help" },
106
- { label: "Community", to: "/community" },
107
- { label: "Status", to: "/status" },
108
- ],
109
- },
110
- ];
111
-
112
- // Legal links
113
- const legalLinks = [
114
- { label: "Privacy Policy", to: "/privacy" },
115
- { label: "Terms of Service", to: "/terms" },
116
- { label: "Cookie Policy", to: "/cookies" },
117
- ];
118
-
119
- // Newsletter submit handler
120
- const handleNewsletterSubmit = (email) => {
121
- console.log("Newsletter signup:", email);
122
- // Implement your newsletter signup logic here
123
- };
50
+ useSeoMeta({
51
+ titleTemplate: (title) =>
52
+ title ? (siteName.value ? `${title} | ${siteName.value}` : title) : (siteName.value ?? ""),
53
+ ogSiteName: siteName,
54
+ });
124
55
 
125
- // Marker.io widget
126
- if (appConfig && appConfig?.xMarketing?.config?.markerProjectId) {
56
+ // Marker.io widget — only loads when a project ID is configured.
57
+ if (xMarketing?.config?.markerProjectId) {
127
58
  onMounted(() => {
128
59
  markerSDK.loadWidget({
129
- project: appConfig?.xMarketing?.config?.markerProjectId,
60
+ project: xMarketing?.config?.markerProjectId,
130
61
  });
131
62
  });
132
63
  }
@@ -32,7 +32,7 @@
32
32
  class="flex flex-col lg:flex-row lg:justify-between gap-8 lg:gap-12"
33
33
  >
34
34
  <div class="shrink-0 lg:max-w-xl">
35
- <a class="mb-8 inline-block" href="/">
35
+ <a v-if="footer?.logo?.src" class="mb-8 inline-block" href="/">
36
36
  <img
37
37
  :src="footer?.logo?.src"
38
38
  :alt="footer?.logo?.alt"
@@ -85,14 +85,14 @@ const handleScroll = () => {
85
85
  };
86
86
 
87
87
  onMounted(() => {
88
- if (process.client) {
88
+ if (import.meta.client) {
89
89
  window.addEventListener('scroll', handleScroll, { passive: true });
90
90
  handleScroll();
91
91
  }
92
92
  });
93
93
 
94
94
  onUnmounted(() => {
95
- if (process.client) {
95
+ if (import.meta.client) {
96
96
  window.removeEventListener('scroll', handleScroll);
97
97
  }
98
98
  });
@@ -93,10 +93,10 @@
93
93
  {{ category.description }}
94
94
  </p>
95
95
  </div>
96
- <UToggle
96
+ <USwitch
97
97
  :model-value="selections[category.id]"
98
98
  :disabled="category.required"
99
- @update:model-value="(val) => (selections[category.id] = val)"
99
+ @update:model-value="(val: boolean) => (selections[category.id] = val)"
100
100
  />
101
101
  </div>
102
102
  </div>
@@ -149,6 +149,13 @@
149
149
  <script setup lang="ts">
150
150
  type CategoryId = "necessary" | "analytics" | "marketing" | "preferences";
151
151
 
152
+ const DEFAULT_SELECTIONS: Record<CategoryId, boolean> = {
153
+ necessary: false,
154
+ analytics: false,
155
+ marketing: false,
156
+ preferences: false,
157
+ };
158
+
152
159
  interface Category {
153
160
  id: CategoryId;
154
161
  label: string;
@@ -251,17 +258,12 @@ const tracking = useConsentTracking();
251
258
  // - The visitor hasn't made a decision yet (no localStorage key).
252
259
  const bannerVisible = ref(false);
253
260
  const prefsOpen = ref(false);
254
- const selections = ref<Record<CategoryId, boolean>>({});
261
+ const selections = ref<Record<CategoryId, boolean>>({ ...DEFAULT_SELECTIONS });
255
262
 
256
263
  const effectiveCategories = computed(() => props.categories);
257
264
 
258
265
  function buildStateFromSelections(): Record<CategoryId, boolean> {
259
- const out: Record<CategoryId, boolean> = {
260
- necessary: true,
261
- analytics: false,
262
- marketing: false,
263
- preferences: false,
264
- };
266
+ const out: Record<CategoryId, boolean> = { ...DEFAULT_SELECTIONS };
265
267
  for (const c of props.categories) {
266
268
  out[c.id] = c.required ? true : Boolean(selections.value[c.id]);
267
269
  }
@@ -269,12 +271,7 @@ function buildStateFromSelections(): Record<CategoryId, boolean> {
269
271
  }
270
272
 
271
273
  function buildStateAll(value: boolean): Record<CategoryId, boolean> {
272
- const out: Record<CategoryId, boolean> = {
273
- necessary: true,
274
- analytics: false,
275
- marketing: false,
276
- preferences: false,
277
- };
274
+ const out: Record<CategoryId, boolean> = { ...DEFAULT_SELECTIONS };
278
275
  for (const c of props.categories) {
279
276
  out[c.id] = c.required ? true : value;
280
277
  }
@@ -282,11 +279,13 @@ function buildStateAll(value: boolean): Record<CategoryId, boolean> {
282
279
  }
283
280
 
284
281
  function initSelections() {
282
+ const next: Record<CategoryId, boolean> = { ...DEFAULT_SELECTIONS };
285
283
  for (const c of props.categories) {
286
- selections.value[c.id] = c.required
284
+ next[c.id] = c.required
287
285
  ? true
288
286
  : Boolean(c.enabledByDefault ?? false);
289
287
  }
288
+ selections.value = next;
290
289
  }
291
290
 
292
291
  function syncFromStored() {
@@ -47,7 +47,7 @@
47
47
  {{ category.description }}
48
48
  </p>
49
49
  </div>
50
- <UToggle
50
+ <USwitch
51
51
  v-model="selections[category.id]"
52
52
  :disabled="category.required"
53
53
  />
@@ -1,11 +1,171 @@
1
+ /**
2
+ * Nuxt Content helpers for marketing blog pages.
3
+ *
4
+ * Expects a `blog` collection (see root `content.config.ts`) and
5
+ * markdown under content/blog/. Replaces the former Builder.io
6
+ * CDN fetches and the unused `apiURL` stub.
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} XMarkBlogPost
11
+ * @property {string} title
12
+ * @property {string} [slug]
13
+ * @property {string} [path]
14
+ * @property {string} [_path]
15
+ * @property {string} [description]
16
+ * @property {string} [date]
17
+ * @property {string} [category]
18
+ * @property {string[]} [tags]
19
+ * @property {{ src: string, alt?: string }} [img]
20
+ * @property {string} [image]
21
+ * @property {{ name: string, avatar?: string, title?: string, bio?: string } | string} [author]
22
+ * @property {string|number} [readingTime]
23
+ * @property {boolean} [published]
24
+ * @property {unknown} [body]
25
+ */
26
+
27
+ /**
28
+ * Normalize a Content collection entry into the shape used by
29
+ * `XMarkBlogCard` / `XMarkBlogList` / `XMarkBlogDetail`.
30
+ * @param {Record<string, unknown>} doc
31
+ * @returns {XMarkBlogPost}
32
+ */
33
+ export function normalizeBlogPost(doc) {
34
+ if (!doc || typeof doc !== 'object') {
35
+ return { title: '' }
36
+ }
37
+
38
+ const path = typeof doc.path === 'string'
39
+ ? doc.path
40
+ : typeof doc._path === 'string'
41
+ ? doc._path
42
+ : ''
43
+
44
+ const slugFromPath = path
45
+ ? path.replace(/^\/+/, '').split('/').filter(Boolean).pop()
46
+ : ''
47
+
48
+ const slug = typeof doc.slug === 'string' && doc.slug
49
+ ? doc.slug
50
+ : (typeof doc.stem === 'string' && doc.stem
51
+ ? doc.stem.split('/').pop()
52
+ : slugFromPath)
53
+
54
+ const image = typeof doc.image === 'string'
55
+ ? doc.image
56
+ : (doc.img && typeof doc.img === 'object' && typeof doc.img.src === 'string'
57
+ ? doc.img.src
58
+ : '')
59
+
60
+ let author = doc.author
61
+ if (typeof author === 'string' && author) {
62
+ author = { name: author }
63
+ }
64
+
65
+ let readingTime = doc.readingTime
66
+ if (typeof readingTime === 'number') {
67
+ readingTime = `${readingTime} min read`
68
+ }
69
+
70
+ let date = doc.date
71
+ if (date instanceof Date) {
72
+ date = date.toISOString()
73
+ } else if (typeof date === 'string') {
74
+ // keep as-is
75
+ } else if (date != null) {
76
+ date = String(date)
77
+ }
78
+
79
+ return {
80
+ ...doc,
81
+ title: typeof doc.title === 'string' ? doc.title : '',
82
+ slug: slug || '',
83
+ path: path || undefined,
84
+ _path: path || undefined,
85
+ description: typeof doc.description === 'string' ? doc.description : doc.excerpt,
86
+ date,
87
+ category: doc.category,
88
+ tags: Array.isArray(doc.tags) ? doc.tags : [],
89
+ image: image || undefined,
90
+ img: image
91
+ ? { src: image, alt: typeof doc.title === 'string' ? doc.title : '' }
92
+ : (doc.img && typeof doc.img === 'object' ? doc.img : undefined),
93
+ author,
94
+ readingTime,
95
+ published: doc.published !== false,
96
+ }
97
+ }
98
+
1
99
  export function useXBlog() {
2
- const config = useRuntimeConfig();
3
- const endpoint = config.public.apiURL + "/general";
4
- const get = async () => {
5
- const general = await $fetch(endpoint);
6
- return general;
7
- };
100
+ const appConfig = useAppConfig()
101
+ const blogConfig = computed(() => appConfig.xMarketing?.blog ?? {})
102
+
103
+ /**
104
+ * List published posts, newest first.
105
+ * @param {{ limit?: number, tag?: string, category?: string }} [options]
106
+ * @returns {Promise<XMarkBlogPost[]>}
107
+ */
108
+ async function getPosts(options = {}) {
109
+ const { limit, tag, category } = options
110
+
111
+ let query = queryCollection('blog')
112
+ .where('published', '<>', false)
113
+ .order('date', 'DESC')
114
+
115
+ const all = await query.all()
116
+ let posts = (all ?? []).map(normalizeBlogPost)
117
+
118
+ if (tag) {
119
+ posts = posts.filter(p => p.tags?.includes(tag))
120
+ }
121
+ if (category) {
122
+ posts = posts.filter(p => p.category === category)
123
+ }
124
+ if (typeof limit === 'number' && limit > 0) {
125
+ posts = posts.slice(0, limit)
126
+ }
127
+
128
+ return posts
129
+ }
130
+
131
+ /**
132
+ * Fetch a single post by Content path (e.g. `/blog/my-post`).
133
+ * @param {string} path
134
+ * @returns {Promise<XMarkBlogPost | null>}
135
+ */
136
+ async function getPostByPath(path) {
137
+ try {
138
+ const doc = await queryCollection('blog').path(path).first()
139
+ if (!doc) return null
140
+ return normalizeBlogPost(doc)
141
+ } catch {
142
+ return null
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Previous / next posts around a path (Content surroundings).
148
+ * @param {string} path
149
+ * @returns {Promise<{ previous: XMarkBlogPost | null, next: XMarkBlogPost | null }>}
150
+ */
151
+ async function getSurround(path) {
152
+ try {
153
+ const surround = await queryCollectionItemSurroundings('blog', path, {
154
+ fields: ['title', 'description', 'image', 'date', 'path', 'stem'],
155
+ })
156
+ const previous = surround?.[0] ? normalizeBlogPost(surround[0]) : null
157
+ const next = surround?.[1] ? normalizeBlogPost(surround[1]) : null
158
+ return { previous, next }
159
+ } catch {
160
+ return { previous: null, next: null }
161
+ }
162
+ }
163
+
8
164
  return {
9
- get,
10
- };
165
+ blogConfig,
166
+ normalizeBlogPost,
167
+ getPosts,
168
+ getPostByPath,
169
+ getSurround,
170
+ }
11
171
  }
@@ -1,75 +1,87 @@
1
1
  <template>
2
- <div>
3
- <main class="bg-white">
4
- <header
5
- class="w-full h-[460px] xl:h-[537px] bg-no-repeat bg-cover bg-center bg-blend-darken relative"
6
- :style="`background-image: url('${content.headerImage}?format=webp')`"
7
- >
8
- <div
9
- class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50"
10
- ></div>
11
- <div
12
- class="absolute top-20 left-1/2 px-4 mx-auto w-full max-w-screen-xl -translate-x-1/2 xl:top-1/2 xl:-translate-y-1/2 xl:px-0"
13
- >
14
- <h1
15
- class="mb-4 max-w-6xl text-4xl font-extrabold leading-none text-white sm:text-5xl lg:text-7xl px-4"
16
- >
17
- {{ content.title }}
18
- </h1>
19
- </div>
20
- </header>
21
- <div
22
- class="flex flex-col relative z-20 justify-between p-8 -mt-36 mx-4 max-w-screen-xl bg-white rounded-xl xl:-mt-32 xl:p-9 xl:mx-auto pb-24 shadow-sm"
23
- >
24
- <div class="absolute right-0 top-0 p-3">
25
- <UButton to="/blog" label="Back to Blog List" variant="ghost" />
26
- </div>
27
- <div>
28
- <div class="text-4xl text-black font-bold pb-6 pt-4">
29
- {{ content.title }}
30
- </div>
31
- <article
32
- class="xl:w-[828px] w-full max-w-none format format-sm sm:format-base lg:format-lg format-blue pb-24"
33
- >
34
- <section
35
- class="prose md:prose-xl lg:prose-xl prose-stone"
36
- v-html="content.body"
37
- ></section>
38
- </article>
2
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
3
+ <div class="mb-6">
4
+ <UButton
5
+ to="/blog"
6
+ variant="ghost"
7
+ color="neutral"
8
+ icon="i-lucide-arrow-left"
9
+ label="Back to Blog"
10
+ size="sm"
11
+ />
12
+ </div>
39
13
 
40
- <aside class="hidden xl:block" aria-labelledby="sidebar-label">
41
- <div class="xl:w-[336px] sticky top-6">
42
- <h3 id="sidebar-label" class="sr-only">Sidebar</h3>
43
- </div>
44
- </aside>
45
- </div>
46
- </div>
47
- </main>
48
- </div>
14
+ <XMarkBlogDetail
15
+ v-if="displayPost"
16
+ :post="displayPost"
17
+ :previous-post="surround?.previous ?? undefined"
18
+ :next-post="surround?.next ?? undefined"
19
+ :toc="tocLinks"
20
+ >
21
+ <ContentRenderer
22
+ v-if="rawPost"
23
+ :value="rawPost"
24
+ />
25
+ </XMarkBlogDetail>
26
+ </div>
49
27
  </template>
50
28
 
51
- <script setup>
52
- const route = useRoute();
53
- const config = useRuntimeConfig();
54
- const builder = await $fetch(`https://cdn.builder.io/api/v3/content/posts/`, {
55
- query: {
56
- apiKey: config?.public?.BUILDERIO_KEY,
57
- limit: 1,
58
- "query.data.slug": route.params.slug[0],
59
- },
60
- });
61
- const content = computed(() => {
62
- return builder.results[0].data;
63
- });
64
- const title = builder.results[0].data.title;
65
- const description = builder.results[0].data.description;
29
+ <script setup lang="ts">
30
+ const route = useRoute()
31
+ const { getSurround, normalizeBlogPost } = useXBlog()
32
+
33
+ // Opt-out: xMarketing.blog.active === false disables the shipped blog pages.
34
+ if (useAppConfig()?.xMarketing?.blog?.active === false) {
35
+ throw createError({ statusCode: 404, statusMessage: 'Page not found' })
36
+ }
37
+
38
+ const path = computed(() => {
39
+ const slug = route.params.slug
40
+ const parts = Array.isArray(slug) ? slug : [slug]
41
+ return `/blog/${parts.filter(Boolean).join('/')}`
42
+ })
43
+
44
+ const { data: rawPost } = await useAsyncData(
45
+ () => `marketing-blog-post-${path.value}`,
46
+ () => queryCollection('blog').path(path.value).first(),
47
+ )
48
+
49
+ if (!rawPost.value) {
50
+ throw createError({
51
+ statusCode: 404,
52
+ statusMessage: 'Post not found',
53
+ })
54
+ }
55
+
56
+ const displayPost = computed(() =>
57
+ rawPost.value ? normalizeBlogPost(rawPost.value as unknown as Record<string, unknown>) : null,
58
+ )
59
+
60
+ const { data: surround } = await useAsyncData(
61
+ () => `marketing-blog-surround-${path.value}`,
62
+ () => getSurround(path.value),
63
+ )
64
+
65
+ type TocLink = { id: string; text: string; depth: number }
66
+
67
+ const tocLinks = computed((): TocLink[] => {
68
+ const body = (rawPost.value as { body?: { toc?: { links?: TocLink[] } } } | null)?.body
69
+ return body?.toc?.links ?? []
70
+ })
71
+
72
+ const title = computed(() => displayPost.value?.title)
73
+ const description = computed(() =>
74
+ typeof displayPost.value?.description === 'string'
75
+ ? displayPost.value.description
76
+ : undefined,
77
+ )
78
+ const ogImage = computed(() => displayPost.value?.img?.src ?? displayPost.value?.image)
79
+
66
80
  useSeoMeta({
67
- title,
68
- ogTitle: title,
69
- description,
70
- ogDescription: description,
71
- });
81
+ title,
82
+ ogTitle: title,
83
+ description,
84
+ ogDescription: description,
85
+ ogImage,
86
+ })
72
87
  </script>
73
-
74
- <style lang="scss" scoped>
75
- </style>