@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.
Files changed (115) hide show
  1. package/.claude/settings.local.json +22 -0
  2. package/.playground/app.config.ts +44 -45
  3. package/.playground/app.vue +263 -0
  4. package/.playground/components/AppFooter.vue +103 -0
  5. package/.playground/components/AppHeader.vue +41 -0
  6. package/.playground/components/CodeExample.vue +70 -0
  7. package/.playground/components/ComponentPlayground.vue +117 -0
  8. package/.playground/components/DocsHeader.vue +19 -0
  9. package/.playground/layouts/default.vue +13 -3
  10. package/.playground/layouts/docs-layout.vue +161 -0
  11. package/.playground/layouts/docs.vue +11 -0
  12. package/.playground/package-lock.json +6 -0
  13. package/.playground/pages/blog/author.vue +145 -0
  14. package/.playground/pages/blog/card.vue +149 -0
  15. package/.playground/pages/blog/cta.vue +131 -0
  16. package/.playground/pages/blog/detail.vue +379 -0
  17. package/.playground/pages/blog/list.vue +261 -0
  18. package/.playground/pages/blog/navigation.vue +228 -0
  19. package/.playground/pages/blog/sidebar.vue +279 -0
  20. package/.playground/pages/config.vue +289 -0
  21. package/.playground/pages/content/comparison.vue +253 -0
  22. package/.playground/pages/content/cta.vue +245 -0
  23. package/.playground/pages/content/features.vue +217 -0
  24. package/.playground/pages/content/newsletter-section.vue +260 -0
  25. package/.playground/pages/content/newsletter.vue +200 -0
  26. package/.playground/pages/content/pricing.vue +335 -0
  27. package/.playground/pages/content/testimonials.vue +188 -0
  28. package/.playground/pages/hero.vue +257 -0
  29. package/.playground/pages/index.vue +379 -2
  30. package/.playground/pages/layout/footer.vue +210 -0
  31. package/.playground/pages/layout/legal-footer.vue +121 -0
  32. package/.playground/pages/layout/navbar.vue +285 -0
  33. package/.playground/pages/layout/section.vue +266 -0
  34. package/.playground/pages/layout/x-legal.vue +199 -0
  35. package/.playground/pages/modals/feature-modal.vue +320 -0
  36. package/.playground/pages/modals/image-lightbox.vue +277 -0
  37. package/.playground/pages/modals/video-modal.vue +222 -0
  38. package/.playground/pages/notifications/announcement-bar.vue +164 -0
  39. package/.playground/pages/notifications/cookie-banner.vue +210 -0
  40. package/.playground/pages/notifications/cookie-toast.vue +143 -0
  41. package/.playground/pages/notifications/gdpr.vue +276 -0
  42. package/.playground/pages/notifications/social-proof-toast.vue +255 -0
  43. package/.playground/pages/ui/badge.vue +161 -0
  44. package/.playground/pages/ui/bg-pattern.vue +177 -0
  45. package/.playground/pages/ui/glass-card.vue +179 -0
  46. package/.playground/pages/ui/glow-divider.vue +179 -0
  47. package/.playground/pages/ui/promo-card.vue +259 -0
  48. package/.playground/pages/ui/section-stitch.vue +411 -0
  49. package/.playground/pages/utilities/back-to-top.vue +153 -0
  50. package/.playground/pages/utilities/popup-chat.vue +224 -0
  51. package/README.md +799 -164
  52. package/app/app.config.ts +88 -2
  53. package/app/app.vue +106 -11
  54. package/app/assets/css/x-marketing.css +668 -0
  55. package/app/components/X/Footer/XLegal/index.vue +63 -26
  56. package/app/components/X/Footer/index.vue +89 -89
  57. package/app/components/X/Header/App.vue +63 -0
  58. package/app/components/X/Mark/Blog/Author.vue +170 -0
  59. package/app/components/X/Mark/Blog/CTANewsletter.vue +197 -0
  60. package/app/components/X/Mark/Blog/Card.vue +117 -0
  61. package/app/components/X/Mark/Blog/Detail.vue +240 -0
  62. package/app/components/X/Mark/Blog/List.vue +156 -0
  63. package/app/components/X/Mark/Blog/Navigation.vue +100 -0
  64. package/app/components/X/Mark/Blog/Sidebar.vue +160 -0
  65. package/app/components/X/Mark/Button/BackToTop.vue +99 -0
  66. package/app/components/X/Mark/CTA/index.vue +294 -0
  67. package/app/components/X/Mark/Card/Glass.vue +64 -0
  68. package/app/components/X/Mark/Card/Promo.vue +164 -0
  69. package/app/components/X/Mark/Divider/Glow.vue +113 -0
  70. package/app/components/X/Mark/Features/index.vue +194 -0
  71. package/app/components/X/Mark/Hero/index.vue +217 -0
  72. package/app/components/X/Mark/Layout/AnnouncementBar.vue +139 -0
  73. package/app/components/X/Mark/Layout/Footer.vue +160 -0
  74. package/app/components/X/Mark/Layout/FooterLegal.vue +42 -0
  75. package/app/components/X/Mark/Layout/Navbar.vue +267 -0
  76. package/app/components/X/Mark/Modal/Chat.vue +151 -0
  77. package/app/components/X/Mark/Modal/Feature.vue +197 -0
  78. package/app/components/X/Mark/Modal/Image.vue +226 -0
  79. package/app/components/X/Mark/Modal/Video.vue +203 -0
  80. package/app/components/X/Mark/Modal/index.vue +133 -0
  81. package/app/components/X/Mark/Newsletter/Form.vue +174 -0
  82. package/app/components/X/Mark/Pattern/Bg.vue +86 -0
  83. package/app/components/X/Mark/Pricing/Comparison.vue +126 -0
  84. package/app/components/X/Mark/Pricing/Plans.vue +181 -0
  85. package/app/components/X/Mark/Privacy/CookieBanner.vue +112 -0
  86. package/app/components/X/Mark/Privacy/CookieToast.vue +43 -0
  87. package/app/components/X/Mark/Privacy/GDPR.vue +245 -0
  88. package/app/components/X/Mark/Section/Newsletter.vue +183 -0
  89. package/app/components/X/Mark/Section/Stitch.vue +156 -0
  90. package/app/components/X/Mark/Section/index.vue +151 -0
  91. package/app/components/X/Mark/SocialProof/Badge.vue +102 -0
  92. package/app/components/X/Mark/SocialProof/Testimonials.vue +326 -0
  93. package/app/components/X/Mark/SocialProof/Toast.vue +136 -0
  94. package/app/components/X/Mark/_archive/Pattern/Background.vue +83 -0
  95. package/app/components/X/Mark/_archive/Pattern/CheckBg.vue +35 -0
  96. package/app/components/X/Mark/_archive/Pattern/CircuitBg.vue +41 -0
  97. package/app/components/X/Mark/_archive/Pattern/DotsBg.vue +35 -0
  98. package/app/components/X/Mark/_archive/Pattern/GlowDivider.vue +86 -0
  99. package/app/components/X/Mark/_archive/Pattern/HexBg.vue +35 -0
  100. package/app/components/X/Mark/_archive/Pattern/PlaidBg.vue +47 -0
  101. package/app/components/X/Mark/_archive/Pattern/SectionStitch.vue +168 -0
  102. package/app/components/X/Mark/_archive/Pattern/WaveBg.vue +41 -0
  103. package/app/components/X/_archive/Newsletter/Form.vue +123 -0
  104. package/app/components/X/_archive/Newsletter/Section.vue +131 -0
  105. package/app/composables/useParallax.ts +124 -0
  106. package/app/composables/useScrollReveal.ts +92 -0
  107. package/app/pages/index.vue +210 -0
  108. package/app/plugins/marketing.client.ts +13 -0
  109. package/app/types/marketing.d.ts +268 -0
  110. package/componentList.json +511 -0
  111. package/nuxt.config.ts +6 -13
  112. package/package.json +21 -9
  113. package/.editorconfig +0 -12
  114. package/app/assets/css/main.css +0 -2
  115. package/app/components/X/Marketing/Sections/EmailSubscription/index.vue +0 -129
