@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,217 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section
|
|
3
|
+
class="relative min-h-svh flex overflow-hidden"
|
|
4
|
+
:class="[
|
|
5
|
+
alignmentClasses,
|
|
6
|
+
verticalAlignClasses,
|
|
7
|
+
{ 'pt-20': includeNavPadding }
|
|
8
|
+
]"
|
|
9
|
+
>
|
|
10
|
+
<!-- Background -->
|
|
11
|
+
<div class="absolute inset-0">
|
|
12
|
+
<!-- Custom Background Slot -->
|
|
13
|
+
<slot name="background">
|
|
14
|
+
<!-- Video Background -->
|
|
15
|
+
<video
|
|
16
|
+
v-if="video?.src"
|
|
17
|
+
:src="video.src"
|
|
18
|
+
:poster="video.poster || img?.src"
|
|
19
|
+
autoplay
|
|
20
|
+
loop
|
|
21
|
+
muted
|
|
22
|
+
playsinline
|
|
23
|
+
class="absolute inset-0 w-full h-full object-cover xParallax"
|
|
24
|
+
data-parallax-speed="0.3"
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
<!-- Image Background -->
|
|
28
|
+
<img
|
|
29
|
+
v-else-if="img?.src"
|
|
30
|
+
:src="img.src"
|
|
31
|
+
:alt="img.alt || ''"
|
|
32
|
+
class="absolute inset-0 w-full h-full object-cover xParallax"
|
|
33
|
+
data-parallax-speed="0.3"
|
|
34
|
+
/>
|
|
35
|
+
</slot>
|
|
36
|
+
|
|
37
|
+
<!-- Overlay -->
|
|
38
|
+
<div
|
|
39
|
+
v-if="overlay !== 'none'"
|
|
40
|
+
class="absolute inset-0"
|
|
41
|
+
:class="overlayClass"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<!-- Content -->
|
|
46
|
+
<div class="relative z-10 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
47
|
+
<div
|
|
48
|
+
class="max-w-3xl"
|
|
49
|
+
:class="contentAlignmentClasses"
|
|
50
|
+
>
|
|
51
|
+
<!-- Eyebrow -->
|
|
52
|
+
<p
|
|
53
|
+
v-if="eyebrow"
|
|
54
|
+
class="xText-eyebrow text-white/70 mb-4 xFadeUp"
|
|
55
|
+
>
|
|
56
|
+
{{ eyebrow }}
|
|
57
|
+
</p>
|
|
58
|
+
|
|
59
|
+
<!-- Title -->
|
|
60
|
+
<h1
|
|
61
|
+
class="xText-display text-white xText-balance xFadeUp"
|
|
62
|
+
style="transition-delay: 100ms"
|
|
63
|
+
>
|
|
64
|
+
<slot name="title">{{ title }}</slot>
|
|
65
|
+
</h1>
|
|
66
|
+
|
|
67
|
+
<!-- Subtitle -->
|
|
68
|
+
<p
|
|
69
|
+
v-if="subtitle || $slots.subtitle"
|
|
70
|
+
class="mt-6 text-xl text-white/80 max-w-2xl xFadeUp"
|
|
71
|
+
style="transition-delay: 200ms"
|
|
72
|
+
>
|
|
73
|
+
<slot name="subtitle">{{ subtitle }}</slot>
|
|
74
|
+
</p>
|
|
75
|
+
|
|
76
|
+
<!-- Actions -->
|
|
77
|
+
<div
|
|
78
|
+
v-if="buttons.length || $slots.actions"
|
|
79
|
+
class="mt-10 flex flex-wrap gap-4 xFadeUp"
|
|
80
|
+
:class="{ 'justify-center': align === 'center', 'justify-end': align === 'right' }"
|
|
81
|
+
style="transition-delay: 300ms"
|
|
82
|
+
>
|
|
83
|
+
<slot name="actions">
|
|
84
|
+
<UButton
|
|
85
|
+
v-for="(btn, index) in buttons"
|
|
86
|
+
:key="index"
|
|
87
|
+
size="xl"
|
|
88
|
+
:color="btn.color || (index === 0 ? 'primary' : 'neutral')"
|
|
89
|
+
:variant="btn.variant || 'solid'"
|
|
90
|
+
:to="btn.to"
|
|
91
|
+
:icon="btn.icon"
|
|
92
|
+
:class="[
|
|
93
|
+
btn.variant === 'outline' || (index > 0 && !btn.variant)
|
|
94
|
+
? 'bg-white/10 backdrop-blur-sm border-white/40 text-white hover:bg-white/20 hover:border-white/60'
|
|
95
|
+
: ''
|
|
96
|
+
]"
|
|
97
|
+
>
|
|
98
|
+
{{ btn.label }}
|
|
99
|
+
</UButton>
|
|
100
|
+
</slot>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<!-- Scroll Indicator -->
|
|
106
|
+
<div
|
|
107
|
+
v-if="hasScrollIndicator"
|
|
108
|
+
class="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2"
|
|
109
|
+
>
|
|
110
|
+
<span class="text-xs text-white/50 uppercase tracking-widest">Scroll</span>
|
|
111
|
+
<div class="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center pt-2">
|
|
112
|
+
<div class="w-1.5 h-1.5 bg-white/60 rounded-full animate-[scroll-indicator_2s_ease-in-out_infinite]" />
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</section>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<script setup>
|
|
119
|
+
const props = defineProps({
|
|
120
|
+
/** Background image object { src: string, alt?: string } */
|
|
121
|
+
img: {
|
|
122
|
+
type: Object,
|
|
123
|
+
default: null,
|
|
124
|
+
validator: (v) => !v || typeof v.src === 'string',
|
|
125
|
+
},
|
|
126
|
+
/** Background video object { src: string, poster?: string } */
|
|
127
|
+
video: {
|
|
128
|
+
type: Object,
|
|
129
|
+
default: null,
|
|
130
|
+
validator: (v) => !v || typeof v.src === 'string',
|
|
131
|
+
},
|
|
132
|
+
/** Overlay style */
|
|
133
|
+
overlay: {
|
|
134
|
+
type: String,
|
|
135
|
+
default: 'gradient',
|
|
136
|
+
validator: (v) => ['light', 'heavy', 'gradient', 'none'].includes(v),
|
|
137
|
+
},
|
|
138
|
+
/** Small text above title */
|
|
139
|
+
eyebrow: {
|
|
140
|
+
type: String,
|
|
141
|
+
default: '',
|
|
142
|
+
},
|
|
143
|
+
/** Main headline */
|
|
144
|
+
title: {
|
|
145
|
+
type: String,
|
|
146
|
+
required: true,
|
|
147
|
+
},
|
|
148
|
+
/** Supporting text */
|
|
149
|
+
subtitle: {
|
|
150
|
+
type: String,
|
|
151
|
+
default: '',
|
|
152
|
+
},
|
|
153
|
+
/** Action buttons array [{ label, color?, variant?, to?, icon? }] */
|
|
154
|
+
buttons: {
|
|
155
|
+
type: Array,
|
|
156
|
+
default: () => [],
|
|
157
|
+
validator: (v) => v.every(btn => btn.label),
|
|
158
|
+
},
|
|
159
|
+
/** Content horizontal alignment */
|
|
160
|
+
align: {
|
|
161
|
+
type: String,
|
|
162
|
+
default: 'left',
|
|
163
|
+
validator: (v) => ['left', 'center', 'right'].includes(v),
|
|
164
|
+
},
|
|
165
|
+
/** Content vertical position */
|
|
166
|
+
verticalAlign: {
|
|
167
|
+
type: String,
|
|
168
|
+
default: 'center',
|
|
169
|
+
validator: (v) => ['center', 'bottom'].includes(v),
|
|
170
|
+
},
|
|
171
|
+
/** Has scroll indicator arrow */
|
|
172
|
+
hasScrollIndicator: {
|
|
173
|
+
type: Boolean,
|
|
174
|
+
default: true,
|
|
175
|
+
},
|
|
176
|
+
/** Add top padding for non-transparent nav */
|
|
177
|
+
includeNavPadding: {
|
|
178
|
+
type: Boolean,
|
|
179
|
+
default: false,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const overlayClass = computed(() => {
|
|
184
|
+
const overlays = {
|
|
185
|
+
light: 'bg-black/30',
|
|
186
|
+
heavy: 'bg-black/60',
|
|
187
|
+
gradient: 'bg-gradient-to-t from-black/80 via-black/40 to-transparent',
|
|
188
|
+
};
|
|
189
|
+
return overlays[props.overlay] || '';
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const alignmentClasses = computed(() => {
|
|
193
|
+
const aligns = {
|
|
194
|
+
left: 'text-left',
|
|
195
|
+
center: 'text-center',
|
|
196
|
+
right: 'text-right',
|
|
197
|
+
};
|
|
198
|
+
return aligns[props.align];
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const verticalAlignClasses = computed(() => {
|
|
202
|
+
const aligns = {
|
|
203
|
+
center: 'items-center',
|
|
204
|
+
bottom: 'items-end pb-24',
|
|
205
|
+
};
|
|
206
|
+
return aligns[props.verticalAlign];
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const contentAlignmentClasses = computed(() => {
|
|
210
|
+
const aligns = {
|
|
211
|
+
left: '',
|
|
212
|
+
center: 'mx-auto',
|
|
213
|
+
right: 'ml-auto',
|
|
214
|
+
};
|
|
215
|
+
return aligns[props.align];
|
|
216
|
+
});
|
|
217
|
+
</script>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Transition name="slide-down">
|
|
3
|
+
<div
|
|
4
|
+
v-if="visible"
|
|
5
|
+
:class="[
|
|
6
|
+
'relative z-50',
|
|
7
|
+
bgClasses
|
|
8
|
+
]"
|
|
9
|
+
>
|
|
10
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5">
|
|
11
|
+
<div class="flex items-center justify-center gap-x-4 text-sm">
|
|
12
|
+
<!-- Icon -->
|
|
13
|
+
<UIcon
|
|
14
|
+
v-if="icon"
|
|
15
|
+
:name="icon"
|
|
16
|
+
class="w-5 h-5 flex-shrink-0"
|
|
17
|
+
/>
|
|
18
|
+
|
|
19
|
+
<!-- Message -->
|
|
20
|
+
<p class="text-center">
|
|
21
|
+
<span v-if="badge" class="font-semibold mr-2">{{ badge }}</span>
|
|
22
|
+
<span>{{ message }}</span>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<!-- CTA Link -->
|
|
26
|
+
<NuxtLink
|
|
27
|
+
v-if="link"
|
|
28
|
+
:to="link"
|
|
29
|
+
class="inline-flex items-center gap-1 font-medium hover:underline underline-offset-2"
|
|
30
|
+
>
|
|
31
|
+
{{ linkText }}
|
|
32
|
+
<UIcon name="i-lucide-arrow-right" class="w-4 h-4" />
|
|
33
|
+
</NuxtLink>
|
|
34
|
+
|
|
35
|
+
<!-- Close Button -->
|
|
36
|
+
<button
|
|
37
|
+
v-if="dismissible"
|
|
38
|
+
class="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
|
39
|
+
@click="dismiss"
|
|
40
|
+
>
|
|
41
|
+
<UIcon name="i-lucide-x" class="w-4 h-4" />
|
|
42
|
+
<span class="sr-only">Dismiss</span>
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</Transition>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<script setup>
|
|
51
|
+
const props = defineProps({
|
|
52
|
+
/** Main message text */
|
|
53
|
+
message: {
|
|
54
|
+
type: String,
|
|
55
|
+
required: true,
|
|
56
|
+
},
|
|
57
|
+
/** Optional badge/label before message */
|
|
58
|
+
badge: {
|
|
59
|
+
type: String,
|
|
60
|
+
default: '',
|
|
61
|
+
},
|
|
62
|
+
/** Icon name to display */
|
|
63
|
+
icon: {
|
|
64
|
+
type: String,
|
|
65
|
+
default: '',
|
|
66
|
+
},
|
|
67
|
+
/** CTA link URL */
|
|
68
|
+
link: {
|
|
69
|
+
type: String,
|
|
70
|
+
default: '',
|
|
71
|
+
},
|
|
72
|
+
/** CTA link text */
|
|
73
|
+
linkText: {
|
|
74
|
+
type: String,
|
|
75
|
+
default: 'Learn more',
|
|
76
|
+
},
|
|
77
|
+
/** Color variant */
|
|
78
|
+
variant: {
|
|
79
|
+
type: String,
|
|
80
|
+
default: 'primary',
|
|
81
|
+
validator: (v) => ['primary', 'neutral', 'success', 'warning', 'error'].includes(v),
|
|
82
|
+
},
|
|
83
|
+
/** Allow dismissing the bar */
|
|
84
|
+
dismissible: {
|
|
85
|
+
type: Boolean,
|
|
86
|
+
default: true,
|
|
87
|
+
},
|
|
88
|
+
/** Storage key for dismissed state */
|
|
89
|
+
storageKey: {
|
|
90
|
+
type: String,
|
|
91
|
+
default: 'announcement-dismissed',
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const emit = defineEmits(['dismiss']);
|
|
96
|
+
|
|
97
|
+
const visible = ref(true);
|
|
98
|
+
|
|
99
|
+
onMounted(() => {
|
|
100
|
+
if (props.dismissible && props.storageKey) {
|
|
101
|
+
const dismissed = localStorage.getItem(props.storageKey);
|
|
102
|
+
if (dismissed) {
|
|
103
|
+
visible.value = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const dismiss = () => {
|
|
109
|
+
if (props.storageKey) {
|
|
110
|
+
localStorage.setItem(props.storageKey, 'true');
|
|
111
|
+
}
|
|
112
|
+
visible.value = false;
|
|
113
|
+
emit('dismiss');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const bgClasses = computed(() => {
|
|
117
|
+
const variants = {
|
|
118
|
+
primary: 'bg-primary-500 text-white',
|
|
119
|
+
neutral: 'bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900',
|
|
120
|
+
success: 'bg-green-500 text-white',
|
|
121
|
+
warning: 'bg-yellow-500 text-neutral-900',
|
|
122
|
+
error: 'bg-red-500 text-white',
|
|
123
|
+
};
|
|
124
|
+
return variants[props.variant];
|
|
125
|
+
});
|
|
126
|
+
</script>
|
|
127
|
+
|
|
128
|
+
<style scoped>
|
|
129
|
+
.slide-down-enter-active,
|
|
130
|
+
.slide-down-leave-active {
|
|
131
|
+
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.slide-down-enter-from,
|
|
135
|
+
.slide-down-leave-to {
|
|
136
|
+
transform: translateY(-100%);
|
|
137
|
+
opacity: 0;
|
|
138
|
+
}
|
|
139
|
+
</style>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<footer class="bg-neutral-950 text-white">
|
|
3
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-20">
|
|
4
|
+
<!-- Main Footer Grid -->
|
|
5
|
+
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-8 lg:gap-12">
|
|
6
|
+
<!-- Brand Column -->
|
|
7
|
+
<div class="col-span-2">
|
|
8
|
+
<slot name="brand">
|
|
9
|
+
<NuxtLink v-if="logo?.src" to="/" class="inline-block">
|
|
10
|
+
<img
|
|
11
|
+
:src="logo.src"
|
|
12
|
+
:alt="logo.alt || 'Logo'"
|
|
13
|
+
class="h-8 w-auto"
|
|
14
|
+
/>
|
|
15
|
+
</NuxtLink>
|
|
16
|
+
<p
|
|
17
|
+
v-if="description"
|
|
18
|
+
class="mt-4 text-neutral-400 max-w-xs"
|
|
19
|
+
>
|
|
20
|
+
{{ description }}
|
|
21
|
+
</p>
|
|
22
|
+
</slot>
|
|
23
|
+
|
|
24
|
+
<!-- Social Links -->
|
|
25
|
+
<div v-if="social.length" class="mt-6 flex gap-4">
|
|
26
|
+
<a
|
|
27
|
+
v-for="item in social"
|
|
28
|
+
:key="item.name"
|
|
29
|
+
:href="item.href"
|
|
30
|
+
target="_blank"
|
|
31
|
+
rel="noopener noreferrer"
|
|
32
|
+
class="text-neutral-400 hover:text-primary-400 transition-all duration-300 hover:scale-110"
|
|
33
|
+
>
|
|
34
|
+
<UIcon :name="item.icon" class="w-5 h-5" />
|
|
35
|
+
<span class="sr-only">{{ item.name }}</span>
|
|
36
|
+
</a>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- Link Columns -->
|
|
41
|
+
<div
|
|
42
|
+
v-for="column in columns"
|
|
43
|
+
:key="column.title"
|
|
44
|
+
class="col-span-1"
|
|
45
|
+
>
|
|
46
|
+
<h4 class="font-semibold text-sm uppercase tracking-wider text-neutral-300 mb-4">
|
|
47
|
+
{{ column.title }}
|
|
48
|
+
</h4>
|
|
49
|
+
<ul class="space-y-3">
|
|
50
|
+
<li v-for="link in column.links" :key="link.label">
|
|
51
|
+
<NuxtLink
|
|
52
|
+
:to="link.to"
|
|
53
|
+
class="text-neutral-400 hover:text-white transition-colors text-sm inline-block relative group"
|
|
54
|
+
>
|
|
55
|
+
{{ link.label }}
|
|
56
|
+
<span class="absolute -bottom-0.5 left-0 w-0 h-px bg-primary-500 transition-all duration-300 group-hover:w-full" />
|
|
57
|
+
</NuxtLink>
|
|
58
|
+
</li>
|
|
59
|
+
</ul>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Newsletter Section -->
|
|
64
|
+
<div
|
|
65
|
+
v-if="hasNewsletter"
|
|
66
|
+
class="mt-12 pt-8 border-t border-neutral-800"
|
|
67
|
+
>
|
|
68
|
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
|
69
|
+
<div>
|
|
70
|
+
<h4 class="font-semibold">{{ newsletterTitle }}</h4>
|
|
71
|
+
<p class="mt-1 text-sm text-neutral-400">{{ newsletterDescription }}</p>
|
|
72
|
+
</div>
|
|
73
|
+
<form class="flex gap-2 w-full md:w-auto" @submit.prevent="$emit('newsletter-submit', email)">
|
|
74
|
+
<UInput
|
|
75
|
+
v-model="email"
|
|
76
|
+
type="email"
|
|
77
|
+
placeholder="Enter your email"
|
|
78
|
+
class="flex-1 md:w-64"
|
|
79
|
+
/>
|
|
80
|
+
<UButton type="submit" color="primary">
|
|
81
|
+
Subscribe
|
|
82
|
+
</UButton>
|
|
83
|
+
</form>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<!-- Bottom Bar -->
|
|
88
|
+
<div class="mt-12 pt-8 border-t border-neutral-800 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
|
89
|
+
<p class="text-sm text-neutral-500">
|
|
90
|
+
{{ copyright }}
|
|
91
|
+
</p>
|
|
92
|
+
<div v-if="legalLinks.length" class="flex flex-wrap gap-6">
|
|
93
|
+
<NuxtLink
|
|
94
|
+
v-for="link in legalLinks"
|
|
95
|
+
:key="link.label"
|
|
96
|
+
:to="link.to"
|
|
97
|
+
class="text-sm text-neutral-500 hover:text-neutral-300 transition-colors"
|
|
98
|
+
>
|
|
99
|
+
{{ link.label }}
|
|
100
|
+
</NuxtLink>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</footer>
|
|
105
|
+
</template>
|
|
106
|
+
|
|
107
|
+
<script setup>
|
|
108
|
+
const props = defineProps({
|
|
109
|
+
/** Logo object { src: string, alt?: string } */
|
|
110
|
+
logo: {
|
|
111
|
+
type: Object,
|
|
112
|
+
default: null,
|
|
113
|
+
validator: (v) => !v || typeof v.src === 'string',
|
|
114
|
+
},
|
|
115
|
+
/** Brand description */
|
|
116
|
+
description: {
|
|
117
|
+
type: String,
|
|
118
|
+
default: '',
|
|
119
|
+
},
|
|
120
|
+
/** Social media links */
|
|
121
|
+
social: {
|
|
122
|
+
type: Array,
|
|
123
|
+
default: () => [],
|
|
124
|
+
},
|
|
125
|
+
/** Footer link columns */
|
|
126
|
+
columns: {
|
|
127
|
+
type: Array,
|
|
128
|
+
default: () => [],
|
|
129
|
+
},
|
|
130
|
+
/** Legal/policy links */
|
|
131
|
+
legalLinks: {
|
|
132
|
+
type: Array,
|
|
133
|
+
default: () => [],
|
|
134
|
+
},
|
|
135
|
+
/** Copyright text */
|
|
136
|
+
copyright: {
|
|
137
|
+
type: String,
|
|
138
|
+
default: () => `© ${new Date().getFullYear()} All rights reserved.`,
|
|
139
|
+
},
|
|
140
|
+
/** Has newsletter section */
|
|
141
|
+
hasNewsletter: {
|
|
142
|
+
type: Boolean,
|
|
143
|
+
default: false,
|
|
144
|
+
},
|
|
145
|
+
/** Newsletter title */
|
|
146
|
+
newsletterTitle: {
|
|
147
|
+
type: String,
|
|
148
|
+
default: 'Subscribe to our newsletter',
|
|
149
|
+
},
|
|
150
|
+
/** Newsletter description */
|
|
151
|
+
newsletterDescription: {
|
|
152
|
+
type: String,
|
|
153
|
+
default: 'Get the latest updates and news.',
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
defineEmits(['newsletter-submit']);
|
|
158
|
+
|
|
159
|
+
const email = ref('');
|
|
160
|
+
</script>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<footer class="border-t border-neutral-200 dark:border-neutral-800">
|
|
3
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
4
|
+
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
5
|
+
<!-- Copyright -->
|
|
6
|
+
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
|
7
|
+
{{ copyright }}
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<!-- Legal Links -->
|
|
11
|
+
<nav v-if="links.length" class="flex flex-wrap gap-6">
|
|
12
|
+
<NuxtLink
|
|
13
|
+
v-for="link in links"
|
|
14
|
+
:key="link.label"
|
|
15
|
+
:to="link.to"
|
|
16
|
+
class="text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
|
17
|
+
>
|
|
18
|
+
{{ link.label }}
|
|
19
|
+
</NuxtLink>
|
|
20
|
+
</nav>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</footer>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup>
|
|
27
|
+
defineProps({
|
|
28
|
+
/** Copyright text */
|
|
29
|
+
copyright: {
|
|
30
|
+
type: String,
|
|
31
|
+
default: () => `© ${new Date().getFullYear()} All rights reserved.`,
|
|
32
|
+
},
|
|
33
|
+
/** Legal/policy links */
|
|
34
|
+
links: {
|
|
35
|
+
type: Array,
|
|
36
|
+
default: () => [
|
|
37
|
+
{ label: 'Privacy Policy', to: '/privacy' },
|
|
38
|
+
{ label: 'Terms of Service', to: '/terms' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
</script>
|