@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,41 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="absolute inset-0 pointer-events-none overflow-hidden">
|
|
3
|
+
<div
|
|
4
|
+
class="absolute inset-0 transition-transform duration-100 xParallax"
|
|
5
|
+
data-parallax-speed="0.4"
|
|
6
|
+
:style="{
|
|
7
|
+
opacity: opacity,
|
|
8
|
+
backgroundImage: patternSvg
|
|
9
|
+
}"
|
|
10
|
+
/>
|
|
11
|
+
<div class="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-[#0a0a0a] to-transparent z-10" />
|
|
12
|
+
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-[#0a0a0a] to-transparent z-10" />
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup>
|
|
17
|
+
const props = defineProps({
|
|
18
|
+
/** Pattern opacity (0-1) */
|
|
19
|
+
opacity: {
|
|
20
|
+
type: Number,
|
|
21
|
+
default: 0.4,
|
|
22
|
+
},
|
|
23
|
+
/** Primary pattern color */
|
|
24
|
+
color: {
|
|
25
|
+
type: String,
|
|
26
|
+
default: '#164e63',
|
|
27
|
+
},
|
|
28
|
+
/** Secondary pattern color */
|
|
29
|
+
colorSecondary: {
|
|
30
|
+
type: String,
|
|
31
|
+
default: '#1e3a8a',
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const patternSvg = computed(() => {
|
|
36
|
+
const encodedColor = encodeURIComponent(props.color)
|
|
37
|
+
const encodedColorSecondary = encodeURIComponent(props.colorSecondary)
|
|
38
|
+
return `url('data:image/svg+xml,%3Csvg width="120" height="40" viewBox="0 0 120 40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpattern id="wave" x="0" y="0" width="120" height="40" patternUnits="userSpaceOnUse"%3E%3Cpath d="M0 20 Q30 0 60 20 T120 20" fill="none" stroke="${encodedColor}" stroke-width="1" opacity="0.5"/%3E%3Cpath d="M0 30 Q30 10 60 30 T120 30" fill="none" stroke="${encodedColorSecondary}" stroke-width="1" opacity="0.4"/%3E%3C/pattern%3E%3Crect width="120" height="40" fill="url(%23wave)"/%3E%3C/svg%3E')`
|
|
39
|
+
})
|
|
40
|
+
</script>
|
|
41
|
+
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="py-16 md:py-24" :class="backgroundClasses">
|
|
3
|
+
<UContainer>
|
|
4
|
+
<div class="max-w-2xl mx-auto text-center">
|
|
5
|
+
<h2 v-if="title" class="text-2xl md:text-3xl font-bold mb-4">
|
|
6
|
+
{{ title }}
|
|
7
|
+
</h2>
|
|
8
|
+
<p v-if="description" class="opacity-70 mb-8">
|
|
9
|
+
{{ description }}
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<UForm :state="form" @submit="onSubmit">
|
|
13
|
+
<div class="flex flex-col sm:flex-row gap-4">
|
|
14
|
+
<UFormField name="email" class="flex-1">
|
|
15
|
+
<UInput
|
|
16
|
+
v-model="form.email"
|
|
17
|
+
type="email"
|
|
18
|
+
:placeholder="emailPlaceholder"
|
|
19
|
+
size="xl"
|
|
20
|
+
:ui="inputStyles"
|
|
21
|
+
required
|
|
22
|
+
/>
|
|
23
|
+
</UFormField>
|
|
24
|
+
<UFormField v-if="showZip" name="zip">
|
|
25
|
+
<UInput
|
|
26
|
+
v-model="form.zip"
|
|
27
|
+
:placeholder="zipPlaceholder"
|
|
28
|
+
size="xl"
|
|
29
|
+
:ui="inputStyles"
|
|
30
|
+
/>
|
|
31
|
+
</UFormField>
|
|
32
|
+
<UButton
|
|
33
|
+
type="submit"
|
|
34
|
+
size="xl"
|
|
35
|
+
color="primary"
|
|
36
|
+
:label="buttonLabel"
|
|
37
|
+
:loading="loading"
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
</UForm>
|
|
41
|
+
|
|
42
|
+
<p v-if="disclaimer" class="text-xs opacity-50 mt-4">
|
|
43
|
+
{{ disclaimer }}
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
</UContainer>
|
|
47
|
+
</section>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<script setup>
|
|
51
|
+
const props = defineProps({
|
|
52
|
+
title: {
|
|
53
|
+
type: String,
|
|
54
|
+
default: "",
|
|
55
|
+
},
|
|
56
|
+
description: {
|
|
57
|
+
type: String,
|
|
58
|
+
default: "",
|
|
59
|
+
},
|
|
60
|
+
emailPlaceholder: {
|
|
61
|
+
type: String,
|
|
62
|
+
default: "Email address",
|
|
63
|
+
},
|
|
64
|
+
zipPlaceholder: {
|
|
65
|
+
type: String,
|
|
66
|
+
default: "Zip code",
|
|
67
|
+
},
|
|
68
|
+
buttonLabel: {
|
|
69
|
+
type: String,
|
|
70
|
+
default: "Subscribe",
|
|
71
|
+
},
|
|
72
|
+
disclaimer: {
|
|
73
|
+
type: String,
|
|
74
|
+
default: "",
|
|
75
|
+
},
|
|
76
|
+
showZip: {
|
|
77
|
+
type: Boolean,
|
|
78
|
+
default: false,
|
|
79
|
+
},
|
|
80
|
+
variant: {
|
|
81
|
+
type: String,
|
|
82
|
+
default: "dark",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const emit = defineEmits(["submit"]);
|
|
87
|
+
|
|
88
|
+
const form = reactive({
|
|
89
|
+
email: "",
|
|
90
|
+
zip: "",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const loading = ref(false);
|
|
94
|
+
|
|
95
|
+
const backgroundClasses = computed(() => {
|
|
96
|
+
const variants = {
|
|
97
|
+
dark: "bg-deep-900 text-white",
|
|
98
|
+
light: "bg-gray-50 text-gray-900",
|
|
99
|
+
transparent: "",
|
|
100
|
+
};
|
|
101
|
+
return variants[props.variant] || variants.dark;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const inputStyles = computed(() => {
|
|
105
|
+
if (props.variant === "dark") {
|
|
106
|
+
return {
|
|
107
|
+
root: "bg-white/10 border-white/20 text-white placeholder:text-white/50",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const onSubmit = async () => {
|
|
114
|
+
loading.value = true;
|
|
115
|
+
try {
|
|
116
|
+
emit("submit", { ...form });
|
|
117
|
+
form.email = "";
|
|
118
|
+
form.zip = "";
|
|
119
|
+
} finally {
|
|
120
|
+
loading.value = false;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
</script>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="py-24">
|
|
3
|
+
<main class="container mx-auto">
|
|
4
|
+
<section
|
|
5
|
+
class="py-24 rounded-3xl border-primary border max-w-screen-2xl"
|
|
6
|
+
:class="cardClass"
|
|
7
|
+
:style="cardStyle"
|
|
8
|
+
>
|
|
9
|
+
<div
|
|
10
|
+
class="flex flex-col items-center justify-center"
|
|
11
|
+
v-if="!submitted"
|
|
12
|
+
>
|
|
13
|
+
<h2 class="text-4xl font-bold text-center text-primary">
|
|
14
|
+
{{ heading }}
|
|
15
|
+
</h2>
|
|
16
|
+
<p class="mt-4 text-center text-gray-800 dark:text-gray-100">
|
|
17
|
+
{{ subheading }}
|
|
18
|
+
</p>
|
|
19
|
+
<UForm
|
|
20
|
+
class="mt-8 flex flex-col items-center justify-center w-full max-w-lg"
|
|
21
|
+
:schema="schema"
|
|
22
|
+
:state="state"
|
|
23
|
+
@submit="onSubmit"
|
|
24
|
+
>
|
|
25
|
+
<div class="w-full text-center">
|
|
26
|
+
<UFormField label="" name="email" class="w-full">
|
|
27
|
+
<UInput
|
|
28
|
+
padded
|
|
29
|
+
placeholder="Enter your email"
|
|
30
|
+
icon="i-lucide-mail"
|
|
31
|
+
size="xl"
|
|
32
|
+
class="w-full flex-1"
|
|
33
|
+
v-model="state.email"
|
|
34
|
+
:loading="loading"
|
|
35
|
+
:disabled="disabled"
|
|
36
|
+
/>
|
|
37
|
+
</UFormField>
|
|
38
|
+
<UButton
|
|
39
|
+
type="submit"
|
|
40
|
+
size="xl"
|
|
41
|
+
class="mt-6"
|
|
42
|
+
:label="submitLabel"
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
</UForm>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="text-center text-2xl" v-else>
|
|
48
|
+
<div class="text-3xl font-bold">Done!</div>
|
|
49
|
+
You have successfully subscribed.
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
</main>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<script setup>
|
|
57
|
+
import { z } from "zod";
|
|
58
|
+
|
|
59
|
+
const toast = useToast();
|
|
60
|
+
const props = defineProps({
|
|
61
|
+
heading: {
|
|
62
|
+
type: String,
|
|
63
|
+
default: "Stay Up To Date",
|
|
64
|
+
},
|
|
65
|
+
subheading: {
|
|
66
|
+
type: String,
|
|
67
|
+
default: "Stay up to date with our latest news and product announcements.",
|
|
68
|
+
},
|
|
69
|
+
submitLabel: {
|
|
70
|
+
type: String,
|
|
71
|
+
default: "Subscribe",
|
|
72
|
+
},
|
|
73
|
+
cardClass: {
|
|
74
|
+
type: String,
|
|
75
|
+
default: `bg-fit bg-center bg-no-repeat bg-primary/5`,
|
|
76
|
+
},
|
|
77
|
+
cardStyle: {
|
|
78
|
+
type: String,
|
|
79
|
+
default: `background-image: url('https://cdn.x.enterprises/bg-subscription-poof2.webp');`,
|
|
80
|
+
},
|
|
81
|
+
organizationId: {
|
|
82
|
+
type: String,
|
|
83
|
+
required: true,
|
|
84
|
+
},
|
|
85
|
+
endpoint: {
|
|
86
|
+
type: String,
|
|
87
|
+
default: "https://api.emailmarketingnewsletters.com",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const schema = z.object({
|
|
92
|
+
email: z.string().email("Invalid email"),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const state = reactive({
|
|
96
|
+
email: undefined,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
async function onSubmit(event) {
|
|
100
|
+
loading.value = true;
|
|
101
|
+
disabled.value = true;
|
|
102
|
+
try {
|
|
103
|
+
await $fetch(`${props.endpoint}/public/subscriptions/subscribe`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
email: state.email,
|
|
107
|
+
organizationId: props.organizationId,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
submitted.value = true;
|
|
111
|
+
toast.add({
|
|
112
|
+
icon: "i-lucide-badge-check",
|
|
113
|
+
title: "You have successfully subscribed to our newsletter.",
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.log(error);
|
|
117
|
+
toast.add({
|
|
118
|
+
icon: "i-lucide-alert-circle",
|
|
119
|
+
title: `An error occurred or you've already subscribed.`,
|
|
120
|
+
});
|
|
121
|
+
} finally {
|
|
122
|
+
loading.value = false;
|
|
123
|
+
disabled.value = false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const loading = ref(false);
|
|
128
|
+
const disabled = ref(false);
|
|
129
|
+
const submitted = ref(false);
|
|
130
|
+
</script>
|
|
131
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallax Scroll Effect Composable
|
|
3
|
+
* Implements smooth parallax scrolling for background elements
|
|
4
|
+
* From spec-prompt.md specification
|
|
5
|
+
*/
|
|
6
|
+
export const useParallax = (options = {}) => {
|
|
7
|
+
const defaultOptions = {
|
|
8
|
+
defaultSpeed: 0.5,
|
|
9
|
+
...options,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const elements = ref<Element[]>([]);
|
|
13
|
+
|
|
14
|
+
const initParallax = () => {
|
|
15
|
+
if (process.client) {
|
|
16
|
+
// Find all elements with xParallax class and data-parallax-speed attribute
|
|
17
|
+
const parallaxElements = document.querySelectorAll('.xParallax[data-parallax-speed]');
|
|
18
|
+
elements.value = Array.from(parallaxElements);
|
|
19
|
+
|
|
20
|
+
if (elements.value.length === 0) return;
|
|
21
|
+
|
|
22
|
+
const updateParallax = () => {
|
|
23
|
+
const scrollY = window.scrollY;
|
|
24
|
+
|
|
25
|
+
elements.value.forEach((el) => {
|
|
26
|
+
const speed = parseFloat((el as HTMLElement).dataset.parallaxSpeed || String(defaultOptions.defaultSpeed));
|
|
27
|
+
const rect = el.getBoundingClientRect();
|
|
28
|
+
const offsetTop = rect.top + scrollY;
|
|
29
|
+
|
|
30
|
+
// Calculate the parallax offset based on scroll position relative to element
|
|
31
|
+
const offset = (scrollY - offsetTop) * speed;
|
|
32
|
+
|
|
33
|
+
// Apply the transform
|
|
34
|
+
(el as HTMLElement).style.transform = `translateY(${offset}px)`;
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Use requestAnimationFrame for smoother updates
|
|
39
|
+
let ticking = false;
|
|
40
|
+
const handleScroll = () => {
|
|
41
|
+
if (!ticking) {
|
|
42
|
+
window.requestAnimationFrame(() => {
|
|
43
|
+
updateParallax();
|
|
44
|
+
ticking = false;
|
|
45
|
+
});
|
|
46
|
+
ticking = true;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
51
|
+
|
|
52
|
+
// Initial update
|
|
53
|
+
updateParallax();
|
|
54
|
+
|
|
55
|
+
onUnmounted(() => {
|
|
56
|
+
window.removeEventListener('scroll', handleScroll);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const cleanup = () => {
|
|
62
|
+
elements.value = [];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
onMounted(() => {
|
|
66
|
+
// Small delay to ensure DOM is ready
|
|
67
|
+
setTimeout(initParallax, 100);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
onUnmounted(() => {
|
|
71
|
+
cleanup();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
initParallax,
|
|
76
|
+
cleanup,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Hook for individual element parallax
|
|
82
|
+
* Usage: const { elementRef } = useElementParallax(0.3)
|
|
83
|
+
*/
|
|
84
|
+
export const useElementParallax = (speed = 0.5) => {
|
|
85
|
+
const elementRef = ref<HTMLElement | null>(null);
|
|
86
|
+
|
|
87
|
+
onMounted(() => {
|
|
88
|
+
if (process.client && elementRef.value) {
|
|
89
|
+
let ticking = false;
|
|
90
|
+
|
|
91
|
+
const updateParallax = () => {
|
|
92
|
+
if (!elementRef.value) return;
|
|
93
|
+
|
|
94
|
+
const scrollY = window.scrollY;
|
|
95
|
+
const rect = elementRef.value.getBoundingClientRect();
|
|
96
|
+
const offsetTop = rect.top + scrollY;
|
|
97
|
+
const offset = (scrollY - offsetTop) * speed;
|
|
98
|
+
|
|
99
|
+
elementRef.value.style.transform = `translateY(${offset}px)`;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleScroll = () => {
|
|
103
|
+
if (!ticking) {
|
|
104
|
+
window.requestAnimationFrame(() => {
|
|
105
|
+
updateParallax();
|
|
106
|
+
ticking = false;
|
|
107
|
+
});
|
|
108
|
+
ticking = true;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
113
|
+
updateParallax();
|
|
114
|
+
|
|
115
|
+
onUnmounted(() => {
|
|
116
|
+
window.removeEventListener('scroll', handleScroll);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
elementRef,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Premium Scroll Reveal Animation Composable
|
|
3
|
+
* Implements intersection observer for smooth fade-in animations
|
|
4
|
+
* Design Brief: 300-400ms transitions with ease-out curves
|
|
5
|
+
*
|
|
6
|
+
* Supports:
|
|
7
|
+
* - [data-reveal] attribute (legacy)
|
|
8
|
+
* - .xFadeUp, .xFadeIn, .xFadeLeft, .xFadeRight, .xScale classes
|
|
9
|
+
* - .xFadeUp-stagger for staggered child animations
|
|
10
|
+
*/
|
|
11
|
+
export const useScrollReveal = (options = {}) => {
|
|
12
|
+
const defaultOptions = {
|
|
13
|
+
threshold: 0.15,
|
|
14
|
+
rootMargin: '0px 0px -80px 0px',
|
|
15
|
+
...options,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const observer = ref<IntersectionObserver | null>(null);
|
|
19
|
+
|
|
20
|
+
// Selector for all animation elements
|
|
21
|
+
const animationSelectors = [
|
|
22
|
+
'[data-reveal]',
|
|
23
|
+
'.xFadeUp',
|
|
24
|
+
'.xFadeIn',
|
|
25
|
+
'.xFadeLeft',
|
|
26
|
+
'.xFadeRight',
|
|
27
|
+
'.xScale',
|
|
28
|
+
'.xFadeUp-stagger',
|
|
29
|
+
].join(', ');
|
|
30
|
+
|
|
31
|
+
const initScrollReveal = () => {
|
|
32
|
+
if (process.client) {
|
|
33
|
+
observer.value = new IntersectionObserver((entries) => {
|
|
34
|
+
entries.forEach((entry) => {
|
|
35
|
+
if (entry.isIntersecting) {
|
|
36
|
+
entry.target.classList.add('is-visible');
|
|
37
|
+
// Optionally unobserve after animation
|
|
38
|
+
if (options.once !== false) {
|
|
39
|
+
observer.value?.unobserve(entry.target);
|
|
40
|
+
}
|
|
41
|
+
} else if (options.once === false) {
|
|
42
|
+
// Re-hide if not in view and once is false
|
|
43
|
+
entry.target.classList.remove('is-visible');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}, defaultOptions);
|
|
47
|
+
|
|
48
|
+
// Observe all animation elements
|
|
49
|
+
const elements = document.querySelectorAll(animationSelectors);
|
|
50
|
+
elements.forEach((el) => observer.value?.observe(el));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const cleanup = () => {
|
|
55
|
+
if (observer.value) {
|
|
56
|
+
observer.value.disconnect();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
onMounted(() => {
|
|
61
|
+
// Small delay to ensure DOM is ready
|
|
62
|
+
setTimeout(initScrollReveal, 100);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
onUnmounted(() => {
|
|
66
|
+
cleanup();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
initScrollReveal,
|
|
71
|
+
cleanup,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Helper to add staggered reveal delays to child elements
|
|
77
|
+
*/
|
|
78
|
+
export const useStaggerReveal = (containerSelector: string, staggerDelay = 100) => {
|
|
79
|
+
onMounted(() => {
|
|
80
|
+
if (process.client) {
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
const container = document.querySelector(containerSelector);
|
|
83
|
+
if (container) {
|
|
84
|
+
const children = container.querySelectorAll('[data-reveal]');
|
|
85
|
+
children.forEach((child, index) => {
|
|
86
|
+
(child as HTMLElement).style.transitionDelay = `${index * staggerDelay}ms`;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}, 100);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
};
|