@xenterprises/nuxt-x-marketing 0.0.23 → 1.0.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/.claude/settings.local.json +22 -0
- package/.playground/app.config.ts +44 -45
- package/.playground/app.vue +263 -0
- package/.playground/components/AppFooter.vue +103 -0
- package/.playground/components/AppHeader.vue +41 -0
- package/.playground/components/CodeExample.vue +70 -0
- package/.playground/components/ComponentPlayground.vue +117 -0
- package/.playground/components/DocsHeader.vue +19 -0
- package/.playground/layouts/default.vue +13 -3
- package/.playground/layouts/docs-layout.vue +161 -0
- package/.playground/layouts/docs.vue +11 -0
- package/.playground/package-lock.json +6 -0
- package/.playground/pages/blog/author.vue +145 -0
- package/.playground/pages/blog/card.vue +149 -0
- package/.playground/pages/blog/cta.vue +131 -0
- package/.playground/pages/blog/detail.vue +379 -0
- package/.playground/pages/blog/list.vue +261 -0
- package/.playground/pages/blog/navigation.vue +228 -0
- package/.playground/pages/blog/sidebar.vue +279 -0
- package/.playground/pages/config.vue +289 -0
- package/.playground/pages/content/comparison.vue +253 -0
- package/.playground/pages/content/cta.vue +245 -0
- package/.playground/pages/content/features.vue +217 -0
- package/.playground/pages/content/newsletter-section.vue +260 -0
- package/.playground/pages/content/newsletter.vue +200 -0
- package/.playground/pages/content/pricing.vue +335 -0
- package/.playground/pages/content/testimonials.vue +188 -0
- package/.playground/pages/hero.vue +257 -0
- package/.playground/pages/index.vue +379 -2
- package/.playground/pages/layout/footer.vue +210 -0
- package/.playground/pages/layout/legal-footer.vue +121 -0
- package/.playground/pages/layout/navbar.vue +285 -0
- package/.playground/pages/layout/section.vue +266 -0
- package/.playground/pages/layout/x-legal.vue +199 -0
- package/.playground/pages/modals/feature-modal.vue +320 -0
- package/.playground/pages/modals/image-lightbox.vue +277 -0
- package/.playground/pages/modals/video-modal.vue +222 -0
- package/.playground/pages/notifications/announcement-bar.vue +164 -0
- package/.playground/pages/notifications/cookie-banner.vue +210 -0
- package/.playground/pages/notifications/cookie-toast.vue +143 -0
- package/.playground/pages/notifications/gdpr.vue +276 -0
- package/.playground/pages/notifications/social-proof-toast.vue +255 -0
- package/.playground/pages/ui/badge.vue +161 -0
- package/.playground/pages/ui/bg-pattern.vue +177 -0
- package/.playground/pages/ui/glass-card.vue +179 -0
- package/.playground/pages/ui/glow-divider.vue +179 -0
- package/.playground/pages/ui/promo-card.vue +259 -0
- package/.playground/pages/ui/section-stitch.vue +411 -0
- package/.playground/pages/utilities/back-to-top.vue +153 -0
- package/.playground/pages/utilities/popup-chat.vue +224 -0
- package/README.md +799 -164
- package/app/app.config.ts +88 -2
- package/app/app.vue +106 -11
- package/app/assets/css/x-marketing.css +668 -0
- package/app/components/X/Footer/XLegal/index.vue +63 -26
- package/app/components/X/Footer/index.vue +89 -89
- package/app/components/X/Header/App.vue +63 -0
- package/app/components/X/Mark/Blog/Author.vue +170 -0
- package/app/components/X/Mark/Blog/CTANewsletter.vue +197 -0
- package/app/components/X/Mark/Blog/Card.vue +117 -0
- package/app/components/X/Mark/Blog/Detail.vue +240 -0
- package/app/components/X/Mark/Blog/List.vue +156 -0
- package/app/components/X/Mark/Blog/Navigation.vue +100 -0
- package/app/components/X/Mark/Blog/Sidebar.vue +160 -0
- package/app/components/X/Mark/Button/BackToTop.vue +99 -0
- package/app/components/X/Mark/CTA/index.vue +294 -0
- package/app/components/X/Mark/Card/Glass.vue +64 -0
- package/app/components/X/Mark/Card/Promo.vue +164 -0
- package/app/components/X/Mark/Divider/Glow.vue +113 -0
- package/app/components/X/Mark/Features/index.vue +194 -0
- package/app/components/X/Mark/Hero/index.vue +217 -0
- package/app/components/X/Mark/Layout/AnnouncementBar.vue +139 -0
- package/app/components/X/Mark/Layout/Footer.vue +160 -0
- package/app/components/X/Mark/Layout/FooterLegal.vue +42 -0
- package/app/components/X/Mark/Layout/Navbar.vue +267 -0
- package/app/components/X/Mark/Modal/Chat.vue +151 -0
- package/app/components/X/Mark/Modal/Feature.vue +197 -0
- package/app/components/X/Mark/Modal/Image.vue +226 -0
- package/app/components/X/Mark/Modal/Video.vue +203 -0
- package/app/components/X/Mark/Modal/index.vue +133 -0
- package/app/components/X/Mark/Newsletter/Form.vue +174 -0
- package/app/components/X/Mark/Pattern/Bg.vue +86 -0
- package/app/components/X/Mark/Pricing/Comparison.vue +126 -0
- package/app/components/X/Mark/Pricing/Plans.vue +181 -0
- package/app/components/X/Mark/Privacy/CookieBanner.vue +112 -0
- package/app/components/X/Mark/Privacy/CookieToast.vue +43 -0
- package/app/components/X/Mark/Privacy/GDPR.vue +245 -0
- package/app/components/X/Mark/Section/Newsletter.vue +183 -0
- package/app/components/X/Mark/Section/Stitch.vue +156 -0
- package/app/components/X/Mark/Section/index.vue +151 -0
- package/app/components/X/Mark/SocialProof/Badge.vue +102 -0
- package/app/components/X/Mark/SocialProof/Testimonials.vue +326 -0
- package/app/components/X/Mark/SocialProof/Toast.vue +136 -0
- package/app/components/X/Mark/_archive/Pattern/Background.vue +83 -0
- package/app/components/X/Mark/_archive/Pattern/CheckBg.vue +35 -0
- package/app/components/X/Mark/_archive/Pattern/CircuitBg.vue +41 -0
- package/app/components/X/Mark/_archive/Pattern/DotsBg.vue +35 -0
- package/app/components/X/Mark/_archive/Pattern/GlowDivider.vue +86 -0
- package/app/components/X/Mark/_archive/Pattern/HexBg.vue +35 -0
- package/app/components/X/Mark/_archive/Pattern/PlaidBg.vue +47 -0
- package/app/components/X/Mark/_archive/Pattern/SectionStitch.vue +168 -0
- package/app/components/X/Mark/_archive/Pattern/WaveBg.vue +41 -0
- package/app/components/X/_archive/Newsletter/Form.vue +123 -0
- package/app/components/X/_archive/Newsletter/Section.vue +131 -0
- package/app/composables/useParallax.ts +124 -0
- package/app/composables/useScrollReveal.ts +92 -0
- package/app/pages/index.vue +210 -0
- package/app/plugins/marketing.client.ts +13 -0
- package/app/types/marketing.d.ts +268 -0
- package/componentList.json +511 -0
- package/nuxt.config.ts +6 -13
- package/package.json +21 -9
- package/.editorconfig +0 -12
- package/app/assets/css/main.css +0 -2
- package/app/components/X/Marketing/Sections/EmailSubscription/index.vue +0 -129
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav class="grid sm:grid-cols-2 gap-4 pt-8 border-t border-neutral-200 dark:border-neutral-800">
|
|
3
|
+
<!-- Previous -->
|
|
4
|
+
<UButton
|
|
5
|
+
v-if="previous"
|
|
6
|
+
:to="getPostUrl(previous)"
|
|
7
|
+
variant="ghost"
|
|
8
|
+
color="neutral"
|
|
9
|
+
class="justify-start! h-auto! py-4! px-4!"
|
|
10
|
+
:ui="{
|
|
11
|
+
base: 'group',
|
|
12
|
+
rounded: 'rounded-xl'
|
|
13
|
+
}"
|
|
14
|
+
>
|
|
15
|
+
<template #leading>
|
|
16
|
+
<UIcon
|
|
17
|
+
name="i-lucide-arrow-left"
|
|
18
|
+
class="w-5 h-5 text-neutral-400 group-hover:text-primary-500 transition-colors flex-shrink-0"
|
|
19
|
+
/>
|
|
20
|
+
</template>
|
|
21
|
+
<div class="flex-1 min-w-0 text-left">
|
|
22
|
+
<span class="text-xs text-neutral-500 uppercase tracking-wide block font-normal">
|
|
23
|
+
{{ previousLabel }}
|
|
24
|
+
</span>
|
|
25
|
+
<p class="font-medium text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors line-clamp-1 mt-0.5">
|
|
26
|
+
{{ previous.title }}
|
|
27
|
+
</p>
|
|
28
|
+
</div>
|
|
29
|
+
</UButton>
|
|
30
|
+
<div v-else />
|
|
31
|
+
|
|
32
|
+
<!-- Next -->
|
|
33
|
+
<UButton
|
|
34
|
+
v-if="next"
|
|
35
|
+
:to="getPostUrl(next)"
|
|
36
|
+
variant="ghost"
|
|
37
|
+
color="neutral"
|
|
38
|
+
class="justify-end! h-auto! py-4! px-4!"
|
|
39
|
+
:ui="{
|
|
40
|
+
base: 'group',
|
|
41
|
+
rounded: 'rounded-xl'
|
|
42
|
+
}"
|
|
43
|
+
>
|
|
44
|
+
<div class="flex-1 min-w-0 text-right">
|
|
45
|
+
<span class="text-xs text-neutral-500 uppercase tracking-wide block font-normal">
|
|
46
|
+
{{ nextLabel }}
|
|
47
|
+
</span>
|
|
48
|
+
<p class="font-medium text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors line-clamp-1 mt-0.5">
|
|
49
|
+
{{ next.title }}
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
<template #trailing>
|
|
53
|
+
<UIcon
|
|
54
|
+
name="i-lucide-arrow-right"
|
|
55
|
+
class="w-5 h-5 text-neutral-400 group-hover:text-primary-500 transition-colors flex-shrink-0"
|
|
56
|
+
/>
|
|
57
|
+
</template>
|
|
58
|
+
</UButton>
|
|
59
|
+
</nav>
|
|
60
|
+
</template>
|
|
61
|
+
|
|
62
|
+
<script setup>
|
|
63
|
+
const props = defineProps({
|
|
64
|
+
/** Previous post object */
|
|
65
|
+
previous: {
|
|
66
|
+
type: Object,
|
|
67
|
+
default: null,
|
|
68
|
+
// { title, slug } or { title, _path }
|
|
69
|
+
},
|
|
70
|
+
/** Next post object */
|
|
71
|
+
next: {
|
|
72
|
+
type: Object,
|
|
73
|
+
default: null,
|
|
74
|
+
// { title, slug } or { title, _path }
|
|
75
|
+
},
|
|
76
|
+
/** Base path for blog posts */
|
|
77
|
+
basePath: {
|
|
78
|
+
type: String,
|
|
79
|
+
default: '/blog',
|
|
80
|
+
},
|
|
81
|
+
/** Label for previous link */
|
|
82
|
+
previousLabel: {
|
|
83
|
+
type: String,
|
|
84
|
+
default: 'Previous',
|
|
85
|
+
},
|
|
86
|
+
/** Label for next link */
|
|
87
|
+
nextLabel: {
|
|
88
|
+
type: String,
|
|
89
|
+
default: 'Next',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const getPostUrl = (post) => {
|
|
94
|
+
// Support both slug and _path (from Nuxt Content)
|
|
95
|
+
if (post._path) {
|
|
96
|
+
return post._path;
|
|
97
|
+
}
|
|
98
|
+
return `${props.basePath}/${post.slug}`;
|
|
99
|
+
};
|
|
100
|
+
</script>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<aside class="space-y-8">
|
|
3
|
+
<!-- Search -->
|
|
4
|
+
<section v-if="hasSearch">
|
|
5
|
+
<h4 class="font-medium mb-3 text-neutral-900 dark:text-white">Search</h4>
|
|
6
|
+
<UInput
|
|
7
|
+
v-model="search"
|
|
8
|
+
placeholder="Search posts..."
|
|
9
|
+
icon="i-lucide-search"
|
|
10
|
+
@keyup.enter="$emit('search', search)"
|
|
11
|
+
/>
|
|
12
|
+
</section>
|
|
13
|
+
|
|
14
|
+
<!-- Categories -->
|
|
15
|
+
<section v-if="categories?.length">
|
|
16
|
+
<h4 class="font-medium mb-3 text-neutral-900 dark:text-white">Categories</h4>
|
|
17
|
+
<nav class="space-y-1">
|
|
18
|
+
<NuxtLink
|
|
19
|
+
v-for="category in categories"
|
|
20
|
+
:key="category.slug"
|
|
21
|
+
:to="categoryPath(category.slug)"
|
|
22
|
+
class="flex justify-between items-center py-2 px-3 rounded-lg text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
|
23
|
+
:class="{
|
|
24
|
+
'bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400': isActiveCategory(category.slug)
|
|
25
|
+
}"
|
|
26
|
+
>
|
|
27
|
+
<span>{{ category.name }}</span>
|
|
28
|
+
<span class="text-neutral-400 text-xs">{{ category.count }}</span>
|
|
29
|
+
</NuxtLink>
|
|
30
|
+
</nav>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
<!-- Tags -->
|
|
34
|
+
<section v-if="tags?.length">
|
|
35
|
+
<h4 class="font-medium mb-3 text-neutral-900 dark:text-white">Tags</h4>
|
|
36
|
+
<div class="flex flex-wrap gap-2">
|
|
37
|
+
<NuxtLink
|
|
38
|
+
v-for="tag in tags"
|
|
39
|
+
:key="tag.slug"
|
|
40
|
+
:to="tagPath(tag.slug)"
|
|
41
|
+
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
|
42
|
+
>
|
|
43
|
+
{{ tag.name }}
|
|
44
|
+
</NuxtLink>
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
47
|
+
|
|
48
|
+
<!-- Recent Posts -->
|
|
49
|
+
<section v-if="recentPosts?.length">
|
|
50
|
+
<h4 class="font-medium mb-3 text-neutral-900 dark:text-white">Recent Posts</h4>
|
|
51
|
+
<div class="space-y-4">
|
|
52
|
+
<NuxtLink
|
|
53
|
+
v-for="post in recentPosts"
|
|
54
|
+
:key="post.slug"
|
|
55
|
+
:to="`/blog/${post.slug}`"
|
|
56
|
+
class="flex gap-3 group"
|
|
57
|
+
>
|
|
58
|
+
<figure class="w-16 h-16 rounded-lg overflow-hidden flex-shrink-0">
|
|
59
|
+
<img
|
|
60
|
+
v-if="post.img?.src"
|
|
61
|
+
:src="post.img.src"
|
|
62
|
+
:alt="post.img.alt || post.title"
|
|
63
|
+
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
64
|
+
/>
|
|
65
|
+
<div
|
|
66
|
+
v-else
|
|
67
|
+
class="w-full h-full bg-neutral-200 dark:bg-neutral-800 flex items-center justify-center"
|
|
68
|
+
>
|
|
69
|
+
<UIcon name="i-lucide-file-text" class="w-6 h-6 text-neutral-400" />
|
|
70
|
+
</div>
|
|
71
|
+
</figure>
|
|
72
|
+
<div class="flex-1 min-w-0">
|
|
73
|
+
<h5 class="text-sm font-medium text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors line-clamp-2">
|
|
74
|
+
{{ post.title }}
|
|
75
|
+
</h5>
|
|
76
|
+
<time
|
|
77
|
+
v-if="post.date"
|
|
78
|
+
:datetime="post.date"
|
|
79
|
+
class="text-xs text-neutral-500 mt-1 block"
|
|
80
|
+
>
|
|
81
|
+
{{ formatDate(post.date) }}
|
|
82
|
+
</time>
|
|
83
|
+
</div>
|
|
84
|
+
</NuxtLink>
|
|
85
|
+
</div>
|
|
86
|
+
</section>
|
|
87
|
+
|
|
88
|
+
<!-- Newsletter CTA -->
|
|
89
|
+
<slot name="cta">
|
|
90
|
+
<XMarkBlogCTANewsletter v-if="hasCta" variant="compact" />
|
|
91
|
+
</slot>
|
|
92
|
+
|
|
93
|
+
<!-- Custom Content -->
|
|
94
|
+
<slot />
|
|
95
|
+
</aside>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<script setup>
|
|
99
|
+
const props = defineProps({
|
|
100
|
+
/** Has search input */
|
|
101
|
+
hasSearch: {
|
|
102
|
+
type: Boolean,
|
|
103
|
+
default: true,
|
|
104
|
+
},
|
|
105
|
+
/** Categories list */
|
|
106
|
+
categories: {
|
|
107
|
+
type: Array,
|
|
108
|
+
default: () => [],
|
|
109
|
+
// Each: { name, slug, count }
|
|
110
|
+
},
|
|
111
|
+
/** Tags list */
|
|
112
|
+
tags: {
|
|
113
|
+
type: Array,
|
|
114
|
+
default: () => [],
|
|
115
|
+
// Each: { name, slug }
|
|
116
|
+
},
|
|
117
|
+
/** Recent posts */
|
|
118
|
+
recentPosts: {
|
|
119
|
+
type: Array,
|
|
120
|
+
default: () => [],
|
|
121
|
+
// Each: { title, slug, img?: { src, alt? }, date? }
|
|
122
|
+
},
|
|
123
|
+
/** Base path for categories */
|
|
124
|
+
categoryBasePath: {
|
|
125
|
+
type: String,
|
|
126
|
+
default: '/blog/category',
|
|
127
|
+
},
|
|
128
|
+
/** Base path for tags */
|
|
129
|
+
tagBasePath: {
|
|
130
|
+
type: String,
|
|
131
|
+
default: '/blog/tag',
|
|
132
|
+
},
|
|
133
|
+
/** Has newsletter CTA */
|
|
134
|
+
hasCta: {
|
|
135
|
+
type: Boolean,
|
|
136
|
+
default: true,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
defineEmits(['search']);
|
|
141
|
+
|
|
142
|
+
const route = useRoute();
|
|
143
|
+
const search = ref('');
|
|
144
|
+
|
|
145
|
+
const categoryPath = (slug) => `${props.categoryBasePath}/${slug}`;
|
|
146
|
+
const tagPath = (slug) => `${props.tagBasePath}/${slug}`;
|
|
147
|
+
|
|
148
|
+
const isActiveCategory = (slug) => {
|
|
149
|
+
return route.params.category === slug || route.query.category === slug;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const formatDate = (dateStr) => {
|
|
153
|
+
const date = new Date(dateStr);
|
|
154
|
+
return date.toLocaleDateString('en-US', {
|
|
155
|
+
month: 'short',
|
|
156
|
+
day: 'numeric',
|
|
157
|
+
year: 'numeric',
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
</script>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Transition
|
|
3
|
+
enter-active-class="transition-all duration-300 ease-out"
|
|
4
|
+
enter-from-class="translate-y-4 opacity-0"
|
|
5
|
+
leave-active-class="transition-all duration-200 ease-in"
|
|
6
|
+
leave-to-class="translate-y-4 opacity-0"
|
|
7
|
+
>
|
|
8
|
+
<button
|
|
9
|
+
v-if="isVisible"
|
|
10
|
+
class="fixed z-40 w-11 h-11 flex items-center justify-center rounded-full shadow-lg transition-all duration-300 hover:scale-110"
|
|
11
|
+
:class="[positionClasses, variantClasses]"
|
|
12
|
+
:aria-label="label"
|
|
13
|
+
@click="scrollToTop"
|
|
14
|
+
>
|
|
15
|
+
<UIcon :name="icon" class="w-5 h-5" />
|
|
16
|
+
</button>
|
|
17
|
+
</Transition>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup>
|
|
21
|
+
const props = defineProps({
|
|
22
|
+
/** Scroll threshold before showing button */
|
|
23
|
+
threshold: {
|
|
24
|
+
type: Number,
|
|
25
|
+
default: 400,
|
|
26
|
+
},
|
|
27
|
+
/** Button position */
|
|
28
|
+
position: {
|
|
29
|
+
type: String,
|
|
30
|
+
default: 'bottom-right',
|
|
31
|
+
validator: (v) => ['bottom-right', 'bottom-left', 'bottom-center'].includes(v),
|
|
32
|
+
},
|
|
33
|
+
/** Style variant */
|
|
34
|
+
variant: {
|
|
35
|
+
type: String,
|
|
36
|
+
default: 'primary',
|
|
37
|
+
validator: (v) => ['primary', 'neutral', 'glass'].includes(v),
|
|
38
|
+
},
|
|
39
|
+
/** Icon name */
|
|
40
|
+
icon: {
|
|
41
|
+
type: String,
|
|
42
|
+
default: 'i-lucide-arrow-up',
|
|
43
|
+
},
|
|
44
|
+
/** Aria label */
|
|
45
|
+
label: {
|
|
46
|
+
type: String,
|
|
47
|
+
default: 'Back to top',
|
|
48
|
+
},
|
|
49
|
+
/** Smooth scroll behavior */
|
|
50
|
+
smooth: {
|
|
51
|
+
type: Boolean,
|
|
52
|
+
default: true,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const isVisible = ref(false);
|
|
57
|
+
|
|
58
|
+
const positionClasses = computed(() => {
|
|
59
|
+
const positions = {
|
|
60
|
+
'bottom-right': 'bottom-6 right-6',
|
|
61
|
+
'bottom-left': 'bottom-6 left-6',
|
|
62
|
+
'bottom-center': 'bottom-6 left-1/2 -translate-x-1/2',
|
|
63
|
+
};
|
|
64
|
+
return positions[props.position];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const variantClasses = computed(() => {
|
|
68
|
+
const variants = {
|
|
69
|
+
primary: 'bg-primary-500 text-white hover:bg-primary-600 hover:shadow-lg hover:shadow-primary-500/30',
|
|
70
|
+
neutral: 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 hover:bg-neutral-800 dark:hover:bg-neutral-100',
|
|
71
|
+
glass: 'xGlass text-neutral-900 dark:text-white hover:bg-white/80 dark:hover:bg-black/60',
|
|
72
|
+
};
|
|
73
|
+
return variants[props.variant];
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const scrollToTop = () => {
|
|
77
|
+
window.scrollTo({
|
|
78
|
+
top: 0,
|
|
79
|
+
behavior: props.smooth ? 'smooth' : 'auto',
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleScroll = () => {
|
|
84
|
+
isVisible.value = window.scrollY > props.threshold;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
onMounted(() => {
|
|
88
|
+
if (process.client) {
|
|
89
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
90
|
+
handleScroll();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
onUnmounted(() => {
|
|
95
|
+
if (process.client) {
|
|
96
|
+
window.removeEventListener('scroll', handleScroll);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
</script>
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section
|
|
3
|
+
:class="[
|
|
4
|
+
'relative overflow-hidden rounded-2xl',
|
|
5
|
+
paddingClasses,
|
|
6
|
+
bgColorClasses
|
|
7
|
+
]"
|
|
8
|
+
>
|
|
9
|
+
<!-- Background Pattern -->
|
|
10
|
+
<XMarkPatternBg
|
|
11
|
+
v-if="resolvedBg.pattern"
|
|
12
|
+
:pattern="resolvedBg.pattern"
|
|
13
|
+
:opacity="resolvedBg.patternOpacity"
|
|
14
|
+
class="absolute inset-0 pointer-events-none"
|
|
15
|
+
/>
|
|
16
|
+
|
|
17
|
+
<!-- Background Image -->
|
|
18
|
+
<div
|
|
19
|
+
v-if="resolvedBg.image"
|
|
20
|
+
class="absolute inset-0"
|
|
21
|
+
>
|
|
22
|
+
<img
|
|
23
|
+
:src="resolvedBg.image"
|
|
24
|
+
:alt="resolvedBg.imageAlt"
|
|
25
|
+
class="w-full h-full object-cover"
|
|
26
|
+
:class="resolvedBg.imagePosition"
|
|
27
|
+
/>
|
|
28
|
+
<div
|
|
29
|
+
v-if="resolvedBg.imageOverlay"
|
|
30
|
+
class="absolute inset-0"
|
|
31
|
+
:class="bgImageOverlayClass"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Content -->
|
|
36
|
+
<div :class="['relative z-10', containerClasses]">
|
|
37
|
+
<!-- Icon -->
|
|
38
|
+
<div v-if="icon" :class="iconContainerClasses">
|
|
39
|
+
<UIcon :name="icon" :class="iconClasses" />
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Content -->
|
|
43
|
+
<div :class="contentClasses">
|
|
44
|
+
<!-- Eyebrow -->
|
|
45
|
+
<p v-if="eyebrow" :class="eyebrowClasses">
|
|
46
|
+
{{ eyebrow }}
|
|
47
|
+
</p>
|
|
48
|
+
|
|
49
|
+
<!-- Title -->
|
|
50
|
+
<h3 :class="titleClasses">
|
|
51
|
+
{{ title }}
|
|
52
|
+
</h3>
|
|
53
|
+
|
|
54
|
+
<!-- Description -->
|
|
55
|
+
<p v-if="description" :class="descriptionClasses">
|
|
56
|
+
{{ description }}
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Buttons -->
|
|
61
|
+
<div v-if="buttons?.length" :class="buttonsClasses">
|
|
62
|
+
<UButton
|
|
63
|
+
v-for="(button, index) in buttons"
|
|
64
|
+
:key="index"
|
|
65
|
+
:label="button.label"
|
|
66
|
+
:to="button.to"
|
|
67
|
+
:color="getButtonColor(button, index)"
|
|
68
|
+
:variant="button.variant || (index === 0 ? 'solid' : 'outline')"
|
|
69
|
+
:icon="button.icon"
|
|
70
|
+
:size="size === 'lg' ? 'lg' : 'md'"
|
|
71
|
+
:class="getButtonClass(button, index)"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</section>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<script setup>
|
|
79
|
+
const props = defineProps({
|
|
80
|
+
/** Eyebrow text above title */
|
|
81
|
+
eyebrow: {
|
|
82
|
+
type: String,
|
|
83
|
+
default: '',
|
|
84
|
+
},
|
|
85
|
+
/** CTA title */
|
|
86
|
+
title: {
|
|
87
|
+
type: String,
|
|
88
|
+
required: true,
|
|
89
|
+
},
|
|
90
|
+
/** CTA description */
|
|
91
|
+
description: {
|
|
92
|
+
type: String,
|
|
93
|
+
default: '',
|
|
94
|
+
},
|
|
95
|
+
/** Optional icon */
|
|
96
|
+
icon: {
|
|
97
|
+
type: String,
|
|
98
|
+
default: '',
|
|
99
|
+
},
|
|
100
|
+
/** Action buttons */
|
|
101
|
+
buttons: {
|
|
102
|
+
type: Array,
|
|
103
|
+
default: () => [],
|
|
104
|
+
// [{ label, to?, color?, variant?, icon? }]
|
|
105
|
+
},
|
|
106
|
+
/** Layout variant */
|
|
107
|
+
variant: {
|
|
108
|
+
type: String,
|
|
109
|
+
default: 'default',
|
|
110
|
+
validator: (v) => ['default', 'centered', 'inline', 'stacked'].includes(v),
|
|
111
|
+
},
|
|
112
|
+
/**
|
|
113
|
+
* Background configuration object or string shorthand
|
|
114
|
+
* @property {string} variant - 'subtle' | 'bold' | 'gradient' | 'none'
|
|
115
|
+
* @property {string} color - Custom Tailwind classes (overrides variant)
|
|
116
|
+
* @property {string} image - Background image URL
|
|
117
|
+
* @property {string} imageAlt - Background image alt text
|
|
118
|
+
* @property {string} imageOverlay - 'light' | 'heavy' | 'gradient'
|
|
119
|
+
* @property {string} imagePosition - Tailwind object-position class
|
|
120
|
+
* @property {string} pattern - 'dots' | 'grid' | 'diagonal' | 'topography' | 'circuit' | 'waves'
|
|
121
|
+
* @property {number} patternOpacity - Pattern opacity (0-1)
|
|
122
|
+
*/
|
|
123
|
+
bg: {
|
|
124
|
+
type: [Object, String],
|
|
125
|
+
default: 'subtle',
|
|
126
|
+
},
|
|
127
|
+
/** Size */
|
|
128
|
+
size: {
|
|
129
|
+
type: String,
|
|
130
|
+
default: 'md',
|
|
131
|
+
validator: (v) => ['sm', 'md', 'lg'].includes(v),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Default bg values
|
|
136
|
+
const bgDefaults = {
|
|
137
|
+
variant: 'subtle',
|
|
138
|
+
color: '',
|
|
139
|
+
image: '',
|
|
140
|
+
imageAlt: '',
|
|
141
|
+
imageOverlay: '',
|
|
142
|
+
imagePosition: 'object-center',
|
|
143
|
+
pattern: '',
|
|
144
|
+
patternOpacity: 0.05,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Resolve bg prop (supports string shorthand for variant or full object)
|
|
148
|
+
const resolvedBg = computed(() => {
|
|
149
|
+
if (typeof props.bg === 'string') {
|
|
150
|
+
return { ...bgDefaults, variant: props.bg };
|
|
151
|
+
}
|
|
152
|
+
return { ...bgDefaults, ...props.bg };
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Check if background is "bold" (dark background requiring light text)
|
|
156
|
+
const isBoldBg = computed(() => {
|
|
157
|
+
return ['bold', 'gradient'].includes(resolvedBg.value.variant) || resolvedBg.value.image;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const paddingClasses = computed(() => {
|
|
161
|
+
const sizes = {
|
|
162
|
+
sm: 'p-6',
|
|
163
|
+
md: 'p-8 md:p-12',
|
|
164
|
+
lg: 'p-12 md:p-16',
|
|
165
|
+
};
|
|
166
|
+
return sizes[props.size];
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const bgColorClasses = computed(() => {
|
|
170
|
+
// Custom color class takes priority
|
|
171
|
+
if (resolvedBg.value.color) return resolvedBg.value.color;
|
|
172
|
+
|
|
173
|
+
const variants = {
|
|
174
|
+
subtle: 'bg-neutral-100 dark:bg-neutral-900',
|
|
175
|
+
bold: 'bg-primary-600 dark:bg-primary-700',
|
|
176
|
+
gradient: 'bg-gradient-to-br from-primary-600 to-primary-800',
|
|
177
|
+
none: '',
|
|
178
|
+
};
|
|
179
|
+
return variants[resolvedBg.value.variant] || variants.subtle;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const bgImageOverlayClass = computed(() => {
|
|
183
|
+
const overlays = {
|
|
184
|
+
light: 'bg-black/30',
|
|
185
|
+
heavy: 'bg-black/60',
|
|
186
|
+
gradient: 'bg-gradient-to-t from-black/80 via-black/40 to-transparent',
|
|
187
|
+
};
|
|
188
|
+
return overlays[resolvedBg.value.imageOverlay] || '';
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const containerClasses = computed(() => {
|
|
192
|
+
const layouts = {
|
|
193
|
+
default: 'flex flex-col md:flex-row md:items-center md:justify-between gap-6',
|
|
194
|
+
centered: 'text-center max-w-2xl mx-auto',
|
|
195
|
+
inline: 'flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4',
|
|
196
|
+
stacked: 'flex flex-col items-center text-center gap-6',
|
|
197
|
+
};
|
|
198
|
+
return layouts[props.variant];
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const iconContainerClasses = computed(() => {
|
|
202
|
+
return [
|
|
203
|
+
'flex items-center justify-center rounded-xl',
|
|
204
|
+
props.size === 'lg' ? 'w-16 h-16' : 'w-12 h-12',
|
|
205
|
+
isBoldBg.value ? 'bg-white/20' : 'bg-primary-100 dark:bg-primary-900/50',
|
|
206
|
+
];
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const iconClasses = computed(() => {
|
|
210
|
+
return [
|
|
211
|
+
props.size === 'lg' ? 'w-8 h-8' : 'w-6 h-6',
|
|
212
|
+
isBoldBg.value ? 'text-white' : 'text-primary-500',
|
|
213
|
+
];
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const contentClasses = computed(() => {
|
|
217
|
+
if (props.variant === 'default') {
|
|
218
|
+
return 'flex-1';
|
|
219
|
+
}
|
|
220
|
+
return '';
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const eyebrowClasses = computed(() => {
|
|
224
|
+
return [
|
|
225
|
+
'xText-eyebrow mb-2',
|
|
226
|
+
isBoldBg.value ? 'text-white/70' : 'text-primary-600 dark:text-primary-400',
|
|
227
|
+
];
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const titleClasses = computed(() => {
|
|
231
|
+
const sizeClasses = {
|
|
232
|
+
sm: 'text-lg font-semibold',
|
|
233
|
+
md: 'text-xl font-semibold',
|
|
234
|
+
lg: 'xText-headline',
|
|
235
|
+
};
|
|
236
|
+
return [
|
|
237
|
+
sizeClasses[props.size],
|
|
238
|
+
isBoldBg.value ? 'text-white' : 'text-neutral-900 dark:text-white',
|
|
239
|
+
];
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const descriptionClasses = computed(() => {
|
|
243
|
+
return [
|
|
244
|
+
'mt-2',
|
|
245
|
+
props.size === 'sm' ? 'text-sm' : 'text-base',
|
|
246
|
+
isBoldBg.value ? 'text-white/80' : 'text-neutral-600 dark:text-neutral-400',
|
|
247
|
+
];
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const buttonsClasses = computed(() => {
|
|
251
|
+
const layouts = {
|
|
252
|
+
default: 'flex flex-wrap gap-3',
|
|
253
|
+
centered: 'flex flex-wrap justify-center gap-3 mt-6',
|
|
254
|
+
inline: 'flex flex-wrap gap-3',
|
|
255
|
+
stacked: 'flex flex-wrap justify-center gap-3',
|
|
256
|
+
};
|
|
257
|
+
return layouts[props.variant];
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const getButtonColor = (button, index) => {
|
|
261
|
+
// Allow explicit color override
|
|
262
|
+
if (button.color && button.color !== 'white') return button.color;
|
|
263
|
+
if (index === 0) {
|
|
264
|
+
// Use neutral instead of white (not a valid Nuxt UI v4 color)
|
|
265
|
+
// Custom classes handle the actual styling
|
|
266
|
+
return isBoldBg.value ? 'neutral' : 'primary';
|
|
267
|
+
}
|
|
268
|
+
return 'neutral';
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const getButtonClass = (button, index) => {
|
|
272
|
+
const isOutline = button.variant === 'outline' || (index > 0 && !button.variant);
|
|
273
|
+
const isGhost = button.variant === 'ghost';
|
|
274
|
+
const isSolid = button.variant === 'solid' || (index === 0 && !button.variant);
|
|
275
|
+
|
|
276
|
+
// Solid buttons on bold/gradient backgrounds - white bg with dark text
|
|
277
|
+
if (isBoldBg.value && isSolid) {
|
|
278
|
+
return 'bg-white text-neutral-900 hover:bg-white/90';
|
|
279
|
+
}
|
|
280
|
+
// Outline buttons on bold/gradient backgrounds - transparent with white border
|
|
281
|
+
if (isBoldBg.value && isOutline) {
|
|
282
|
+
return 'bg-white/10 backdrop-blur-sm border-white/40 hover:bg-white/20 hover:border-white/60 text-white';
|
|
283
|
+
}
|
|
284
|
+
// Ghost buttons on bold/gradient backgrounds - transparent with white text
|
|
285
|
+
if (isBoldBg.value && isGhost) {
|
|
286
|
+
return 'text-white hover:bg-white/10';
|
|
287
|
+
}
|
|
288
|
+
// Outline buttons on subtle backgrounds
|
|
289
|
+
if (!isBoldBg.value && isOutline) {
|
|
290
|
+
return 'border-neutral-400 dark:border-neutral-600 hover:border-neutral-500 dark:hover:border-neutral-500';
|
|
291
|
+
}
|
|
292
|
+
return '';
|
|
293
|
+
};
|
|
294
|
+
</script>
|