cinqcinqdev-seo 0.1.71 → 0.1.73
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/README.md +39 -2
- package/dist/module.json +1 -1
- package/dist/runtime/assets/admin-tw.css +1 -1
- package/dist/runtime/components/admin-cms/DynamicRenderer.vue +16 -2
- package/dist/runtime/components/sections/{BlogHero.vue → cinqcinq/BlogHero.vue} +35 -3
- package/dist/runtime/components/sections/{CallToAction.vue → cinqcinq/CallToAction.vue} +35 -3
- package/dist/runtime/components/sections/{ContactForm.vue → cinqcinq/ContactForm.vue} +34 -2
- package/dist/runtime/components/sections/{FAQ.vue → cinqcinq/FAQ.vue} +35 -3
- package/dist/runtime/components/sections/{Features.vue → cinqcinq/Features.vue} +35 -3
- package/dist/runtime/components/sections/{HeroSection.vue → cinqcinq/HeroSection.vue} +11 -0
- package/dist/runtime/components/sections/{Newsletter.vue → cinqcinq/Newsletter.vue} +35 -3
- package/dist/runtime/components/sections/{Pricing.vue → cinqcinq/Pricing.vue} +35 -3
- package/dist/runtime/components/sections/{Process.vue → cinqcinq/Process.vue} +35 -3
- package/dist/runtime/components/sections/{Quote.vue → cinqcinq/Quote.vue} +35 -3
- package/dist/runtime/components/sections/{RichText.vue → cinqcinq/RichText.vue} +32 -4
- package/dist/runtime/components/sections/{ServiceGrid.vue → cinqcinq/ServiceGrid.vue} +35 -3
- package/dist/runtime/components/sections/{Testimonials.vue → cinqcinq/Testimonials.vue} +35 -3
- package/dist/runtime/components/sections/{TextVisual.vue → cinqcinq/TextVisual.vue} +39 -3
- package/dist/runtime/components/sections/swiss-style/BlogHero.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/BlogHero.vue +146 -0
- package/dist/runtime/components/sections/swiss-style/BlogHero.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/CallToAction.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/CallToAction.vue +138 -0
- package/dist/runtime/components/sections/swiss-style/CallToAction.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Comparison.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Comparison.vue +280 -0
- package/dist/runtime/components/sections/swiss-style/Comparison.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/ContactForm.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/ContactForm.vue +173 -0
- package/dist/runtime/components/sections/swiss-style/ContactForm.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/DataTable.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/DataTable.vue +245 -0
- package/dist/runtime/components/sections/swiss-style/DataTable.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/FAQ.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/FAQ.vue +107 -0
- package/dist/runtime/components/sections/swiss-style/FAQ.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Features.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Features.vue +103 -0
- package/dist/runtime/components/sections/swiss-style/Features.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/HeroFan.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/HeroFan.vue +108 -0
- package/dist/runtime/components/sections/swiss-style/HeroFan.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/HeroSection.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/HeroSection.vue +190 -0
- package/dist/runtime/components/sections/swiss-style/HeroSection.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/HeroSlider.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/HeroSlider.vue +138 -0
- package/dist/runtime/components/sections/swiss-style/HeroSlider.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Newsletter.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Newsletter.vue +125 -0
- package/dist/runtime/components/sections/swiss-style/Newsletter.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Pricing.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Pricing.vue +148 -0
- package/dist/runtime/components/sections/swiss-style/Pricing.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Process.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Process.vue +124 -0
- package/dist/runtime/components/sections/swiss-style/Process.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Quote.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Quote.vue +130 -0
- package/dist/runtime/components/sections/swiss-style/Quote.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/RelatedPages.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/RelatedPages.vue +212 -0
- package/dist/runtime/components/sections/swiss-style/RelatedPages.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/RichText.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/RichText.vue +100 -0
- package/dist/runtime/components/sections/swiss-style/RichText.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/ServiceGrid.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/ServiceGrid.vue +121 -0
- package/dist/runtime/components/sections/swiss-style/ServiceGrid.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Testimonials.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/Testimonials.vue +126 -0
- package/dist/runtime/components/sections/swiss-style/Testimonials.vue.d.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/TextVisual.d.vue.ts +3 -0
- package/dist/runtime/components/sections/swiss-style/TextVisual.vue +146 -0
- package/dist/runtime/components/sections/swiss-style/TextVisual.vue.d.ts +3 -0
- package/dist/runtime/composables/useAdminSections.js +75 -37
- package/dist/runtime/composables/useAdminSettings.d.ts +1 -0
- package/dist/runtime/pages/admin/editor/[id].vue +15 -1
- package/dist/runtime/pages/admin/setup.vue +77 -2
- package/package.json +1 -1
- /package/dist/runtime/components/sections/{BlogHero.d.vue.ts → cinqcinq/BlogHero.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{BlogHero.vue.d.ts → cinqcinq/BlogHero.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{CallToAction.d.vue.ts → cinqcinq/CallToAction.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{CallToAction.vue.d.ts → cinqcinq/CallToAction.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{Comparison.d.vue.ts → cinqcinq/Comparison.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{Comparison.vue → cinqcinq/Comparison.vue} +0 -0
- /package/dist/runtime/components/sections/{Comparison.vue.d.ts → cinqcinq/Comparison.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{ContactForm.d.vue.ts → cinqcinq/ContactForm.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{ContactForm.vue.d.ts → cinqcinq/ContactForm.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{DataTable.d.vue.ts → cinqcinq/DataTable.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{DataTable.vue → cinqcinq/DataTable.vue} +0 -0
- /package/dist/runtime/components/sections/{DataTable.vue.d.ts → cinqcinq/DataTable.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{FAQ.d.vue.ts → cinqcinq/FAQ.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{FAQ.vue.d.ts → cinqcinq/FAQ.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{Features.d.vue.ts → cinqcinq/Features.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{Features.vue.d.ts → cinqcinq/Features.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{HeroFan.d.vue.ts → cinqcinq/HeroFan.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{HeroFan.vue → cinqcinq/HeroFan.vue} +0 -0
- /package/dist/runtime/components/sections/{HeroFan.vue.d.ts → cinqcinq/HeroFan.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{HeroSection.d.vue.ts → cinqcinq/HeroSection.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{HeroSection.vue.d.ts → cinqcinq/HeroSection.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{HeroSlider.d.vue.ts → cinqcinq/HeroSlider.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{HeroSlider.vue → cinqcinq/HeroSlider.vue} +0 -0
- /package/dist/runtime/components/sections/{HeroSlider.vue.d.ts → cinqcinq/HeroSlider.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{Newsletter.d.vue.ts → cinqcinq/Newsletter.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{Newsletter.vue.d.ts → cinqcinq/Newsletter.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{Pricing.d.vue.ts → cinqcinq/Pricing.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{Pricing.vue.d.ts → cinqcinq/Pricing.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{Process.d.vue.ts → cinqcinq/Process.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{Process.vue.d.ts → cinqcinq/Process.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{Quote.d.vue.ts → cinqcinq/Quote.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{Quote.vue.d.ts → cinqcinq/Quote.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{RelatedPages.d.vue.ts → cinqcinq/RelatedPages.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{RelatedPages.vue → cinqcinq/RelatedPages.vue} +0 -0
- /package/dist/runtime/components/sections/{RelatedPages.vue.d.ts → cinqcinq/RelatedPages.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{RichText.d.vue.ts → cinqcinq/RichText.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{RichText.vue.d.ts → cinqcinq/RichText.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{ServiceGrid.d.vue.ts → cinqcinq/ServiceGrid.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{ServiceGrid.vue.d.ts → cinqcinq/ServiceGrid.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{Testimonials.d.vue.ts → cinqcinq/Testimonials.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{Testimonials.vue.d.ts → cinqcinq/Testimonials.vue.d.ts} +0 -0
- /package/dist/runtime/components/sections/{TextVisual.d.vue.ts → cinqcinq/TextVisual.d.vue.ts} +0 -0
- /package/dist/runtime/components/sections/{TextVisual.vue.d.ts → cinqcinq/TextVisual.vue.d.ts} +0 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section ref="sectionEl" :class="isVisible ? 'sv-in' : 'sv-out'" :dir="textDirection" :style="[{ backgroundColor: bgColor, color: textColor }, spacingStyle]">
|
|
3
|
+
|
|
4
|
+
<!-- CENTERED -->
|
|
5
|
+
<div v-if="layout === 'centered'" :class="[sectionPad]">
|
|
6
|
+
<div :class="[containerClass]">
|
|
7
|
+
<div class="border-b pb-8 mb-8" :style="{ borderColor: ca(textColor,'15') }">
|
|
8
|
+
<div v-if="category || readTime" class="flex items-center gap-4 mb-6 flex-wrap">
|
|
9
|
+
<span v-if="category" class="text-[9px] font-black uppercase tracking-[0.5em] border px-3 py-1" :style="{ borderColor: ca(textColor,'25'), color: textColor }">{{ category }}</span>
|
|
10
|
+
<span v-if="readTime" class="text-[11px]" :style="{ opacity: 0.4 }">{{ readTime }}</span>
|
|
11
|
+
</div>
|
|
12
|
+
<component :is="titleTag" class="font-black uppercase leading-[0.88] mb-0" :style="{ fontSize: titleFontSize, letterSpacing: '-0.04em' }">{{ title }}</component>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="grid md:grid-cols-2 gap-10 mb-10">
|
|
15
|
+
<p v-if="description" :class="[bodyClass, 'leading-relaxed']" :style="{ opacity: 0.6 }">{{ description }}</p>
|
|
16
|
+
<div v-if="author || date" class="flex items-center gap-4">
|
|
17
|
+
<img v-if="authorAvatar" :src="authorAvatar" class="w-10 h-10 object-cover shrink-0" style="border-radius:0" />
|
|
18
|
+
<div v-else-if="author" class="w-10 h-10 flex items-center justify-center text-xs font-black shrink-0" :style="{ backgroundColor: ca(textColor,'12') }">{{ author[0] }}</div>
|
|
19
|
+
<div>
|
|
20
|
+
<div v-if="author" class="text-sm font-black uppercase tracking-wide">{{ author }}</div>
|
|
21
|
+
<div class="text-[11px]" :style="{ opacity: 0.4 }">{{ [date, readTime].filter(Boolean).join(' · ') }}</div>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
<div v-if="image" class="overflow-hidden border aspect-[21/9]" :style="{ borderColor: ca(textColor,'12') }">
|
|
26
|
+
<img :src="image" :alt="title" class="w-full h-full object-cover" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<!-- COVER -->
|
|
32
|
+
<div v-else-if="layout === 'cover'" class="relative flex items-end" style="min-height: 75vh">
|
|
33
|
+
<div class="absolute inset-0">
|
|
34
|
+
<img v-if="image" :src="image" :alt="title" class="w-full h-full object-cover" />
|
|
35
|
+
<div v-else class="w-full h-full" :style="{ backgroundColor: ca(textColor,'06') }"></div>
|
|
36
|
+
<div class="absolute inset-0" :style="{ background: `linear-gradient(to top, ${bgColor} 25%, ${ca(bgColor,'cc')} 55%, transparent 100%)` }"></div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="relative w-full px-8 md:px-16 pb-12 pt-40">
|
|
39
|
+
<div :class="[containerClass]">
|
|
40
|
+
<div v-if="category || readTime" class="flex items-center gap-4 mb-6">
|
|
41
|
+
<span v-if="category" class="text-[9px] font-black uppercase tracking-[0.5em] border px-3 py-1" :style="{ borderColor: ca(textColor,'40'), color: textColor }">{{ category }}</span>
|
|
42
|
+
<span v-if="readTime" class="text-[11px]" :style="{ opacity: 0.5 }">{{ readTime }}</span>
|
|
43
|
+
</div>
|
|
44
|
+
<component :is="titleTag" class="font-black uppercase leading-[0.88] mb-6" :style="{ fontSize: titleFontSize, letterSpacing: '-0.04em' }">{{ title }}</component>
|
|
45
|
+
<div v-if="author || date" class="flex items-center gap-4 border-t pt-6" :style="{ borderColor: ca(textColor,'25') }">
|
|
46
|
+
<img v-if="authorAvatar" :src="authorAvatar" class="w-8 h-8 object-cover shrink-0" style="border-radius:0" />
|
|
47
|
+
<div>
|
|
48
|
+
<span v-if="author" class="text-xs font-black uppercase tracking-wide">{{ author }}</span>
|
|
49
|
+
<span v-if="date" class="text-[11px] ml-3" :style="{ opacity: 0.5 }">{{ date }}</span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- MINIMAL (default) -->
|
|
57
|
+
<div v-else :class="[sectionPad]">
|
|
58
|
+
<div :class="[containerClass]">
|
|
59
|
+
<div class="border-b pb-6 mb-6" :style="{ borderColor: ca(textColor,'15') }">
|
|
60
|
+
<div v-if="category" class="text-[9px] font-black uppercase tracking-[0.5em] mb-4" :style="{ opacity: 0.4 }">{{ category }}</div>
|
|
61
|
+
<component :is="titleTag" class="font-black uppercase leading-[0.88]" :style="{ fontSize: titleFontSize, letterSpacing: '-0.04em' }">{{ title }}</component>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="flex items-center gap-6" :style="{ opacity: 0.5 }">
|
|
64
|
+
<span v-if="author" class="text-[11px] font-black uppercase tracking-wider">{{ author }}</span>
|
|
65
|
+
<span v-if="date" class="text-[11px]">{{ date }}</span>
|
|
66
|
+
<span v-if="readTime" class="text-[11px]">{{ readTime }}</span>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
</section>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<script setup>
|
|
75
|
+
const ca = (c, h) => {
|
|
76
|
+
const m = c?.match(/rgba\((var\([^)]+\)),\s*[\d.]+\)/);
|
|
77
|
+
return m ? `rgba(${m[1]}, ${Math.round(parseInt(h, 16) / 255 * 1e3) / 1e3})` : (c || "") + h;
|
|
78
|
+
};
|
|
79
|
+
import { ref, computed, onMounted } from "vue";
|
|
80
|
+
const props = defineProps({
|
|
81
|
+
layout: { type: String, default: "centered" },
|
|
82
|
+
title: { type: String, default: "" },
|
|
83
|
+
titleTag: { type: String, default: "h1" },
|
|
84
|
+
description: { type: String, default: "" },
|
|
85
|
+
category: { type: String, default: "" },
|
|
86
|
+
readTime: { type: String, default: "" },
|
|
87
|
+
author: { type: String, default: "" },
|
|
88
|
+
authorAvatar: { type: String, default: "" },
|
|
89
|
+
date: { type: String, default: "" },
|
|
90
|
+
image: { type: String, default: "" },
|
|
91
|
+
textAlign: { type: String, default: "left" },
|
|
92
|
+
bgColor: { type: String, default: "rgba(var(--body-bg), 1)" },
|
|
93
|
+
textColor: { type: String, default: "rgba(var(--text-color), 1)" },
|
|
94
|
+
spacing: { type: String, default: "normal" },
|
|
95
|
+
radius: { type: String, default: "sharp" },
|
|
96
|
+
textDirection: { type: String, default: "ltr" },
|
|
97
|
+
contentWidth: { type: String, default: "normal" },
|
|
98
|
+
textSize: { type: String, default: "md" },
|
|
99
|
+
paddingTop: { type: String, default: "" },
|
|
100
|
+
paddingBottom: { type: String, default: "" },
|
|
101
|
+
marginTop: { type: String, default: "" },
|
|
102
|
+
marginBottom: { type: String, default: "" },
|
|
103
|
+
paddingX: { type: String, default: "" }
|
|
104
|
+
});
|
|
105
|
+
const sectionEl = ref(null);
|
|
106
|
+
const isVisible = ref(false);
|
|
107
|
+
onMounted(() => {
|
|
108
|
+
const obs = new IntersectionObserver(([e]) => {
|
|
109
|
+
if (e.isIntersecting) {
|
|
110
|
+
isVisible.value = true;
|
|
111
|
+
obs.disconnect();
|
|
112
|
+
}
|
|
113
|
+
}, { threshold: 0.05 });
|
|
114
|
+
if (sectionEl.value) obs.observe(sectionEl.value);
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
if (!isVisible.value) isVisible.value = true;
|
|
117
|
+
}, 300);
|
|
118
|
+
});
|
|
119
|
+
const sectionPad = computed(() => ({ compact: "py-8 px-4 md:px-8", normal: "py-14 px-8 md:px-16", spacious: "py-24 px-8 md:px-16" })[props.spacing] || "py-14 px-8 md:px-16");
|
|
120
|
+
const containerClass = computed(() => ({ narrow: "max-w-3xl mx-auto", normal: "max-w-6xl mx-auto", wide: "max-w-7xl mx-auto", full: "max-w-full" })[props.contentWidth] || "max-w-6xl mx-auto");
|
|
121
|
+
const titleFontSize = computed(() => ({ sm: "clamp(1.5rem,3vw,2.5rem)", md: "clamp(2rem,4vw,3.5rem)", lg: "clamp(3rem,7vw,6rem)" })[props.textSize] || "clamp(2rem,4vw,3.5rem)");
|
|
122
|
+
const bodyClass = computed(() => ({ sm: "text-xs", md: "text-sm", lg: "text-base" })[props.textSize] || "text-sm");
|
|
123
|
+
const SM = { none: "0", xs: "8px", sm: "24px", md: "48px", lg: "80px", xl: "128px", "-xs": "-8px", "-sm": "-24px", "-md": "-48px", "-lg": "-80px", "-xl": "-128px" };
|
|
124
|
+
const spacingStyle = computed(() => {
|
|
125
|
+
const s = {};
|
|
126
|
+
if (props.paddingTop && SM[props.paddingTop]) s.paddingTop = SM[props.paddingTop];
|
|
127
|
+
if (props.paddingBottom && SM[props.paddingBottom]) s.paddingBottom = SM[props.paddingBottom];
|
|
128
|
+
if (props.marginTop && SM[props.marginTop]) s.marginTop = SM[props.marginTop];
|
|
129
|
+
if (props.marginBottom && SM[props.marginBottom]) s.marginBottom = SM[props.marginBottom];
|
|
130
|
+
if (props.paddingX && SM[props.paddingX]) {
|
|
131
|
+
const v = SM[props.paddingX];
|
|
132
|
+
if (v.startsWith("-")) {
|
|
133
|
+
s.marginLeft = v;
|
|
134
|
+
s.marginRight = v;
|
|
135
|
+
} else {
|
|
136
|
+
s.paddingLeft = v;
|
|
137
|
+
s.paddingRight = v;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return s;
|
|
141
|
+
});
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<style scoped>
|
|
145
|
+
.sv-out{opacity:0;transform:translateY(16px)}.sv-in{opacity:1;transform:none;transition:opacity .6s ease,transform .6s ease}
|
|
146
|
+
</style>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section ref="sectionEl" :class="[sectionPad, isVisible ? 'sv-in' : 'sv-out']" :dir="textDirection" :style="[{ backgroundColor: bgColor, color: textColor }, spacingStyle]">
|
|
3
|
+
|
|
4
|
+
<!-- CENTERED -->
|
|
5
|
+
<div v-if="layout === 'centered'" :class="[containerClass]">
|
|
6
|
+
<div class="border py-14 px-10 md:px-16" :style="{ borderColor: ca(textColor,'20') }">
|
|
7
|
+
<div class="border-l-4 pl-8 mb-10" :style="{ borderColor: textColor }">
|
|
8
|
+
<span v-if="badge" class="block text-[9px] font-black uppercase tracking-[0.5em] mb-3" :style="{ opacity: 0.4 }">{{ badge }}</span>
|
|
9
|
+
<h2 class="font-black uppercase leading-[0.9]" :style="{ fontSize: titleFontSize, letterSpacing: '-0.03em' }">{{ title }}</h2>
|
|
10
|
+
<p v-if="description" :class="[bodyClass, 'leading-relaxed mt-4 max-w-md']" :style="{ opacity: 0.6 }">{{ description }}</p>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="flex flex-wrap gap-4">
|
|
13
|
+
<NuxtLink v-if="ctaText" :to="ctaLink || '#'"
|
|
14
|
+
class="inline-flex items-center gap-3 px-7 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:gap-6 border"
|
|
15
|
+
:style="{ backgroundColor: textColor, color: bgColor, borderColor: textColor }">
|
|
16
|
+
{{ ctaText }}<span>→</span>
|
|
17
|
+
</NuxtLink>
|
|
18
|
+
<NuxtLink v-if="ctaSecondaryText" :to="ctaSecondaryLink || '#'"
|
|
19
|
+
class="inline-flex items-center gap-3 px-7 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:gap-6 border"
|
|
20
|
+
:style="{ borderColor: ca(textColor,'40'), color: textColor }">
|
|
21
|
+
{{ ctaSecondaryText }}<span>→</span>
|
|
22
|
+
</NuxtLink>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- BANNER -->
|
|
28
|
+
<div v-else-if="layout === 'banner'" :class="[containerClass]">
|
|
29
|
+
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-8 border-t border-b py-10" :style="{ borderColor: ca(textColor,'20') }">
|
|
30
|
+
<div>
|
|
31
|
+
<span v-if="badge" class="block text-[9px] font-black uppercase tracking-[0.5em] mb-2" :style="{ opacity: 0.4 }">{{ badge }}</span>
|
|
32
|
+
<h2 class="font-black uppercase leading-[0.9]" :style="{ fontSize: titleFontSize, letterSpacing: '-0.03em' }">{{ title }}</h2>
|
|
33
|
+
<p v-if="description" :class="[bodyClass, 'mt-2']" :style="{ opacity: 0.6 }">{{ description }}</p>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="flex flex-wrap gap-3 shrink-0">
|
|
36
|
+
<NuxtLink v-if="ctaText" :to="ctaLink || '#'"
|
|
37
|
+
class="inline-flex items-center gap-3 px-7 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:gap-6 border"
|
|
38
|
+
:style="{ backgroundColor: textColor, color: bgColor, borderColor: textColor }">
|
|
39
|
+
{{ ctaText }}<span>→</span>
|
|
40
|
+
</NuxtLink>
|
|
41
|
+
<NuxtLink v-if="ctaSecondaryText" :to="ctaSecondaryLink || '#'"
|
|
42
|
+
class="inline-flex items-center gap-3 px-7 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:gap-6 border"
|
|
43
|
+
:style="{ borderColor: ca(textColor,'40'), color: textColor }">
|
|
44
|
+
{{ ctaSecondaryText }}
|
|
45
|
+
</NuxtLink>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- MINIMAL (default) -->
|
|
51
|
+
<div v-else :class="[containerClass]">
|
|
52
|
+
<div class="flex items-start md:items-center justify-between gap-8 border-t border-b py-10 flex-col md:flex-row" :style="{ borderColor: ca(textColor,'15') }">
|
|
53
|
+
<div>
|
|
54
|
+
<span v-if="badge" class="text-[9px] font-black uppercase tracking-[0.5em] block mb-2" :style="{ opacity: 0.4 }">{{ badge }}</span>
|
|
55
|
+
<h2 class="font-black uppercase leading-[0.9]" :style="{ fontSize: titleFontSize, letterSpacing: '-0.03em' }">{{ title }}</h2>
|
|
56
|
+
<p v-if="description" :class="[bodyClass, 'mt-2']" :style="{ opacity: 0.55 }">{{ description }}</p>
|
|
57
|
+
</div>
|
|
58
|
+
<NuxtLink v-if="ctaText" :to="ctaLink || '#'"
|
|
59
|
+
class="inline-flex items-center gap-3 text-[11px] font-black uppercase tracking-widest transition-all hover:gap-6 shrink-0"
|
|
60
|
+
:style="{ color: textColor }">
|
|
61
|
+
{{ ctaText }}<span>→</span>
|
|
62
|
+
</NuxtLink>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
</section>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<script setup>
|
|
70
|
+
const ca = (c, h) => {
|
|
71
|
+
const m = c?.match(/rgba\((var\([^)]+\)),\s*[\d.]+\)/);
|
|
72
|
+
return m ? `rgba(${m[1]}, ${Math.round(parseInt(h, 16) / 255 * 1e3) / 1e3})` : (c || "") + h;
|
|
73
|
+
};
|
|
74
|
+
import { ref, computed, onMounted } from "vue";
|
|
75
|
+
const props = defineProps({
|
|
76
|
+
layout: { type: String, default: "centered" },
|
|
77
|
+
badge: { type: String, default: "" },
|
|
78
|
+
title: { type: String, default: "Ready to get started?" },
|
|
79
|
+
description: { type: String, default: "" },
|
|
80
|
+
ctaText: { type: String, default: "Get Started" },
|
|
81
|
+
ctaLink: { type: String, default: "#" },
|
|
82
|
+
ctaSecondaryText: { type: String, default: "" },
|
|
83
|
+
ctaSecondaryLink: { type: String, default: "#" },
|
|
84
|
+
bgColor: { type: String, default: "rgba(var(--body-bg), 1)" },
|
|
85
|
+
textColor: { type: String, default: "rgba(var(--text-color), 1)" },
|
|
86
|
+
spacing: { type: String, default: "normal" },
|
|
87
|
+
radius: { type: String, default: "sharp" },
|
|
88
|
+
textDirection: { type: String, default: "ltr" },
|
|
89
|
+
contentWidth: { type: String, default: "normal" },
|
|
90
|
+
textSize: { type: String, default: "md" },
|
|
91
|
+
paddingTop: { type: String, default: "" },
|
|
92
|
+
paddingBottom: { type: String, default: "" },
|
|
93
|
+
marginTop: { type: String, default: "" },
|
|
94
|
+
marginBottom: { type: String, default: "" },
|
|
95
|
+
paddingX: { type: String, default: "" }
|
|
96
|
+
});
|
|
97
|
+
const sectionEl = ref(null);
|
|
98
|
+
const isVisible = ref(false);
|
|
99
|
+
onMounted(() => {
|
|
100
|
+
const obs = new IntersectionObserver(([e]) => {
|
|
101
|
+
if (e.isIntersecting) {
|
|
102
|
+
isVisible.value = true;
|
|
103
|
+
obs.disconnect();
|
|
104
|
+
}
|
|
105
|
+
}, { threshold: 0.06 });
|
|
106
|
+
if (sectionEl.value) obs.observe(sectionEl.value);
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
if (!isVisible.value) isVisible.value = true;
|
|
109
|
+
}, 300);
|
|
110
|
+
});
|
|
111
|
+
const sectionPad = computed(() => ({ compact: "py-8 px-4 md:px-8", normal: "py-14 px-8 md:px-16", spacious: "py-24 px-8 md:px-16" })[props.spacing] || "py-14 px-8 md:px-16");
|
|
112
|
+
const containerClass = computed(() => ({ narrow: "max-w-3xl mx-auto", normal: "max-w-6xl mx-auto", wide: "max-w-7xl mx-auto", full: "max-w-full" })[props.contentWidth] || "max-w-6xl mx-auto");
|
|
113
|
+
const titleFontSize = computed(() => ({ sm: "clamp(1rem,1.5vw,1.5rem)", md: "clamp(1.5rem,3vw,2.5rem)", lg: "clamp(2.5rem,6vw,5rem)" })[props.textSize] || "clamp(1.5rem,3vw,2.5rem)");
|
|
114
|
+
const bodyClass = computed(() => ({ sm: "text-xs", md: "text-sm", lg: "text-base" })[props.textSize] || "text-sm");
|
|
115
|
+
const SM = { none: "0", xs: "8px", sm: "24px", md: "48px", lg: "80px", xl: "128px", "-xs": "-8px", "-sm": "-24px", "-md": "-48px", "-lg": "-80px", "-xl": "-128px" };
|
|
116
|
+
const spacingStyle = computed(() => {
|
|
117
|
+
const s = {};
|
|
118
|
+
if (props.paddingTop && SM[props.paddingTop]) s.paddingTop = SM[props.paddingTop];
|
|
119
|
+
if (props.paddingBottom && SM[props.paddingBottom]) s.paddingBottom = SM[props.paddingBottom];
|
|
120
|
+
if (props.marginTop && SM[props.marginTop]) s.marginTop = SM[props.marginTop];
|
|
121
|
+
if (props.marginBottom && SM[props.marginBottom]) s.marginBottom = SM[props.marginBottom];
|
|
122
|
+
if (props.paddingX && SM[props.paddingX]) {
|
|
123
|
+
const v = SM[props.paddingX];
|
|
124
|
+
if (v.startsWith("-")) {
|
|
125
|
+
s.marginLeft = v;
|
|
126
|
+
s.marginRight = v;
|
|
127
|
+
} else {
|
|
128
|
+
s.paddingLeft = v;
|
|
129
|
+
s.paddingRight = v;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return s;
|
|
133
|
+
});
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<style scoped>
|
|
137
|
+
.sv-out{opacity:0;transform:translateY(16px)}.sv-in{opacity:1;transform:none;transition:opacity .6s ease,transform .6s ease}
|
|
138
|
+
</style>
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section ref="sectionEl" :class="[sectionPad, isVisible ? 'sv-in' : 'sv-out']" :dir="textDirection" :style="[{ backgroundColor: bgColor, color: textColor }, spacingStyle]">
|
|
3
|
+
<div :class="[containerClass]">
|
|
4
|
+
|
|
5
|
+
<!-- Header -->
|
|
6
|
+
<div v-if="badge || title || description" :class="['border-b pb-8 mb-12', headerAlign === 'center' ? 'text-center' : '']" :style="{ borderColor: ca(textColor,'15') }">
|
|
7
|
+
<span v-if="badge" class="block text-[9px] font-black uppercase tracking-[0.5em] mb-4" :style="{ opacity: 0.35 }">{{ badge }}</span>
|
|
8
|
+
<component :is="titleTag" v-if="title" class="font-black uppercase leading-[0.88] mb-4" :style="{ fontSize: titleFontSize, letterSpacing: '-0.04em' }">{{ title }}</component>
|
|
9
|
+
<p v-if="description" :class="[bodyClass, 'leading-relaxed', headerAlign === 'center' ? 'max-w-xl mx-auto' : 'max-w-xl']" :style="{ opacity: 0.55 }">{{ description }}</p>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<!-- CARDS layout -->
|
|
13
|
+
<div v-if="layout === 'cards'" :class="['grid border divide-x', gridCols]" :style="{ borderColor: ca(textColor,'15') }">
|
|
14
|
+
<div
|
|
15
|
+
v-for="(plan, i) in parsedPlans" :key="i"
|
|
16
|
+
class="relative flex flex-col p-8"
|
|
17
|
+
:style="plan.highlighted ? { backgroundColor: textColor, color: bgColor } : {}"
|
|
18
|
+
>
|
|
19
|
+
<!-- Popular badge -->
|
|
20
|
+
<span v-if="plan.badge" class="block text-[9px] font-black uppercase tracking-[0.4em] mb-5 border-l-4 pl-2"
|
|
21
|
+
:style="plan.highlighted ? { borderColor: bgColor, opacity: 0.6 } : { borderColor: textColor, opacity: 0.4 }">{{ plan.badge }}</span>
|
|
22
|
+
<!-- Plan name -->
|
|
23
|
+
<div class="text-[10px] font-black uppercase tracking-[0.4em] mb-5" :style="{ opacity: plan.highlighted ? 0.55 : 0.4 }">{{ plan.name }}</div>
|
|
24
|
+
<!-- Price -->
|
|
25
|
+
<div class="flex items-end gap-1 mb-2">
|
|
26
|
+
<span class="text-5xl font-black tracking-tight leading-none">{{ plan.price }}</span>
|
|
27
|
+
<span class="text-sm pb-1" :style="{ opacity: 0.45 }">{{ plan.priceUnit }}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<!-- Description -->
|
|
30
|
+
<p v-if="plan.description" :class="[bodyClass, 'leading-relaxed mb-8 mt-3']" :style="{ opacity: plan.highlighted ? 0.65 : 0.5 }">{{ plan.description }}</p>
|
|
31
|
+
<!-- Divider -->
|
|
32
|
+
<div class="mb-7 border-t" :style="{ borderColor: plan.highlighted ? ca(bgColor,'20') : ca(textColor,'10') }"></div>
|
|
33
|
+
<!-- Features -->
|
|
34
|
+
<ul class="space-y-3.5 flex-1 mb-8">
|
|
35
|
+
<li v-for="(feat, j) in plan.features" :key="j" class="flex items-start gap-3">
|
|
36
|
+
<span class="shrink-0 w-5 h-5 flex items-center justify-center text-[10px] font-black leading-none mt-0.5 border"
|
|
37
|
+
:style="feat.type === 'excluded'
|
|
38
|
+
? { borderColor: plan.highlighted ? ca(bgColor,'25') : ca(textColor,'15'), opacity: 0.4 }
|
|
39
|
+
: { backgroundColor: plan.highlighted ? bgColor : textColor, color: plan.highlighted ? textColor : bgColor, borderColor: 'transparent' }">
|
|
40
|
+
{{ feat.type === 'excluded' ? '✗' : '✓' }}
|
|
41
|
+
</span>
|
|
42
|
+
<span :class="[bodyClass, 'leading-snug']" :style="{ opacity: feat.type === 'excluded' ? 0.3 : (plan.highlighted ? 0.85 : 0.7) }">{{ feat.text }}</span>
|
|
43
|
+
</li>
|
|
44
|
+
</ul>
|
|
45
|
+
<!-- CTA -->
|
|
46
|
+
<NuxtLink v-if="plan.ctaText" :to="plan.ctaLink || '#'"
|
|
47
|
+
class="flex items-center justify-center gap-2 py-3.5 text-[11px] font-black uppercase tracking-widest border transition-all hover:gap-4"
|
|
48
|
+
:style="plan.highlighted
|
|
49
|
+
? { backgroundColor: bgColor, color: textColor, borderColor: bgColor }
|
|
50
|
+
: { borderColor: ca(textColor,'25'), color: textColor }">
|
|
51
|
+
{{ plan.ctaText }}<span>→</span>
|
|
52
|
+
</NuxtLink>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- TABLE layout -->
|
|
57
|
+
<div v-else-if="layout === 'table'" class="overflow-x-auto border" :style="{ borderColor: ca(textColor,'15') }">
|
|
58
|
+
<table class="w-full min-w-[540px]" style="border-collapse: collapse;">
|
|
59
|
+
<thead>
|
|
60
|
+
<tr :style="{ borderBottom: `2px solid ${ca(textColor,'12')}` }">
|
|
61
|
+
<th class="text-left pb-6 pr-6 pt-6 pl-6 align-bottom" style="width: 30%">
|
|
62
|
+
<span v-if="rowsLabel" :class="[bodyClass, 'font-black uppercase tracking-wider']" :style="{ opacity: 0.35 }">{{ rowsLabel }}</span>
|
|
63
|
+
</th>
|
|
64
|
+
<th v-for="(plan, i) in parsedPlans" :key="i" class="pb-6 px-6 pt-6 align-bottom" :style="{ width: `${70 / Math.max(parsedPlans.length, 1)}%`, borderLeft: `1px solid ${ca(textColor,'10')}` }">
|
|
65
|
+
<div :style="plan.highlighted ? { borderLeft: `4px solid ${textColor}`, paddingLeft: '12px' } : {}">
|
|
66
|
+
<span v-if="plan.badge" class="block text-[9px] font-black uppercase tracking-[0.4em] mb-2" :style="{ opacity: 0.4 }">{{ plan.badge }}</span>
|
|
67
|
+
<div class="font-black uppercase tracking-tight text-sm mb-2">{{ plan.name }}</div>
|
|
68
|
+
<div class="flex items-end gap-1">
|
|
69
|
+
<span class="text-2xl font-black leading-none">{{ plan.price }}</span>
|
|
70
|
+
<span class="text-xs pb-0.5" :style="{ opacity: 0.45 }">{{ plan.priceUnit }}</span>
|
|
71
|
+
</div>
|
|
72
|
+
<NuxtLink v-if="plan.ctaText" :to="plan.ctaLink || '#'"
|
|
73
|
+
class="mt-4 inline-flex items-center gap-1.5 px-4 py-2 text-[10px] font-black uppercase tracking-wider border transition-all hover:gap-3"
|
|
74
|
+
:style="plan.highlighted
|
|
75
|
+
? { backgroundColor: textColor, color: bgColor, borderColor: textColor }
|
|
76
|
+
: { borderColor: ca(textColor,'20'), color: textColor }">
|
|
77
|
+
{{ plan.ctaText }}
|
|
78
|
+
</NuxtLink>
|
|
79
|
+
</div>
|
|
80
|
+
</th>
|
|
81
|
+
</tr>
|
|
82
|
+
</thead>
|
|
83
|
+
<tbody>
|
|
84
|
+
<tr v-for="(row, ri) in parsedRows" :key="ri"
|
|
85
|
+
:style="{ borderTop: `1px solid ${ca(textColor,'08')}` }">
|
|
86
|
+
<td class="py-4 pr-6 pl-6">
|
|
87
|
+
<span :class="[bodyClass, 'font-medium']" :style="{ opacity: 0.7 }">{{ row }}</span>
|
|
88
|
+
</td>
|
|
89
|
+
<td v-for="(plan, pi) in parsedPlans" :key="pi" class="py-4 px-6 text-center" :style="{ borderLeft: `1px solid ${ca(textColor,'08')}` }">
|
|
90
|
+
<div class="flex items-center justify-center">
|
|
91
|
+
<template v-if="plan.features[ri]">
|
|
92
|
+
<span v-if="plan.features[ri].type === 'included'"
|
|
93
|
+
class="inline-flex items-center justify-center w-6 h-6 text-[10px] font-black border"
|
|
94
|
+
:style="{ backgroundColor: textColor, color: bgColor, borderColor: textColor }">✓</span>
|
|
95
|
+
<span v-else-if="plan.features[ri].type === 'excluded'"
|
|
96
|
+
class="inline-flex items-center justify-center w-6 h-6 text-[10px] border"
|
|
97
|
+
:style="{ borderColor: ca(textColor,'15'), color: ca(textColor,'30') }">✗</span>
|
|
98
|
+
<span v-else :class="[bodyClass, 'font-semibold']" :style="{ opacity: 0.8 }">{{ plan.features[ri].text }}</span>
|
|
99
|
+
</template>
|
|
100
|
+
<span v-else class="text-xs" :style="{ opacity: 0.15 }">—</span>
|
|
101
|
+
</div>
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
</tbody>
|
|
105
|
+
</table>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<!-- VERSUS layout -->
|
|
109
|
+
<div v-else class="relative grid grid-cols-1 md:grid-cols-2 border" :style="{ borderColor: ca(textColor,'15') }">
|
|
110
|
+
<div v-for="(plan, i) in parsedPlans.slice(0, 2)" :key="i"
|
|
111
|
+
class="flex flex-col p-8 md:p-12"
|
|
112
|
+
:style="i === 1 ? { borderTop: `1px solid ${ca(textColor,'12')}`, borderLeft: undefined } : {}">
|
|
113
|
+
<span v-if="plan.badge" class="block self-start text-[9px] font-black uppercase tracking-[0.4em] mb-5 border-l-4 pl-2" :style="{ borderColor: textColor, opacity: 0.4 }">{{ plan.badge }}</span>
|
|
114
|
+
<div class="text-[10px] font-black uppercase tracking-[0.4em] mb-3" :style="{ opacity: 0.4 }">{{ plan.name }}</div>
|
|
115
|
+
<div class="flex items-end gap-1 mb-3">
|
|
116
|
+
<span class="text-4xl font-black tracking-tight leading-none">{{ plan.price }}</span>
|
|
117
|
+
<span class="text-sm pb-0.5" :style="{ opacity: 0.45 }">{{ plan.priceUnit }}</span>
|
|
118
|
+
</div>
|
|
119
|
+
<p v-if="plan.description" :class="[bodyClass, 'leading-relaxed mb-8 mt-1']" :style="{ opacity: 0.55 }">{{ plan.description }}</p>
|
|
120
|
+
<div class="mb-7 border-t" :style="{ borderColor: ca(textColor,'10') }"></div>
|
|
121
|
+
<ul class="space-y-3.5 flex-1 mb-10">
|
|
122
|
+
<li v-for="(feat, j) in plan.features" :key="j" class="flex items-start gap-3">
|
|
123
|
+
<span class="shrink-0 w-4 h-4 flex items-center justify-center text-[9px] font-black leading-none mt-0.5 border"
|
|
124
|
+
:style="feat.type === 'excluded'
|
|
125
|
+
? { borderColor: ca(textColor,'12'), opacity: 0.3 }
|
|
126
|
+
: { backgroundColor: textColor, color: bgColor, borderColor: textColor }">
|
|
127
|
+
{{ feat.type === 'excluded' ? '✗' : '✓' }}
|
|
128
|
+
</span>
|
|
129
|
+
<span :class="[bodyClass, 'leading-snug']" :style="{ opacity: feat.type === 'excluded' ? 0.3 : 0.7 }">{{ feat.text }}</span>
|
|
130
|
+
</li>
|
|
131
|
+
</ul>
|
|
132
|
+
<NuxtLink v-if="plan.ctaText" :to="plan.ctaLink || '#'"
|
|
133
|
+
class="inline-flex items-center gap-2 px-7 py-3.5 text-[11px] font-black uppercase tracking-widest border transition-all hover:gap-4"
|
|
134
|
+
:style="plan.highlighted
|
|
135
|
+
? { backgroundColor: textColor, color: bgColor, borderColor: textColor }
|
|
136
|
+
: { borderColor: ca(textColor,'25'), color: textColor }">
|
|
137
|
+
{{ plan.ctaText }}<span>→</span>
|
|
138
|
+
</NuxtLink>
|
|
139
|
+
</div>
|
|
140
|
+
<!-- VS badge -->
|
|
141
|
+
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 hidden md:flex items-center justify-center w-10 h-10 border-2 text-[10px] font-black uppercase tracking-widest z-10"
|
|
142
|
+
:style="{ backgroundColor: bgColor, borderColor: ca(textColor,'20'), color: textColor }">VS</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<!-- Empty state -->
|
|
146
|
+
<div v-if="!parsedPlans.length" class="py-16 text-center border" :style="{ borderColor: ca(textColor,'08'), opacity: 0.2 }">
|
|
147
|
+
<div class="text-4xl font-black mb-2">≡</div>
|
|
148
|
+
<div class="text-xs font-black uppercase tracking-widest">Add items to compare</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
</div>
|
|
152
|
+
</section>
|
|
153
|
+
</template>
|
|
154
|
+
|
|
155
|
+
<script setup>
|
|
156
|
+
const ca = (c, h) => {
|
|
157
|
+
const m = c?.match(/rgba\((var\([^)]+\)),\s*[\d.]+\)/);
|
|
158
|
+
return m ? `rgba(${m[1]}, ${Math.round(parseInt(h, 16) / 255 * 1e3) / 1e3})` : (c || "") + h;
|
|
159
|
+
};
|
|
160
|
+
import { ref, computed, onMounted } from "vue";
|
|
161
|
+
const parseFeatureLine = (line) => {
|
|
162
|
+
const t = line.trim();
|
|
163
|
+
if (!t) return null;
|
|
164
|
+
if (t.startsWith("\u2713") || t.toLowerCase().startsWith("yes") || t.startsWith("+")) return { type: "included", text: t.replace(/^[✓+]|^yes/i, "").trim() || t };
|
|
165
|
+
if (t.startsWith("\u2717") || t.toLowerCase().startsWith("no") || t.startsWith("-")) return { type: "excluded", text: t.replace(/^[✗\-]|^no/i, "").trim() || t };
|
|
166
|
+
return { type: "text", text: t };
|
|
167
|
+
};
|
|
168
|
+
const props = defineProps({
|
|
169
|
+
layout: { type: String, default: "cards" },
|
|
170
|
+
headerAlign: { type: String, default: "left" },
|
|
171
|
+
titleTag: { type: String, default: "h2" },
|
|
172
|
+
badge: { type: String, default: "" },
|
|
173
|
+
title: { type: String, default: "" },
|
|
174
|
+
description: { type: String, default: "" },
|
|
175
|
+
rowsLabel: { type: String, default: "Features" },
|
|
176
|
+
rows: { type: String, default: "Projects\nStorage\nSupport\nAPI Access\nCustom Domain" },
|
|
177
|
+
items: { type: Array, default: () => [
|
|
178
|
+
{
|
|
179
|
+
name: "Starter",
|
|
180
|
+
badge: "",
|
|
181
|
+
price: "$0",
|
|
182
|
+
priceUnit: "/mo",
|
|
183
|
+
description: "Perfect for individuals and small projects.",
|
|
184
|
+
highlighted: false,
|
|
185
|
+
ctaText: "Get started",
|
|
186
|
+
ctaLink: "#",
|
|
187
|
+
features: "3 projects\n1 GB storage\nCommunity support\n\u2717 API access\n\u2717 Custom domain"
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "Pro",
|
|
191
|
+
badge: "Popular",
|
|
192
|
+
price: "$29",
|
|
193
|
+
priceUnit: "/mo",
|
|
194
|
+
description: "Everything you need to scale your business.",
|
|
195
|
+
highlighted: true,
|
|
196
|
+
ctaText: "Start free trial",
|
|
197
|
+
ctaLink: "#",
|
|
198
|
+
features: "Unlimited projects\n50 GB storage\nPriority support\n\u2713 API access\n\u2713 Custom domain"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "Enterprise",
|
|
202
|
+
badge: "",
|
|
203
|
+
price: "$99",
|
|
204
|
+
priceUnit: "/mo",
|
|
205
|
+
description: "Advanced features for large organizations.",
|
|
206
|
+
highlighted: false,
|
|
207
|
+
ctaText: "Contact sales",
|
|
208
|
+
ctaLink: "#",
|
|
209
|
+
features: "Unlimited projects\nUnlimited storage\nDedicated support\n\u2713 API access\n\u2713 Custom domain"
|
|
210
|
+
}
|
|
211
|
+
] },
|
|
212
|
+
bgColor: { type: String, default: "rgba(var(--body-bg), 1)" },
|
|
213
|
+
textColor: { type: String, default: "rgba(var(--text-color), 1)" },
|
|
214
|
+
spacing: { type: String, default: "normal" },
|
|
215
|
+
radius: { type: String, default: "sharp" },
|
|
216
|
+
textDirection: { type: String, default: "ltr" },
|
|
217
|
+
contentWidth: { type: String, default: "normal" },
|
|
218
|
+
textSize: { type: String, default: "md" },
|
|
219
|
+
paddingTop: { type: String, default: "" },
|
|
220
|
+
paddingBottom: { type: String, default: "" },
|
|
221
|
+
marginTop: { type: String, default: "" },
|
|
222
|
+
marginBottom: { type: String, default: "" },
|
|
223
|
+
paddingX: { type: String, default: "" }
|
|
224
|
+
});
|
|
225
|
+
const sectionEl = ref(null);
|
|
226
|
+
const isVisible = ref(false);
|
|
227
|
+
onMounted(() => {
|
|
228
|
+
const obs = new IntersectionObserver(([e]) => {
|
|
229
|
+
if (e.isIntersecting) {
|
|
230
|
+
isVisible.value = true;
|
|
231
|
+
obs.disconnect();
|
|
232
|
+
}
|
|
233
|
+
}, { threshold: 0.05 });
|
|
234
|
+
if (sectionEl.value) obs.observe(sectionEl.value);
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
if (!isVisible.value) isVisible.value = true;
|
|
237
|
+
}, 300);
|
|
238
|
+
});
|
|
239
|
+
const sectionPad = computed(() => ({ compact: "py-8 px-4 md:px-8", normal: "py-14 px-8 md:px-16", spacious: "py-24 px-8 md:px-16" })[props.spacing] || "py-14 px-8 md:px-16");
|
|
240
|
+
const containerClass = computed(() => ({ narrow: "max-w-3xl mx-auto", normal: "max-w-6xl mx-auto", wide: "max-w-7xl mx-auto", full: "max-w-full" })[props.contentWidth] || "max-w-6xl mx-auto");
|
|
241
|
+
const titleFontSize = computed(() => ({ sm: "clamp(1rem,1.5vw,1.3rem)", md: "clamp(1.3rem,2vw,1.8rem)", lg: "clamp(2.5rem,6vw,5rem)" })[props.textSize] || "clamp(1.3rem,2vw,1.8rem)");
|
|
242
|
+
const bodyClass = computed(() => ({ sm: "text-xs", md: "text-sm", lg: "text-base" })[props.textSize] || "text-sm");
|
|
243
|
+
const parsedPlans = computed(
|
|
244
|
+
() => (props.items || []).map((plan) => ({
|
|
245
|
+
...plan,
|
|
246
|
+
features: (plan.features || "").split("\n").map(parseFeatureLine).filter(Boolean)
|
|
247
|
+
}))
|
|
248
|
+
);
|
|
249
|
+
const parsedRows = computed(() => (props.rows || "").split("\n").map((r) => r.trim()).filter(Boolean));
|
|
250
|
+
const gridCols = computed(() => {
|
|
251
|
+
const n = parsedPlans.value.length;
|
|
252
|
+
if (n === 1) return "grid-cols-1 max-w-sm mx-auto";
|
|
253
|
+
if (n === 2) return "grid-cols-1 md:grid-cols-2";
|
|
254
|
+
if (n === 4) return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4";
|
|
255
|
+
return "grid-cols-1 md:grid-cols-3";
|
|
256
|
+
});
|
|
257
|
+
const SM = { none: "0", xs: "8px", sm: "24px", md: "48px", lg: "80px", xl: "128px", "-xs": "-8px", "-sm": "-24px", "-md": "-48px", "-lg": "-80px", "-xl": "-128px" };
|
|
258
|
+
const spacingStyle = computed(() => {
|
|
259
|
+
const s = {};
|
|
260
|
+
if (props.paddingTop && SM[props.paddingTop]) s.paddingTop = SM[props.paddingTop];
|
|
261
|
+
if (props.paddingBottom && SM[props.paddingBottom]) s.paddingBottom = SM[props.paddingBottom];
|
|
262
|
+
if (props.marginTop && SM[props.marginTop]) s.marginTop = SM[props.marginTop];
|
|
263
|
+
if (props.marginBottom && SM[props.marginBottom]) s.marginBottom = SM[props.marginBottom];
|
|
264
|
+
if (props.paddingX && SM[props.paddingX]) {
|
|
265
|
+
const v = SM[props.paddingX];
|
|
266
|
+
if (v.startsWith("-")) {
|
|
267
|
+
s.marginLeft = v;
|
|
268
|
+
s.marginRight = v;
|
|
269
|
+
} else {
|
|
270
|
+
s.paddingLeft = v;
|
|
271
|
+
s.paddingRight = v;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return s;
|
|
275
|
+
});
|
|
276
|
+
</script>
|
|
277
|
+
|
|
278
|
+
<style scoped>
|
|
279
|
+
.sv-out{opacity:0;transform:translateY(16px)}.sv-in{opacity:1;transform:none;transition:opacity .6s ease,transform .6s ease}
|
|
280
|
+
</style>
|