cinqcinqdev-seo 0.1.72 → 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/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/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 → cinqcinq/BlogHero.vue} +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 → cinqcinq/CallToAction.vue} +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 → cinqcinq/ContactForm.vue} +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 → cinqcinq/FAQ.vue} +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 → cinqcinq/Features.vue} +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 → cinqcinq/HeroSection.vue} +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 → cinqcinq/Newsletter.vue} +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 → cinqcinq/Pricing.vue} +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 → cinqcinq/Process.vue} +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 → cinqcinq/Quote.vue} +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 → cinqcinq/RichText.vue} +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 → cinqcinq/ServiceGrid.vue} +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 → cinqcinq/Testimonials.vue} +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 → cinqcinq/TextVisual.vue} +0 -0
- /package/dist/runtime/components/sections/{TextVisual.vue.d.ts → cinqcinq/TextVisual.vue.d.ts} +0 -0
|
@@ -0,0 +1,245 @@
|
|
|
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 row: title + search -->
|
|
6
|
+
<div v-if="badge || title || description || searchable" class="flex flex-col md:flex-row md:items-end gap-6 mb-10 pb-6 border-b" :style="{ borderColor: ca(textColor,'12') }">
|
|
7
|
+
<div class="flex-1">
|
|
8
|
+
<span v-if="badge" class="block text-[9px] font-black uppercase tracking-[0.5em] mb-3" :style="{ opacity: 0.35 }">{{ badge }}</span>
|
|
9
|
+
<component :is="titleTag" v-if="title" class="font-black uppercase leading-[0.88]"
|
|
10
|
+
:style="{ fontSize: titleFontSize, letterSpacing: '-0.03em' }">{{ title }}</component>
|
|
11
|
+
<p v-if="description" :class="[bodyClass, 'leading-relaxed mt-3']" :style="{ opacity: 0.55 }">{{ description }}</p>
|
|
12
|
+
</div>
|
|
13
|
+
<!-- Search input -->
|
|
14
|
+
<div v-if="searchable" class="shrink-0 relative">
|
|
15
|
+
<input
|
|
16
|
+
v-model="query"
|
|
17
|
+
type="search"
|
|
18
|
+
placeholder="Search…"
|
|
19
|
+
:class="[bodyClass, 'w-full md:w-56 pl-9 pr-4 py-2.5 border outline-none transition-all bg-transparent']"
|
|
20
|
+
:style="{ borderColor: ca(textColor,'20'), color: textColor }"
|
|
21
|
+
@input="currentPage = 1"
|
|
22
|
+
/>
|
|
23
|
+
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-sm pointer-events-none" :style="{ opacity: 0.35 }">⌕</span>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- DEFAULT / STRIPED / MINIMAL table -->
|
|
28
|
+
<div v-if="layout !== 'cards'" class="overflow-x-auto border" :style="{ borderColor: ca(textColor,'12') }">
|
|
29
|
+
<table class="w-full min-w-[400px]" style="border-collapse: collapse;">
|
|
30
|
+
<caption v-if="caption" class="text-left px-6 pb-3 text-xs" :style="{ opacity: 0.4 }">{{ caption }}</caption>
|
|
31
|
+
<thead :class="stickyHeader ? 'sticky top-0 z-10' : ''" :style="{ backgroundColor: bgColor }">
|
|
32
|
+
<tr :style="{ borderBottom: `2px solid ${ca(textColor,'15')}` }">
|
|
33
|
+
<th
|
|
34
|
+
v-for="(col, ci) in parsedCols" :key="ci"
|
|
35
|
+
:class="['px-6 py-4 text-[9px] font-black uppercase tracking-[0.4em] select-none', col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left', sortable ? 'cursor-pointer hover:opacity-100' : '']"
|
|
36
|
+
:style="{ opacity: 0.4, borderLeft: ci > 0 ? `1px solid ${ca(textColor,'08')}` : 'none' }"
|
|
37
|
+
@click="sortable && toggleSort(ci)"
|
|
38
|
+
>
|
|
39
|
+
<span class="inline-flex items-center gap-1.5">
|
|
40
|
+
{{ col.label }}
|
|
41
|
+
<span v-if="sortable" class="text-[10px] transition-all" :style="{ opacity: sortCol === ci ? 1 : 0.25 }">
|
|
42
|
+
{{ sortCol === ci ? (sortDir === 'asc' ? '↑' : '↓') : '↕' }}
|
|
43
|
+
</span>
|
|
44
|
+
</span>
|
|
45
|
+
</th>
|
|
46
|
+
</tr>
|
|
47
|
+
</thead>
|
|
48
|
+
<tbody>
|
|
49
|
+
<tr v-if="!pageRows.length">
|
|
50
|
+
<td :colspan="parsedCols.length" class="px-6 py-12 text-center text-sm" :style="{ opacity: 0.3 }">No results found</td>
|
|
51
|
+
</tr>
|
|
52
|
+
<tr
|
|
53
|
+
v-for="(row, ri) in pageRows" :key="ri"
|
|
54
|
+
:style="{
|
|
55
|
+
borderTop: `1px solid ${ca(textColor,'07')}`,
|
|
56
|
+
backgroundColor: layout === 'striped' && ri % 2 === 1 ? ca(textColor,'03') : 'transparent',
|
|
57
|
+
}"
|
|
58
|
+
>
|
|
59
|
+
<td
|
|
60
|
+
v-for="(cell, ci) in row.cells" :key="ci"
|
|
61
|
+
:class="['px-6 py-4', parsedCols[ci]?.align === 'center' ? 'text-center' : parsedCols[ci]?.align === 'right' ? 'text-right' : 'text-left']"
|
|
62
|
+
:style="{ borderLeft: ci > 0 ? `1px solid ${ca(textColor,'06')}` : 'none' }"
|
|
63
|
+
>
|
|
64
|
+
<span v-if="parsedCols[ci]?.badge" class="inline-block text-[9px] font-black uppercase tracking-wider px-2.5 py-0.5 border"
|
|
65
|
+
:style="{ borderColor: ca(textColor,'18'), color: textColor }">{{ cell }}</span>
|
|
66
|
+
<span v-else :class="[bodyClass]" :style="{ opacity: 0.8 }">{{ cell }}</span>
|
|
67
|
+
</td>
|
|
68
|
+
</tr>
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- CARDS layout -->
|
|
74
|
+
<div v-else class="grid gap-0 border divide-x divide-y" :class="gridCols" :style="{ borderColor: ca(textColor,'12') }">
|
|
75
|
+
<div v-if="!pageRows.length" class="col-span-full py-12 text-center text-sm" :style="{ opacity: 0.3 }">No results found</div>
|
|
76
|
+
<div
|
|
77
|
+
v-for="(row, ri) in pageRows" :key="ri"
|
|
78
|
+
class="p-6"
|
|
79
|
+
>
|
|
80
|
+
<dl class="space-y-3">
|
|
81
|
+
<div v-for="(cell, ci) in row.cells" :key="ci" class="flex items-start justify-between gap-4 border-b pb-2.5 last:border-b-0" :style="{ borderColor: ca(textColor,'07') }">
|
|
82
|
+
<dt class="text-[9px] font-black uppercase tracking-[0.4em] shrink-0" :style="{ opacity: 0.35 }">{{ parsedCols[ci]?.label }}</dt>
|
|
83
|
+
<dd :class="[bodyClass, 'font-medium text-right']" :style="{ opacity: 0.8 }">{{ cell }}</dd>
|
|
84
|
+
</div>
|
|
85
|
+
</dl>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Pagination -->
|
|
90
|
+
<div v-if="perPage > 0 && totalPages > 1" class="flex items-center justify-between mt-6 border-t pt-4" :style="{ borderColor: ca(textColor,'10') }">
|
|
91
|
+
<span :class="[bodyClass]" :style="{ opacity: 0.4 }">
|
|
92
|
+
{{ (currentPage - 1) * perPage + 1 }}–{{ Math.min(currentPage * perPage, filteredRows.length) }} of {{ filteredRows.length }}
|
|
93
|
+
</span>
|
|
94
|
+
<div class="flex items-center gap-1">
|
|
95
|
+
<button
|
|
96
|
+
v-for="p in totalPages" :key="p"
|
|
97
|
+
class="w-8 h-8 text-xs font-black border transition-all"
|
|
98
|
+
:style="p === currentPage
|
|
99
|
+
? { backgroundColor: textColor, color: bgColor, borderColor: textColor }
|
|
100
|
+
: { borderColor: ca(textColor,'15'), color: textColor, opacity: 0.5 }"
|
|
101
|
+
@click="currentPage = p"
|
|
102
|
+
>{{ p }}</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
</template>
|
|
109
|
+
|
|
110
|
+
<script setup>
|
|
111
|
+
const ca = (c, h) => {
|
|
112
|
+
const m = c?.match(/rgba\((var\([^)]+\)),\s*[\d.]+\)/);
|
|
113
|
+
return m ? `rgba(${m[1]}, ${Math.round(parseInt(h, 16) / 255 * 1e3) / 1e3})` : (c || "") + h;
|
|
114
|
+
};
|
|
115
|
+
import { ref, computed, onMounted } from "vue";
|
|
116
|
+
const props = defineProps({
|
|
117
|
+
layout: { type: String, default: "default" },
|
|
118
|
+
titleTag: { type: String, default: "h2" },
|
|
119
|
+
badge: { type: String, default: "" },
|
|
120
|
+
title: { type: String, default: "" },
|
|
121
|
+
description: { type: String, default: "" },
|
|
122
|
+
caption: { type: String, default: "" },
|
|
123
|
+
searchable: { type: Boolean, default: false },
|
|
124
|
+
sortable: { type: Boolean, default: true },
|
|
125
|
+
stickyHeader: { type: Boolean, default: false },
|
|
126
|
+
perPage: { type: Number, default: 0 },
|
|
127
|
+
cardColumns: { type: String, default: "auto" },
|
|
128
|
+
columns: { type: Array, default: () => [
|
|
129
|
+
{ label: "Name", align: "left", badge: false },
|
|
130
|
+
{ label: "Role", align: "left", badge: false },
|
|
131
|
+
{ label: "Department", align: "left", badge: false },
|
|
132
|
+
{ label: "Status", align: "left", badge: true }
|
|
133
|
+
] },
|
|
134
|
+
items: { type: Array, default: () => [
|
|
135
|
+
{ cells: "Alice Martin | Lead Designer | Product | Active" },
|
|
136
|
+
{ cells: "Bob Chen | Senior Developer | Engineering | Active" },
|
|
137
|
+
{ cells: "Clara Dumont | Product Manager | Product | On leave" },
|
|
138
|
+
{ cells: "David Kim | UX Researcher | Design | Active" },
|
|
139
|
+
{ cells: "Emma Laurent | DevOps Engineer | Engineering | Active" },
|
|
140
|
+
{ cells: "F\xE9lix Moreau | Data Analyst | Analytics | Active" }
|
|
141
|
+
] },
|
|
142
|
+
bgColor: { type: String, default: "rgba(var(--body-bg), 1)" },
|
|
143
|
+
textColor: { type: String, default: "rgba(var(--text-color), 1)" },
|
|
144
|
+
spacing: { type: String, default: "normal" },
|
|
145
|
+
radius: { type: String, default: "sharp" },
|
|
146
|
+
textDirection: { type: String, default: "ltr" },
|
|
147
|
+
contentWidth: { type: String, default: "normal" },
|
|
148
|
+
textSize: { type: String, default: "md" },
|
|
149
|
+
paddingTop: { type: String, default: "" },
|
|
150
|
+
paddingBottom: { type: String, default: "" },
|
|
151
|
+
marginTop: { type: String, default: "" },
|
|
152
|
+
marginBottom: { type: String, default: "" },
|
|
153
|
+
paddingX: { type: String, default: "" }
|
|
154
|
+
});
|
|
155
|
+
const sectionEl = ref(null);
|
|
156
|
+
const isVisible = ref(false);
|
|
157
|
+
onMounted(() => {
|
|
158
|
+
const obs = new IntersectionObserver(([e]) => {
|
|
159
|
+
if (e.isIntersecting) {
|
|
160
|
+
isVisible.value = true;
|
|
161
|
+
obs.disconnect();
|
|
162
|
+
}
|
|
163
|
+
}, { threshold: 0.05 });
|
|
164
|
+
if (sectionEl.value) obs.observe(sectionEl.value);
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
if (!isVisible.value) isVisible.value = true;
|
|
167
|
+
}, 300);
|
|
168
|
+
});
|
|
169
|
+
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");
|
|
170
|
+
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");
|
|
171
|
+
const titleFontSize = computed(() => ({ sm: "clamp(0.9rem,1vw,1.1rem)", md: "clamp(1rem,1.5vw,1.4rem)", lg: "clamp(2.2rem,5vw,4rem)" })[props.textSize] || "clamp(1rem,1.5vw,1.4rem)");
|
|
172
|
+
const bodyClass = computed(() => ({ sm: "text-xs", md: "text-sm", lg: "text-base" })[props.textSize] || "text-sm");
|
|
173
|
+
const parsedCols = computed(() => (props.columns || []).map((col) => ({
|
|
174
|
+
label: col.label || "",
|
|
175
|
+
align: col.align || "left",
|
|
176
|
+
badge: col.badge || false
|
|
177
|
+
})));
|
|
178
|
+
const parsedRows = computed(() => (props.items || []).map((row) => ({
|
|
179
|
+
cells: (row.cells || "").split("|").map((c) => c.trim())
|
|
180
|
+
})));
|
|
181
|
+
const query = ref("");
|
|
182
|
+
const sortCol = ref(-1);
|
|
183
|
+
const sortDir = ref("asc");
|
|
184
|
+
const toggleSort = (i) => {
|
|
185
|
+
if (sortCol.value === i) sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
|
186
|
+
else {
|
|
187
|
+
sortCol.value = i;
|
|
188
|
+
sortDir.value = "asc";
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const currentPage = ref(1);
|
|
192
|
+
const filteredRows = computed(() => {
|
|
193
|
+
let rows = parsedRows.value;
|
|
194
|
+
if (props.searchable && query.value.trim()) {
|
|
195
|
+
const q = query.value.toLowerCase();
|
|
196
|
+
rows = rows.filter((r) => r.cells.some((c) => c.toLowerCase().includes(q)));
|
|
197
|
+
}
|
|
198
|
+
if (props.sortable && sortCol.value >= 0) {
|
|
199
|
+
rows = [...rows].sort((a, b) => {
|
|
200
|
+
const av = a.cells[sortCol.value] || "";
|
|
201
|
+
const bv = b.cells[sortCol.value] || "";
|
|
202
|
+
const cmp = av.localeCompare(bv, void 0, { numeric: true, sensitivity: "base" });
|
|
203
|
+
return sortDir.value === "asc" ? cmp : -cmp;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return rows;
|
|
207
|
+
});
|
|
208
|
+
const totalPages = computed(() => props.perPage > 0 ? Math.ceil(filteredRows.value.length / props.perPage) : 1);
|
|
209
|
+
const pageRows = computed(() => {
|
|
210
|
+
if (props.perPage <= 0) return filteredRows.value;
|
|
211
|
+
const start = (currentPage.value - 1) * props.perPage;
|
|
212
|
+
return filteredRows.value.slice(start, start + props.perPage);
|
|
213
|
+
});
|
|
214
|
+
const gridCols = computed(() => {
|
|
215
|
+
if (props.cardColumns === "1") return "grid-cols-1";
|
|
216
|
+
if (props.cardColumns === "2") return "grid-cols-1 md:grid-cols-2";
|
|
217
|
+
if (props.cardColumns === "4") return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4";
|
|
218
|
+
const n = parsedCols.value.length;
|
|
219
|
+
if (n <= 3) return "grid-cols-1 md:grid-cols-2";
|
|
220
|
+
return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
|
|
221
|
+
});
|
|
222
|
+
const SM = { none: "0", xs: "8px", sm: "24px", md: "48px", lg: "80px", xl: "128px", "-xs": "-8px", "-sm": "-24px", "-md": "-48px", "-lg": "-80px", "-xl": "-128px" };
|
|
223
|
+
const spacingStyle = computed(() => {
|
|
224
|
+
const s = {};
|
|
225
|
+
if (props.paddingTop && SM[props.paddingTop]) s.paddingTop = SM[props.paddingTop];
|
|
226
|
+
if (props.paddingBottom && SM[props.paddingBottom]) s.paddingBottom = SM[props.paddingBottom];
|
|
227
|
+
if (props.marginTop && SM[props.marginTop]) s.marginTop = SM[props.marginTop];
|
|
228
|
+
if (props.marginBottom && SM[props.marginBottom]) s.marginBottom = SM[props.marginBottom];
|
|
229
|
+
if (props.paddingX && SM[props.paddingX]) {
|
|
230
|
+
const v = SM[props.paddingX];
|
|
231
|
+
if (v.startsWith("-")) {
|
|
232
|
+
s.marginLeft = v;
|
|
233
|
+
s.marginRight = v;
|
|
234
|
+
} else {
|
|
235
|
+
s.paddingLeft = v;
|
|
236
|
+
s.paddingRight = v;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return s;
|
|
240
|
+
});
|
|
241
|
+
</script>
|
|
242
|
+
|
|
243
|
+
<style scoped>
|
|
244
|
+
.sv-out{opacity:0;transform:translateY(16px)}.sv-in{opacity:1;transform:none;transition:opacity .6s ease,transform .6s ease}
|
|
245
|
+
</style>
|
|
@@ -0,0 +1,107 @@
|
|
|
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 class="border-b pb-6 mb-0 flex items-end gap-8 justify-between" :style="{ borderColor: ca(textColor,'15') }">
|
|
7
|
+
<component :is="titleTag" class="font-black uppercase leading-[0.88]" :style="{ fontSize: titleFontSize, letterSpacing: '-0.03em' }">{{ title }}</component>
|
|
8
|
+
<span class="text-[9px] font-black uppercase tracking-[0.4em] pb-1 shrink-0" :style="{ opacity: 0.3 }">Q&A</span>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<!-- Items -->
|
|
12
|
+
<div class="divide-y" :style="{ borderColor: ca(textColor,'10') }">
|
|
13
|
+
<div v-for="(item, index) in items" :key="index" :style="{ borderColor: ca(textColor,'10') }">
|
|
14
|
+
<button @click="toggle(index)" class="w-full flex items-start justify-between py-7 text-left cursor-pointer gap-8">
|
|
15
|
+
<div class="flex items-start gap-6">
|
|
16
|
+
<span class="text-[11px] font-black tabular-nums pt-0.5 shrink-0" :style="{ opacity: 0.25 }">{{ String(index + 1).padStart(2, '0') }}</span>
|
|
17
|
+
<span class="font-black uppercase tracking-tight leading-snug">{{ item.question }}</span>
|
|
18
|
+
</div>
|
|
19
|
+
<span class="text-xl font-light shrink-0 mt-0.5 transition-transform duration-200" :style="{ transform: openIndex === index ? 'rotate(45deg)' : 'none', opacity: 0.4 }">+</span>
|
|
20
|
+
</button>
|
|
21
|
+
<div v-if="openIndex === index" class="pl-14 pb-7">
|
|
22
|
+
<p :class="[bodyClass, 'leading-relaxed']" :style="{ opacity: 0.6 }">{{ item.answer }}</p>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Placeholders -->
|
|
27
|
+
<div v-if="!items.length" v-for="n in 3" :key="n" :style="{ borderColor: ca(textColor,'10') }">
|
|
28
|
+
<div class="flex items-center gap-6 py-7">
|
|
29
|
+
<span class="text-[11px] font-black tabular-nums" :style="{ opacity: 0.15 }">0{{ n }}</span>
|
|
30
|
+
<div class="h-4 flex-1" :style="{ backgroundColor: ca(textColor,'10'), width: n === 1 ? '70%' : n === 2 ? '55%' : '65%' }"></div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
</div>
|
|
36
|
+
</section>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script setup>
|
|
40
|
+
const ca = (c, h) => {
|
|
41
|
+
const m = c?.match(/rgba\((var\([^)]+\)),\s*[\d.]+\)/);
|
|
42
|
+
return m ? `rgba(${m[1]}, ${Math.round(parseInt(h, 16) / 255 * 1e3) / 1e3})` : (c || "") + h;
|
|
43
|
+
};
|
|
44
|
+
import { ref, computed, onMounted } from "vue";
|
|
45
|
+
const props = defineProps({
|
|
46
|
+
title: { type: String, default: "FAQ" },
|
|
47
|
+
titleTag: { type: String, default: "h2" },
|
|
48
|
+
items: { type: Array, default: () => [] },
|
|
49
|
+
bgColor: { type: String, default: "rgba(var(--body-bg), 1)" },
|
|
50
|
+
textColor: { type: String, default: "rgba(var(--text-color), 1)" },
|
|
51
|
+
spacing: { type: String, default: "normal" },
|
|
52
|
+
radius: { type: String, default: "sharp" },
|
|
53
|
+
textDirection: { type: String, default: "ltr" },
|
|
54
|
+
contentWidth: { type: String, default: "narrow" },
|
|
55
|
+
textSize: { type: String, default: "md" },
|
|
56
|
+
paddingTop: { type: String, default: "" },
|
|
57
|
+
paddingBottom: { type: String, default: "" },
|
|
58
|
+
marginTop: { type: String, default: "" },
|
|
59
|
+
marginBottom: { type: String, default: "" },
|
|
60
|
+
paddingX: { type: String, default: "" }
|
|
61
|
+
});
|
|
62
|
+
const sectionEl = ref(null);
|
|
63
|
+
const isVisible = ref(false);
|
|
64
|
+
onMounted(() => {
|
|
65
|
+
const obs = new IntersectionObserver(([e]) => {
|
|
66
|
+
if (e.isIntersecting) {
|
|
67
|
+
isVisible.value = true;
|
|
68
|
+
obs.disconnect();
|
|
69
|
+
}
|
|
70
|
+
}, { threshold: 0.06 });
|
|
71
|
+
if (sectionEl.value) obs.observe(sectionEl.value);
|
|
72
|
+
setTimeout(() => {
|
|
73
|
+
if (!isVisible.value) isVisible.value = true;
|
|
74
|
+
}, 300);
|
|
75
|
+
});
|
|
76
|
+
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");
|
|
77
|
+
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-3xl mx-auto");
|
|
78
|
+
const titleFontSize = computed(() => ({ sm: "clamp(1rem,1.5vw,1.3rem)", md: "clamp(1.5rem,2.5vw,2rem)", lg: "clamp(2.5rem,6vw,5rem)" })[props.textSize] || "clamp(1.5rem,2.5vw,2rem)");
|
|
79
|
+
const bodyClass = computed(() => ({ sm: "text-xs", md: "text-sm", lg: "text-base" })[props.textSize] || "text-sm");
|
|
80
|
+
const openIndex = ref(null);
|
|
81
|
+
const toggle = (index) => {
|
|
82
|
+
openIndex.value = openIndex.value === index ? null : index;
|
|
83
|
+
};
|
|
84
|
+
const SM = { none: "0", xs: "8px", sm: "24px", md: "48px", lg: "80px", xl: "128px", "-xs": "-8px", "-sm": "-24px", "-md": "-48px", "-lg": "-80px", "-xl": "-128px" };
|
|
85
|
+
const spacingStyle = computed(() => {
|
|
86
|
+
const s = {};
|
|
87
|
+
if (props.paddingTop && SM[props.paddingTop]) s.paddingTop = SM[props.paddingTop];
|
|
88
|
+
if (props.paddingBottom && SM[props.paddingBottom]) s.paddingBottom = SM[props.paddingBottom];
|
|
89
|
+
if (props.marginTop && SM[props.marginTop]) s.marginTop = SM[props.marginTop];
|
|
90
|
+
if (props.marginBottom && SM[props.marginBottom]) s.marginBottom = SM[props.marginBottom];
|
|
91
|
+
if (props.paddingX && SM[props.paddingX]) {
|
|
92
|
+
const v = SM[props.paddingX];
|
|
93
|
+
if (v.startsWith("-")) {
|
|
94
|
+
s.marginLeft = v;
|
|
95
|
+
s.marginRight = v;
|
|
96
|
+
} else {
|
|
97
|
+
s.paddingLeft = v;
|
|
98
|
+
s.paddingRight = v;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return s;
|
|
102
|
+
});
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<style scoped>
|
|
106
|
+
.sv-out{opacity:0;transform:translateY(16px)}.sv-in{opacity:1;transform:none;transition:opacity .6s ease,transform .6s ease}
|
|
107
|
+
</style>
|
|
@@ -0,0 +1,103 @@
|
|
|
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 class="border-b mb-0 pb-6 flex items-end justify-between gap-8" :style="{ borderColor: ca(textColor,'15') }">
|
|
7
|
+
<component :is="titleTag" class="font-black uppercase leading-[0.88]" :style="{ fontSize: titleFontSize, letterSpacing: '-0.03em' }">{{ title }}</component>
|
|
8
|
+
<span class="text-[9px] font-black uppercase tracking-[0.4em] shrink-0 pb-1" :style="{ opacity: 0.3 }">{{ items.length || 3 }} items</span>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<!-- Grid -->
|
|
12
|
+
<div v-if="items.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 border-b" :style="{ borderColor: ca(textColor,'10') }">
|
|
13
|
+
<div v-for="(item, index) in items" :key="index"
|
|
14
|
+
class="p-8 border-b border-r last:border-r-0 md:[&:nth-child(2n)]:border-r-0 lg:[&:nth-child(2n)]:border-r lg:[&:nth-child(3n)]:border-r-0 transition-colors hover:bg-black/[0.02]"
|
|
15
|
+
:style="{ borderColor: ca(textColor,'10') }">
|
|
16
|
+
<div class="flex items-start justify-between mb-6">
|
|
17
|
+
<span class="font-black tabular-nums" :style="{ fontSize: 'clamp(1.8rem,3vw,2.5rem)', opacity: 0.12, letterSpacing: '-0.04em' }">{{ String(index + 1).padStart(2, '0') }}</span>
|
|
18
|
+
<div class="w-px self-stretch" :style="{ backgroundColor: ca(textColor,'10') }"></div>
|
|
19
|
+
</div>
|
|
20
|
+
<component :is="itemTag" class="font-black uppercase tracking-tight mb-3 leading-tight">{{ item.title }}</component>
|
|
21
|
+
<p :class="[bodyClass, 'leading-relaxed']" :style="{ opacity: 0.55 }">{{ item.description || item.text }}</p>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- Placeholder -->
|
|
26
|
+
<div v-else class="grid grid-cols-1 md:grid-cols-3 border-b" :style="{ borderColor: ca(textColor,'10') }">
|
|
27
|
+
<div v-for="n in 3" :key="n" class="p-8 border-r last:border-r-0" :style="{ borderColor: ca(textColor,'10') }">
|
|
28
|
+
<span class="block font-black tabular-nums mb-6" :style="{ fontSize: '2rem', opacity: 0.08 }">0{{ n }}</span>
|
|
29
|
+
<div class="h-4 mb-3" :style="{ backgroundColor: ca(textColor,'12'), width: '60%' }"></div>
|
|
30
|
+
<div class="h-3" :style="{ backgroundColor: ca(textColor,'08'), width: '80%' }"></div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
</div>
|
|
35
|
+
</section>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup>
|
|
39
|
+
const ca = (c, h) => {
|
|
40
|
+
const m = c?.match(/rgba\((var\([^)]+\)),\s*[\d.]+\)/);
|
|
41
|
+
return m ? `rgba(${m[1]}, ${Math.round(parseInt(h, 16) / 255 * 1e3) / 1e3})` : (c || "") + h;
|
|
42
|
+
};
|
|
43
|
+
import { ref, computed, onMounted } from "vue";
|
|
44
|
+
const props = defineProps({
|
|
45
|
+
title: { type: String, default: "Why choose us?" },
|
|
46
|
+
titleTag: { type: String, default: "h2" },
|
|
47
|
+
itemTag: { type: String, default: "h3" },
|
|
48
|
+
items: { type: Array, default: () => [] },
|
|
49
|
+
bgColor: { type: String, default: "rgba(var(--body-bg), 1)" },
|
|
50
|
+
textColor: { type: String, default: "rgba(var(--text-color), 1)" },
|
|
51
|
+
spacing: { type: String, default: "normal" },
|
|
52
|
+
radius: { type: String, default: "sharp" },
|
|
53
|
+
textDirection: { type: String, default: "ltr" },
|
|
54
|
+
contentWidth: { type: String, default: "normal" },
|
|
55
|
+
textSize: { type: String, default: "md" },
|
|
56
|
+
paddingTop: { type: String, default: "" },
|
|
57
|
+
paddingBottom: { type: String, default: "" },
|
|
58
|
+
marginTop: { type: String, default: "" },
|
|
59
|
+
marginBottom: { type: String, default: "" },
|
|
60
|
+
paddingX: { type: String, default: "" }
|
|
61
|
+
});
|
|
62
|
+
const sectionEl = ref(null);
|
|
63
|
+
const isVisible = ref(false);
|
|
64
|
+
onMounted(() => {
|
|
65
|
+
const obs = new IntersectionObserver(([e]) => {
|
|
66
|
+
if (e.isIntersecting) {
|
|
67
|
+
isVisible.value = true;
|
|
68
|
+
obs.disconnect();
|
|
69
|
+
}
|
|
70
|
+
}, { threshold: 0.06 });
|
|
71
|
+
if (sectionEl.value) obs.observe(sectionEl.value);
|
|
72
|
+
setTimeout(() => {
|
|
73
|
+
if (!isVisible.value) isVisible.value = true;
|
|
74
|
+
}, 300);
|
|
75
|
+
});
|
|
76
|
+
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");
|
|
77
|
+
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");
|
|
78
|
+
const titleFontSize = computed(() => ({ sm: "clamp(1.2rem,2vw,1.8rem)", md: "clamp(1.8rem,3vw,2.8rem)", lg: "clamp(2.5rem,6vw,5rem)" })[props.textSize] || "clamp(1.8rem,3vw,2.8rem)");
|
|
79
|
+
const bodyClass = computed(() => ({ sm: "text-xs", md: "text-sm", lg: "text-base" })[props.textSize] || "text-sm");
|
|
80
|
+
const SM = { none: "0", xs: "8px", sm: "24px", md: "48px", lg: "80px", xl: "128px", "-xs": "-8px", "-sm": "-24px", "-md": "-48px", "-lg": "-80px", "-xl": "-128px" };
|
|
81
|
+
const spacingStyle = computed(() => {
|
|
82
|
+
const s = {};
|
|
83
|
+
if (props.paddingTop && SM[props.paddingTop]) s.paddingTop = SM[props.paddingTop];
|
|
84
|
+
if (props.paddingBottom && SM[props.paddingBottom]) s.paddingBottom = SM[props.paddingBottom];
|
|
85
|
+
if (props.marginTop && SM[props.marginTop]) s.marginTop = SM[props.marginTop];
|
|
86
|
+
if (props.marginBottom && SM[props.marginBottom]) s.marginBottom = SM[props.marginBottom];
|
|
87
|
+
if (props.paddingX && SM[props.paddingX]) {
|
|
88
|
+
const v = SM[props.paddingX];
|
|
89
|
+
if (v.startsWith("-")) {
|
|
90
|
+
s.marginLeft = v;
|
|
91
|
+
s.marginRight = v;
|
|
92
|
+
} else {
|
|
93
|
+
s.paddingLeft = v;
|
|
94
|
+
s.paddingRight = v;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return s;
|
|
98
|
+
});
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<style scoped>
|
|
102
|
+
.sv-out{opacity:0;transform:translateY(16px)}.sv-in{opacity:1;transform:none;transition:opacity .6s ease,transform .6s ease}
|
|
103
|
+
</style>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section
|
|
3
|
+
ref="rootEl"
|
|
4
|
+
class="relative overflow-hidden"
|
|
5
|
+
:dir="textDirection"
|
|
6
|
+
:style="[{ backgroundColor: bgColor, color: textColor }, spacingStyle]"
|
|
7
|
+
>
|
|
8
|
+
<div :class="['relative mx-auto px-8 md:px-16', containerClass, variant === 'compact' ? 'py-14 md:py-20' : 'py-20 md:py-28']">
|
|
9
|
+
|
|
10
|
+
<!-- Grid layout: text left, images right -->
|
|
11
|
+
<div class="grid md:grid-cols-2 gap-12 md:gap-16 items-center">
|
|
12
|
+
|
|
13
|
+
<!-- Left: text block -->
|
|
14
|
+
<div>
|
|
15
|
+
<div class="border-l-4 pl-8 mb-10" :style="{ borderColor: textColor }">
|
|
16
|
+
<component :is="titleTag" class="font-black uppercase leading-[0.88]" :style="{ fontSize: titleFontSize, letterSpacing: '-0.04em' }">
|
|
17
|
+
{{ title }}
|
|
18
|
+
<template v-if="titleLine2Prefix || titleHighlight">
|
|
19
|
+
<br />
|
|
20
|
+
<span v-if="titleLine2Prefix">{{ titleLine2Prefix }} </span>
|
|
21
|
+
<span v-if="titleHighlight" :style="{ color: highlightColor }">{{ titleHighlight }}</span>
|
|
22
|
+
</template>
|
|
23
|
+
</component>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<p v-if="description" class="text-sm leading-relaxed mb-8 max-w-sm" :style="{ opacity: 0.6 }">{{ description }}</p>
|
|
27
|
+
|
|
28
|
+
<div v-if="primaryCtaText || secondaryCtaText" class="flex flex-wrap gap-3">
|
|
29
|
+
<NuxtLink v-if="primaryCtaText" :to="primaryCtaLink || '#'"
|
|
30
|
+
class="inline-flex items-center gap-3 px-7 py-3.5 text-[11px] font-black uppercase tracking-widest border transition-all hover:gap-6"
|
|
31
|
+
:style="{ backgroundColor: textColor, color: bgColor, borderColor: textColor }">
|
|
32
|
+
{{ primaryCtaText }}<span>→</span>
|
|
33
|
+
</NuxtLink>
|
|
34
|
+
<NuxtLink v-if="secondaryCtaText" :to="secondaryCtaLink || '#'"
|
|
35
|
+
class="inline-flex items-center gap-3 px-7 py-3.5 border text-[11px] font-black uppercase tracking-widest transition-all hover:gap-6"
|
|
36
|
+
:style="{ borderColor: ca(textColor,'30'), color: textColor }">
|
|
37
|
+
{{ secondaryCtaText }}
|
|
38
|
+
</NuxtLink>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<p v-if="socialProof" class="text-xs font-medium mt-4 border-t pt-4" :style="{ opacity: 0.4, borderColor: ca(textColor,'12') }">{{ socialProof }}</p>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- Right: images grid -->
|
|
45
|
+
<div v-if="images?.length" class="grid grid-cols-2 gap-2">
|
|
46
|
+
<div
|
|
47
|
+
v-for="(img, i) in images.slice(0, 4)" :key="i"
|
|
48
|
+
:class="['overflow-hidden border', i === 0 ? 'col-span-2 aspect-[16/7]' : 'aspect-square']"
|
|
49
|
+
:style="{ borderColor: ca(textColor,'12') }"
|
|
50
|
+
>
|
|
51
|
+
<img :src="typeof img === 'object' ? img.url : img" :alt="typeof img === 'object' ? (img.alt || '') : ''" class="w-full h-full object-cover" />
|
|
52
|
+
</div>
|
|
53
|
+
<div v-if="!images.length" v-for="n in 3" :key="n" :class="['border', n === 1 ? 'col-span-2 aspect-[16/7]' : 'aspect-square']" :style="{ borderColor: ca(textColor,'10'), backgroundColor: ca(textColor,'04') }"></div>
|
|
54
|
+
</div>
|
|
55
|
+
<div v-else class="grid grid-cols-2 gap-2">
|
|
56
|
+
<div v-for="n in 3" :key="n" :class="['border', n === 1 ? 'col-span-2 aspect-[16/7]' : 'aspect-square']" :style="{ borderColor: ca(textColor,'10'), backgroundColor: ca(textColor,'04') }"></div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<script setup>
|
|
65
|
+
import { ref, computed } from "vue";
|
|
66
|
+
const ca = (c, h) => {
|
|
67
|
+
if (!c) return "#000" + h;
|
|
68
|
+
const m = c.match(/rgba\((var\([^)]+\)),\s*[\d.]+\)/);
|
|
69
|
+
return m ? `rgba(${m[1]}, ${Math.round(parseInt(h, 16) / 255 * 1e3) / 1e3})` : c + h;
|
|
70
|
+
};
|
|
71
|
+
const props = defineProps({
|
|
72
|
+
titleTag: { type: String, default: "h1" },
|
|
73
|
+
title: { type: String, default: "The Headline" },
|
|
74
|
+
titleLine2Prefix: { type: String, default: "" },
|
|
75
|
+
titleHighlight: { type: String, default: "Highlighted" },
|
|
76
|
+
highlightColor: { type: String, default: "rgba(var(--primary-color), 1)" },
|
|
77
|
+
description: { type: String, default: "A compelling subtitle that explains your value proposition clearly." },
|
|
78
|
+
socialProof: { type: String, default: "" },
|
|
79
|
+
primaryCtaText: { type: String, default: "Get Started" },
|
|
80
|
+
primaryCtaLink: { type: String, default: "#" },
|
|
81
|
+
secondaryCtaText: { type: String, default: "" },
|
|
82
|
+
secondaryCtaLink: { type: String, default: "#" },
|
|
83
|
+
images: { type: Array, default: () => [] },
|
|
84
|
+
variant: { type: String, default: "standard" },
|
|
85
|
+
animation: { type: String, default: "subtle" },
|
|
86
|
+
bgColor: { type: String, default: "rgba(var(--body-bg), 1)" },
|
|
87
|
+
textColor: { type: String, default: "rgba(var(--text-color), 1)" },
|
|
88
|
+
contentWidth: { type: String, default: "normal" },
|
|
89
|
+
textSize: { type: String, default: "md" },
|
|
90
|
+
textDirection: { type: String, default: "ltr" },
|
|
91
|
+
paddingTop: { type: String, default: "" },
|
|
92
|
+
paddingBottom: { type: String, default: "" },
|
|
93
|
+
marginTop: { type: String, default: "" },
|
|
94
|
+
marginBottom: { type: String, default: "" }
|
|
95
|
+
});
|
|
96
|
+
const rootEl = ref(null);
|
|
97
|
+
const containerClass = computed(() => ({ narrow: "max-w-4xl 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");
|
|
98
|
+
const titleFontSize = computed(() => ({ sm: "clamp(2rem,4vw,3.5rem)", md: "clamp(2.5rem,5vw,4.5rem)", lg: "clamp(3rem,7vw,6rem)" })[props.textSize] || "clamp(2.5rem,5vw,4.5rem)");
|
|
99
|
+
const SM = { none: "0", xs: "8px", sm: "24px", md: "48px", lg: "80px", xl: "128px", "-xs": "-8px", "-sm": "-24px", "-md": "-48px", "-lg": "-80px", "-xl": "-128px" };
|
|
100
|
+
const spacingStyle = computed(() => {
|
|
101
|
+
const s = {};
|
|
102
|
+
if (props.paddingTop && SM[props.paddingTop]) s.paddingTop = SM[props.paddingTop];
|
|
103
|
+
if (props.paddingBottom && SM[props.paddingBottom]) s.paddingBottom = SM[props.paddingBottom];
|
|
104
|
+
if (props.marginTop && SM[props.marginTop]) s.marginTop = SM[props.marginTop];
|
|
105
|
+
if (props.marginBottom && SM[props.marginBottom]) s.marginBottom = SM[props.marginBottom];
|
|
106
|
+
return s;
|
|
107
|
+
});
|
|
108
|
+
</script>
|