@@ -0,0 +1,174 @@
1
+ <template>
2
+ <div :class="containerClasses">
3
+ <!-- Default Variant -->
4
+ <template v-if="variant === 'default'">
5
+ <div class="text-center max-w-2xl mx-auto">
6
+ <h3 class="xText-headline text-neutral-900 dark:text-white">
7
+ {{ title }}
8
+ </h3>
9
+ <p v-if="description" class="mt-4 text-lg text-neutral-600 dark:text-neutral-400">
10
+ {{ description }}
11
+ </p>
12
+
13
+ <form class="mt-8 flex flex-col sm:flex-row gap-3 max-w-md mx-auto" @submit.prevent="handleSubmit">
14
+ <UInput
15
+ v-model="email"
16
+ type="email"
17
+ placeholder="Enter your email"
18
+ class="flex-1"
19
+ required
20
+ />
21
+ <UButton type="submit" :loading="loading" :color="button?.color || 'primary'">
22
+ {{ button?.label || 'Subscribe' }}
23
+ </UButton>
24
+ </form>
25
+
26
+ <p v-if="hasPrivacy" class="mt-4 text-sm text-neutral-500">
27
+ No spam. Unsubscribe anytime.
28
+ </p>
29
+ </div>
30
+ </template>
31
+
32
+ <!-- Inline Variant -->
33
+ <template v-else-if="variant === 'inline'">
34
+ <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
35
+ <div>
36
+ <h3 class="text-xl font-semibold text-neutral-900 dark:text-white">
37
+ {{ title }}
38
+ </h3>
39
+ <p v-if="description" class="mt-1 text-neutral-600 dark:text-neutral-400">
40
+ {{ description }}
41
+ </p>
42
+ </div>
43
+
44
+ <form class="flex gap-2 flex-shrink-0" @submit.prevent="handleSubmit">
45
+ <UInput
46
+ v-model="email"
47
+ type="email"
48
+ placeholder="Enter your email"
49
+ class="w-64"
50
+ required
51
+ />
52
+ <UButton type="submit" :loading="loading" :color="button?.color || 'primary'">
53
+ {{ button?.label || 'Subscribe' }}
54
+ </UButton>
55
+ </form>
56
+ </div>
57
+ </template>
58
+
59
+ <!-- Card Variant -->
60
+ <template v-else-if="variant === 'card'">
61
+ <div class="rounded-2xl bg-primary-50 dark:bg-primary-950/30 p-8 md:p-12">
62
+ <div class="max-w-xl mx-auto text-center">
63
+ <h3 class="xText-title text-neutral-900 dark:text-white">
64
+ {{ title }}
65
+ </h3>
66
+ <p v-if="description" class="mt-3 text-neutral-600 dark:text-neutral-400">
67
+ {{ description }}
68
+ </p>
69
+
70
+ <form class="mt-6 flex flex-col sm:flex-row gap-3" @submit.prevent="handleSubmit">
71
+ <UInput
72
+ v-model="email"
73
+ type="email"
74
+ placeholder="Enter your email"
75
+ class="flex-1"
76
+ required
77
+ />
78
+ <UButton type="submit" :loading="loading" :color="button?.color || 'primary'">
79
+ {{ button?.label || 'Subscribe' }}
80
+ </UButton>
81
+ </form>
82
+
83
+ <p v-if="hasPrivacy" class="mt-4 text-sm text-neutral-500">
84
+ No spam. Unsubscribe anytime.
85
+ </p>
86
+ </div>
87
+ </div>
88
+ </template>
89
+
90
+ <!-- Compact Variant -->
91
+ <template v-else-if="variant === 'compact'">
92
+ <div class="rounded-xl bg-neutral-100 dark:bg-neutral-900 p-6">
93
+ <h4 class="font-semibold text-neutral-900 dark:text-white">
94
+ {{ title }}
95
+ </h4>
96
+ <p v-if="description" class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
97
+ {{ description }}
98
+ </p>
99
+
100
+ <form class="mt-4 space-y-3" @submit.prevent="handleSubmit">
101
+ <UInput
102
+ v-model="email"
103
+ type="email"
104
+ placeholder="Enter your email"
105
+ class="w-full"
106
+ required
107
+ />
108
+ <UButton type="submit" :loading="loading" :color="button?.color || 'primary'" block>
109
+ {{ button?.label || 'Subscribe' }}
110
+ </UButton>
111
+ </form>
112
+
113
+ <p v-if="hasPrivacy" class="mt-3 text-xs text-neutral-500">
114
+ No spam. Unsubscribe anytime.
115
+ </p>
116
+ </div>
117
+ </template>
118
+ </div>
119
+ </template>
120
+
121
+ <script setup>
122
+ const props = defineProps({
123
+ /** CTA title */
124
+ title: {
125
+ type: String,
126
+ default: 'Subscribe to our newsletter',
127
+ },
128
+ /** CTA description */
129
+ description: {
130
+ type: String,
131
+ default: '',
132
+ },
133
+ /** Button config { label: string, color?: string } */
134
+ button: {
135
+ type: Object,
136
+ default: () => ({ label: 'Subscribe' }),
137
+ },
138
+ /** Style variant */
139
+ variant: {
140
+ type: String,
141
+ default: 'default',
142
+ validator: (v) => ['default', 'inline', 'card', 'compact'].includes(v),
143
+ },
144
+ /** Has privacy note */
145
+ hasPrivacy: {
146
+ type: Boolean,
147
+ default: true,
148
+ },
149
+ });
150
+
151
+ const emit = defineEmits(['submit']);
152
+
153
+ const email = ref('');
154
+ const loading = ref(false);
155
+
156
+ const containerClasses = computed(() => {
157
+ if (props.variant === 'default') {
158
+ return '';
159
+ }
160
+ return '';
161
+ });
162
+
163
+ const handleSubmit = async () => {
164
+ if (!email.value) return;
165
+
166
+ loading.value = true;
167
+ emit('submit', email.value);
168
+
169
+ // Reset after emit (parent handles actual submission)
170
+ setTimeout(() => {
171
+ loading.value = false;
172
+ }, 1000);
173
+ };
174
+ </script>
@@ -0,0 +1,86 @@
1
+ <template>
2
+ <div
3
+ class="absolute inset-0 pointer-events-none"
4
+ :style="{ opacity: opacity }"
5
+ >
6
+ <div
7
+ class="absolute inset-0"
8
+ :style="{
9
+ backgroundImage: patternSvg,
10
+ maskImage: fade ? 'linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%)' : undefined,
11
+ WebkitMaskImage: fade ? 'linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%)' : undefined,
12
+ }"
13
+ />
14
+ </div>
15
+ </template>
16
+
17
+ <script setup>
18
+ const props = defineProps({
19
+ /** Pattern type */
20
+ pattern: {
21
+ type: String,
22
+ default: 'dots',
23
+ validator: (v) => ['dots', 'grid', 'diagonal', 'topography', 'circuit', 'waves', 'plaid', 'diagonalPlaid', 'hexagons', 'triangles', 'crosses', 'zigzag', 'diamonds'].includes(v),
24
+ },
25
+ /** Pattern opacity (0-1) */
26
+ opacity: {
27
+ type: Number,
28
+ default: 0.05,
29
+ },
30
+ /** Pattern color - 'auto' will use black in light mode and white in dark mode */
31
+ color: {
32
+ type: String,
33
+ default: 'auto',
34
+ },
35
+ /** Fade pattern at top and bottom edges */
36
+ fade: {
37
+ type: Boolean,
38
+ default: true,
39
+ },
40
+ })
41
+
42
+ // Get color mode for auto color switching
43
+ const colorMode = useColorMode()
44
+
45
+ const patternSvg = computed(() => {
46
+ // Determine the color value based on props and color mode
47
+ let colorValue
48
+ if (props.color === 'auto' || props.color === 'currentColor') {
49
+ // Use white in dark mode, black in light mode
50
+ colorValue = colorMode.value === 'dark' ? 'rgb(255,255,255)' : 'rgb(0,0,0)'
51
+ } else {
52
+ colorValue = props.color
53
+ }
54
+ const color = encodeURIComponent(colorValue)
55
+
56
+ const patterns = {
57
+ dots: `url('data:image/svg+xml,%3Csvg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"%3E%3Ccircle cx="10" cy="10" r="1.5" fill="${color}"/%3E%3C/svg%3E')`,
58
+
59
+ grid: `url('data:image/svg+xml,%3Csvg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 0h40v40H0z" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3C/svg%3E')`,
60
+
61
+ diagonal: `url('data:image/svg+xml,%3Csvg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 40L40 0M-10 10L10 -10M30 50L50 30" stroke="${color}" stroke-width="0.5" fill="none"/%3E%3C/svg%3E')`,
62
+
63
+ topography: `url('data:image/svg+xml,%3Csvg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 50 Q25 30 50 50 T100 50" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3Cpath d="M0 70 Q25 50 50 70 T100 70" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3Cpath d="M0 30 Q25 10 50 30 T100 30" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3C/svg%3E')`,
64
+
65
+ circuit: `url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 30h20M40 30h20M30 0v20M30 40v20" stroke="${color}" stroke-width="0.5" fill="none"/%3E%3Ccircle cx="30" cy="30" r="3" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3C/svg%3E')`,
66
+
67
+ waves: `url('data:image/svg+xml,%3Csvg width="120" height="40" viewBox="0 0 120 40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 20 Q30 0 60 20 T120 20" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3C/svg%3E')`,
68
+
69
+ plaid: `url('data:image/svg+xml,%3Csvg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"%3E%3Crect width="40" height="40" fill="none"/%3E%3Cline x1="0" y1="10" x2="40" y2="10" stroke="${color}" stroke-width="0.5"/%3E%3Cline x1="0" y1="30" x2="40" y2="30" stroke="${color}" stroke-width="0.5"/%3E%3Cline x1="10" y1="0" x2="10" y2="40" stroke="${color}" stroke-width="0.5"/%3E%3Cline x1="30" y1="0" x2="30" y2="40" stroke="${color}" stroke-width="0.5"/%3E%3Cline x1="0" y1="20" x2="40" y2="20" stroke="${color}" stroke-width="0.25" opacity="0.5"/%3E%3Cline x1="20" y1="0" x2="20" y2="40" stroke="${color}" stroke-width="0.25" opacity="0.5"/%3E%3C/svg%3E')`,
70
+
71
+ diagonalPlaid: `url('data:image/svg+xml,%3Csvg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 0L40 40M-10 30L30 -10M10 50L50 10" stroke="${color}" stroke-width="0.5" fill="none"/%3E%3Cpath d="M40 0L0 40M50 30L10 -10M30 50L-10 10" stroke="${color}" stroke-width="0.5" fill="none"/%3E%3C/svg%3E')`,
72
+
73
+ hexagons: `url('data:image/svg+xml,%3Csvg width="56" height="100" viewBox="0 0 56 100" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M28 0L56 14v28L28 56 0 42V14L28 0zM28 44L56 58v28L28 100 0 86V58L28 44z" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3C/svg%3E')`,
74
+
75
+ triangles: `url('data:image/svg+xml,%3Csvg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 40L20 0L40 40Z" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3C/svg%3E')`,
76
+
77
+ crosses: `url('data:image/svg+xml,%3Csvg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M20 15v10M15 20h10" stroke="${color}" stroke-width="0.75" fill="none"/%3E%3C/svg%3E')`,
78
+
79
+ zigzag: `url('data:image/svg+xml,%3Csvg width="40" height="20" viewBox="0 0 40 20" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 10L10 0L20 10L30 0L40 10" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3C/svg%3E')`,
80
+
81
+ diamonds: `url('data:image/svg+xml,%3Csvg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M20 0L40 20L20 40L0 20Z" fill="none" stroke="${color}" stroke-width="0.5"/%3E%3C/svg%3E')`,
82
+ }
83
+
84
+ return patterns[props.pattern] || patterns.dots
85
+ })
86
+ </script>
@@ -0,0 +1,126 @@
1
+ <template>
2
+ <div>
3
+ <!-- Badges (top position) -->
4
+ <div
5
+ v-if="badges?.length && badgesPosition === 'top'"
6
+ class="flex flex-wrap gap-4 mb-8"
7
+ :class="badgesAlignClasses"
8
+ >
9
+ <XMarkSocialProofBadge
10
+ v-for="badge in badges"
11
+ :key="typeof badge === 'string' ? badge : badge.preset || badge.label"
12
+ v-bind="getBadgeProps(badge)"
13
+ variant="text-muted"
14
+ />
15
+ </div>
16
+
17
+ <!-- Pricing Table -->
18
+ <UPricingTable
19
+ :tiers="mappedTiers"
20
+ :sections="mappedSections"
21
+ :caption="caption"
22
+ />
23
+
24
+ <!-- Badges (bottom position) -->
25
+ <div
26
+ v-if="badges?.length && badgesPosition === 'bottom'"
27
+ class="flex flex-wrap gap-4 mt-8"
28
+ :class="badgesAlignClasses"
29
+ >
30
+ <XMarkSocialProofBadge
31
+ v-for="badge in badges"
32
+ :key="typeof badge === 'string' ? badge : badge.preset || badge.label"
33
+ v-bind="getBadgeProps(badge)"
34
+ variant="text-muted"
35
+ />
36
+ </div>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup>
41
+ const props = defineProps({
42
+ /** Array of plans to compare */
43
+ plans: {
44
+ type: Array,
45
+ required: true,
46
+ // Each plan: { id, name, price?, highlighted?, button? }
47
+ },
48
+ /** Feature groups with features */
49
+ featureGroups: {
50
+ type: Array,
51
+ required: true,
52
+ // Each group: { name?, features: [{ name, tooltip?, values: { [planId]: boolean|string } }] }
53
+ },
54
+ /** Caption for the table */
55
+ caption: {
56
+ type: String,
57
+ default: undefined,
58
+ },
59
+ /** Social proof badge presets or custom badges */
60
+ badges: {
61
+ type: Array,
62
+ default: () => [],
63
+ },
64
+ /** Position of badges */
65
+ badgesPosition: {
66
+ type: String,
67
+ default: 'bottom',
68
+ validator: (v) => ['top', 'bottom'].includes(v),
69
+ },
70
+ /** Alignment of badges */
71
+ badgesAlign: {
72
+ type: String,
73
+ default: 'center',
74
+ validator: (v) => ['left', 'center', 'right'].includes(v),
75
+ },
76
+ });
77
+
78
+ const emit = defineEmits(['select']);
79
+
80
+ // Map XMark plans to Nuxt UI tiers format
81
+ const mappedTiers = computed(() => {
82
+ return props.plans.map((plan) => ({
83
+ id: plan.id,
84
+ title: plan.name,
85
+ description: plan.description,
86
+ price: plan.price,
87
+ highlight: plan.highlighted,
88
+ button: plan.button ? {
89
+ label: plan.button.label || 'Get Started',
90
+ color: plan.highlighted ? 'primary' : 'neutral',
91
+ variant: plan.highlighted ? 'solid' : 'outline',
92
+ onClick: () => emit('select', plan),
93
+ } : undefined,
94
+ }));
95
+ });
96
+
97
+ // Map XMark featureGroups to Nuxt UI sections format
98
+ const mappedSections = computed(() => {
99
+ return props.featureGroups.map((group, index) => ({
100
+ id: `section-${index}`,
101
+ title: group.name,
102
+ features: group.features.map((feature) => ({
103
+ title: feature.name,
104
+ tiers: feature.values,
105
+ })),
106
+ }));
107
+ });
108
+
109
+ // Badge alignment classes
110
+ const badgesAlignClasses = computed(() => {
111
+ const alignments = {
112
+ left: 'justify-start',
113
+ center: 'justify-center',
114
+ right: 'justify-end',
115
+ };
116
+ return alignments[props.badgesAlign];
117
+ });
118
+
119
+ // Get badge props (handles string presets and object configs)
120
+ const getBadgeProps = (badge) => {
121
+ if (typeof badge === 'string') {
122
+ return { preset: badge };
123
+ }
124
+ return badge;
125
+ };
126
+ </script>
@@ -0,0 +1,181 @@
1
+ <template>
2
+ <div>
3
+ <!-- Billing Toggle -->
4
+ <div v-if="hasBillingToggle" class="flex justify-center mb-12">
5
+ <div class="inline-flex items-center gap-4 rounded-full bg-neutral-100 dark:bg-neutral-900 p-1">
6
+ <button
7
+ class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
8
+ :class="billingPeriod === 'monthly' ? 'bg-white dark:bg-neutral-800 shadow-sm' : 'text-neutral-600 dark:text-neutral-400'"
9
+ @click="billingPeriod = 'monthly'"
10
+ >
11
+ Monthly
12
+ </button>
13
+ <button
14
+ class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
15
+ :class="billingPeriod === 'yearly' ? 'bg-white dark:bg-neutral-800 shadow-sm' : 'text-neutral-600 dark:text-neutral-400'"
16
+ @click="billingPeriod = 'yearly'"
17
+ >
18
+ Yearly
19
+ <span v-if="yearlyDiscount" class="ml-1 text-xs text-primary-500">
20
+ Save {{ yearlyDiscount }}%
21
+ </span>
22
+ </button>
23
+ </div>
24
+ </div>
25
+
26
+ <!-- Badges (top position) -->
27
+ <div
28
+ v-if="badges?.length && badgesPosition === 'top'"
29
+ class="flex flex-wrap gap-4 mb-8"
30
+ :class="badgesAlignClasses"
31
+ >
32
+ <XMarkSocialProofBadge
33
+ v-for="badge in badges"
34
+ :key="typeof badge === 'string' ? badge : badge.preset || badge.label"
35
+ v-bind="getBadgeProps(badge)"
36
+ variant="text-muted"
37
+ />
38
+ </div>
39
+
40
+ <!-- Pricing Plans -->
41
+ <!-- Note: compact and scale on container are mutually exclusive -->
42
+ <UPricingPlans
43
+ :plans="mappedPlans"
44
+ :scale="scale && !compact"
45
+ :compact="compact && !scale"
46
+ >
47
+ <slot />
48
+ </UPricingPlans>
49
+
50
+ <!-- Badges (bottom position) -->
51
+ <div
52
+ v-if="badges?.length && badgesPosition === 'bottom'"
53
+ class="flex flex-wrap gap-4 mt-8"
54
+ :class="badgesAlignClasses"
55
+ >
56
+ <XMarkSocialProofBadge
57
+ v-for="badge in badges"
58
+ :key="typeof badge === 'string' ? badge : badge.preset || badge.label"
59
+ v-bind="getBadgeProps(badge)"
60
+ variant="text-muted"
61
+ />
62
+ </div>
63
+ </div>
64
+ </template>
65
+
66
+ <script setup>
67
+ const props = defineProps({
68
+ /** Array of pricing plans */
69
+ plans: {
70
+ type: Array,
71
+ required: true,
72
+ },
73
+ /** Plan name to highlight (alternative to setting popular: true on plan) */
74
+ highlighted: {
75
+ type: String,
76
+ default: '',
77
+ },
78
+ /** Has monthly/yearly billing toggle */
79
+ hasBillingToggle: {
80
+ type: Boolean,
81
+ default: false,
82
+ },
83
+ /** Yearly discount percentage to display */
84
+ yearlyDiscount: {
85
+ type: Number,
86
+ default: 10,
87
+ },
88
+ /** Scale effect - enlarges plans with highlight */
89
+ scale: {
90
+ type: Boolean,
91
+ default: false,
92
+ },
93
+ /** Compact mode - no gap between plans */
94
+ compact: {
95
+ type: Boolean,
96
+ default: true,
97
+ },
98
+ /** Variant for all plans */
99
+ variant: {
100
+ type: String,
101
+ default: undefined,
102
+ validator: (v) => ['soft', 'solid', 'outline', 'subtle'].includes(v),
103
+ },
104
+ /** Social proof badge presets or custom badges */
105
+ badges: {
106
+ type: Array,
107
+ default: () => [],
108
+ },
109
+ /** Position of badges */
110
+ badgesPosition: {
111
+ type: String,
112
+ default: 'bottom',
113
+ validator: (v) => ['top', 'bottom'].includes(v),
114
+ },
115
+ /** Alignment of badges */
116
+ badgesAlign: {
117
+ type: String,
118
+ default: 'center',
119
+ validator: (v) => ['left', 'center', 'right'].includes(v),
120
+ },
121
+ });
122
+
123
+ const emit = defineEmits(['select']);
124
+
125
+ const billingPeriod = ref('monthly');
126
+
127
+ // Map XMark plan format to Nuxt UI format
128
+ const mappedPlans = computed(() => {
129
+ return props.plans.map((plan) => {
130
+ // Get the price based on billing period
131
+ let price = plan.price;
132
+ let billingCycle = plan.period || '';
133
+
134
+ if (typeof plan.price === 'object') {
135
+ price = billingPeriod.value === 'yearly' ? plan.price.yearly : plan.price.monthly;
136
+ billingCycle = billingPeriod.value === 'yearly' ? '/year' : '/month';
137
+ } else if (props.hasBillingToggle && !plan.period) {
138
+ billingCycle = billingPeriod.value === 'yearly' ? '/year' : '/month';
139
+ }
140
+
141
+ // Determine if this plan should be highlighted
142
+ const isHighlighted = plan.popular || plan.name === props.highlighted;
143
+
144
+ // Determine if this plan should be scaled (only if scale prop is true on container)
145
+ const shouldScale = props.scale && isHighlighted;
146
+
147
+ return {
148
+ title: plan.name,
149
+ description: plan.description,
150
+ price: price,
151
+ billingCycle: billingCycle,
152
+ features: plan.features,
153
+ highlight: isHighlighted,
154
+ scale: shouldScale,
155
+ badge: isHighlighted ? 'Most Popular' : undefined,
156
+ button: {
157
+ label: plan.button?.label || 'Get Started',
158
+ onClick: () => emit('select', plan),
159
+ },
160
+ };
161
+ });
162
+ });
163
+
164
+ // Badge alignment classes
165
+ const badgesAlignClasses = computed(() => {
166
+ const alignments = {
167
+ left: 'justify-start',
168
+ center: 'justify-center',
169
+ right: 'justify-end',
170
+ };
171
+ return alignments[props.badgesAlign];
172
+ });
173
+
174
+ // Get badge props (handles string presets and object configs)
175
+ const getBadgeProps = (badge) => {
176
+ if (typeof badge === 'string') {
177
+ return { preset: badge };
178
+ }
179
+ return badge;
180
+ };
181
+ </script>
@@ -0,0 +1,112 @@
1
+ <template>
2
+ <Transition name="slide-up">
3
+ <div v-if="visible" class="fixed bottom-0 inset-x-0 z-50 p-4">
4
+ <UContainer>
5
+ <div
6
+ class="bg-white dark:bg-deep-900 rounded-xl shadow-2xl border border-default p-6"
7
+ >
8
+ <div class="flex flex-col lg:flex-row lg:items-center gap-4">
9
+ <div class="flex-1">
10
+ <h4 class="font-semibold">{{ title }}</h4>
11
+ <p class="text-sm text-muted mt-1">
12
+ {{ message }}
13
+ <NuxtLink
14
+ v-if="policyLink"
15
+ :to="policyLink"
16
+ class="text-primary hover:underline"
17
+ >
18
+ {{ policyLabel }}
19
+ </NuxtLink>
20
+ </p>
21
+ </div>
22
+
23
+ <div class="flex flex-wrap gap-3">
24
+ <UButton
25
+ v-if="hasCustomize"
26
+ label="Customize"
27
+ variant="ghost"
28
+ @click="$emit('customize')"
29
+ />
30
+ <UButton
31
+ v-if="hasReject"
32
+ label="Reject All"
33
+ variant="outline"
34
+ @click="reject"
35
+ />
36
+ <UButton label="Accept All" @click="accept" />
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </UContainer>
41
+ </div>
42
+ </Transition>
43
+ </template>
44
+
45
+ <script setup>
46
+ const props = defineProps({
47
+ title: {
48
+ type: String,
49
+ default: "We use cookies",
50
+ },
51
+ message: {
52
+ type: String,
53
+ default: "We use cookies to improve your experience on our site.",
54
+ },
55
+ policyLink: {
56
+ type: String,
57
+ default: "/privacy",
58
+ },
59
+ policyLabel: {
60
+ type: String,
61
+ default: "Learn more",
62
+ },
63
+ hasCustomize: {
64
+ type: Boolean,
65
+ default: false,
66
+ },
67
+ hasReject: {
68
+ type: Boolean,
69
+ default: true,
70
+ },
71
+ storageKey: {
72
+ type: String,
73
+ default: "cookie-consent",
74
+ },
75
+ });
76
+
77
+ const emit = defineEmits(["accept", "reject", "customize"]);
78
+
79
+ const visible = ref(false);
80
+
81
+ onMounted(() => {
82
+ const consent = localStorage.getItem(props.storageKey);
83
+ if (!consent) {
84
+ visible.value = true;
85
+ }
86
+ });
87
+
88
+ const accept = () => {
89
+ localStorage.setItem(props.storageKey, "accepted");
90
+ visible.value = false;
91
+ emit("accept");
92
+ };
93
+
94
+ const reject = () => {
95
+ localStorage.setItem(props.storageKey, "rejected");
96
+ visible.value = false;
97
+ emit("reject");
98
+ };
99
+ </script>
100
+
101
+ <style scoped>
102
+ .slide-up-enter-active,
103
+ .slide-up-leave-active {
104
+ transition: transform 0.3s ease, opacity 0.3s ease;
105
+ }
106
+
107
+ .slide-up-enter-from,
108
+ .slide-up-leave-to {
109
+ transform: translateY(100%);
110
+ opacity: 0;
111
+ }
112
+ </style>