cinqcinqdev-seo 0.1.14 → 0.1.16
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 +554 -113
- package/dist/module.d.mts +6 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +14 -6
- package/dist/runtime/assets/admin-tw.css +1 -1
- package/dist/runtime/components/admin/Navbar.vue +6 -5
- package/dist/runtime/components/admin/Sidebar.vue +21 -8
- package/dist/runtime/components/sections/ContactForm.d.vue.ts +10 -0
- package/dist/runtime/components/sections/ContactForm.vue +138 -0
- package/dist/runtime/components/sections/ContactForm.vue.d.ts +10 -0
- package/dist/runtime/components/sections/FAQ.d.vue.ts +3 -0
- package/dist/runtime/components/sections/FAQ.vue +46 -0
- package/dist/runtime/components/sections/FAQ.vue.d.ts +3 -0
- package/dist/runtime/components/sections/Features.d.vue.ts +3 -0
- package/dist/runtime/components/sections/Features.vue +41 -0
- package/dist/runtime/components/sections/Features.vue.d.ts +3 -0
- package/dist/runtime/components/sections/HeroSection.d.vue.ts +3 -0
- package/dist/runtime/components/sections/HeroSection.vue +133 -0
- package/dist/runtime/components/sections/HeroSection.vue.d.ts +3 -0
- package/dist/runtime/components/sections/Pricing.d.vue.ts +3 -0
- package/dist/runtime/components/sections/Pricing.vue +108 -0
- package/dist/runtime/components/sections/Pricing.vue.d.ts +3 -0
- package/dist/runtime/components/sections/Process.d.vue.ts +3 -0
- package/dist/runtime/components/sections/Process.vue +95 -0
- package/dist/runtime/components/sections/Process.vue.d.ts +3 -0
- package/dist/runtime/components/sections/ServiceGrid.d.vue.ts +3 -0
- package/dist/runtime/components/sections/ServiceGrid.vue +97 -0
- package/dist/runtime/components/sections/ServiceGrid.vue.d.ts +3 -0
- package/dist/runtime/components/sections/Testimonials.d.vue.ts +3 -0
- package/dist/runtime/components/sections/Testimonials.vue +95 -0
- package/dist/runtime/components/sections/Testimonials.vue.d.ts +3 -0
- package/dist/runtime/components/sections/TextVisual.d.vue.ts +3 -0
- package/dist/runtime/components/sections/TextVisual.vue +94 -0
- package/dist/runtime/components/sections/TextVisual.vue.d.ts +3 -0
- package/dist/runtime/composables/useAdminBasePath.d.ts +1 -0
- package/dist/runtime/composables/useAdminBasePath.js +5 -0
- package/dist/runtime/composables/useAdminSections.d.ts +0 -7
- package/dist/runtime/composables/useAdminSections.js +68 -17
- package/dist/runtime/composables/useAdminSettings.d.ts +31 -0
- package/dist/runtime/composables/useAdminSettings.js +31 -0
- package/dist/runtime/middleware/admin-auth.js +3 -2
- package/dist/runtime/pages/admin/editor/[id].vue +10 -5
- package/dist/runtime/pages/admin/index.vue +3 -1
- package/dist/runtime/pages/admin/pages/[type].vue +5 -3
- package/dist/runtime/pages/admin/personas/[id].vue +3 -2
- package/dist/runtime/pages/admin/personas/index.vue +3 -2
- package/dist/runtime/pages/admin/setup.d.vue.ts +3 -0
- package/dist/runtime/pages/admin/setup.vue +220 -0
- package/dist/runtime/pages/admin/setup.vue.d.ts +3 -0
- package/dist/runtime/pages/admin/strategies/[id].vue +3 -2
- package/dist/runtime/pages/admin/strategies/index.vue +3 -2
- package/dist/runtime/server/api/ai/generate.post.js +8 -3
- package/dist/runtime/server/api/ai/seo-audit.post.js +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section :class="[sectionPad]" :style="{ backgroundColor: bgColor, color: textColor }">
|
|
3
|
+
<div class="max-w-6xl mx-auto">
|
|
4
|
+
|
|
5
|
+
<div v-if="title || description" class="mb-12 max-w-xl">
|
|
6
|
+
<span v-if="badge" class="inline-block text-[10px] font-bold tracking-[0.5em] uppercase px-3 py-1 rounded-full border mb-4" :style="{ borderColor: textColor + '25', color: textColor + 'aa' }">{{ badge }}</span>
|
|
7
|
+
<component :is="titleTag" class="text-3xl md:text-5xl font-black uppercase tracking-tighter leading-[0.9] mb-3">{{ title }}</component>
|
|
8
|
+
<p v-if="description" class="text-sm leading-relaxed" :style="{ opacity: 0.55 }">{{ description }}</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<!-- GRID -->
|
|
12
|
+
<div v-if="layout === 'grid'" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
13
|
+
<div v-for="(item, i) in items" :key="i" :class="['flex flex-col p-7 border transition-all hover:-translate-y-1', cardRound]" :style="{ backgroundColor: textColor + '04', borderColor: textColor + '10' }">
|
|
14
|
+
<div v-if="item.rating" class="flex gap-0.5 mb-4">
|
|
15
|
+
<span v-for="s in 5" :key="s" class="text-xs" :style="{ opacity: s <= item.rating ? 1 : 0.15 }">★</span>
|
|
16
|
+
</div>
|
|
17
|
+
<p class="text-sm leading-relaxed flex-1 italic mb-6" :style="{ opacity: 0.7 }">"{{ item.quote }}"</p>
|
|
18
|
+
<div class="flex items-center gap-3 pt-4 border-t" :style="{ borderColor: textColor + '10' }">
|
|
19
|
+
<img v-if="item.avatar" :src="item.avatar" class="w-9 h-9 rounded-full object-cover shrink-0" />
|
|
20
|
+
<div v-else class="w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black shrink-0" :style="{ backgroundColor: textColor + '15' }">{{ (item.name || '?')[0] }}</div>
|
|
21
|
+
<div>
|
|
22
|
+
<p class="text-[11px] font-black uppercase tracking-wide leading-none">{{ item.name }}</p>
|
|
23
|
+
<p v-if="item.role || item.company" class="text-[9px] mt-0.5" :style="{ opacity: 0.4 }">{{ [item.role, item.company].filter(Boolean).join(' · ') }}</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- FEATURED -->
|
|
30
|
+
<div v-else-if="layout === 'featured'">
|
|
31
|
+
<div v-if="items[0]" :class="['p-10 border mb-6', cardRound]" :style="{ backgroundColor: textColor + '05', borderColor: textColor + '10' }">
|
|
32
|
+
<div v-if="items[0].rating" class="flex gap-0.5 mb-6">
|
|
33
|
+
<span v-for="s in 5" :key="s" class="text-base" :style="{ opacity: s <= items[0].rating ? 1 : 0.15 }">★</span>
|
|
34
|
+
</div>
|
|
35
|
+
<p class="text-xl md:text-3xl font-bold italic leading-snug mb-8 max-w-3xl" :style="{ opacity: 0.8 }">"{{ items[0].quote }}"</p>
|
|
36
|
+
<div class="flex items-center gap-4">
|
|
37
|
+
<img v-if="items[0].avatar" :src="items[0].avatar" class="w-12 h-12 rounded-full object-cover" />
|
|
38
|
+
<div v-else class="w-12 h-12 rounded-full flex items-center justify-center text-sm font-black" :style="{ backgroundColor: textColor + '15' }">{{ (items[0].name || '?')[0] }}</div>
|
|
39
|
+
<div>
|
|
40
|
+
<p class="text-sm font-black uppercase tracking-wide">{{ items[0].name }}</p>
|
|
41
|
+
<p v-if="items[0].role || items[0].company" class="text-[10px]" :style="{ opacity: 0.4 }">{{ [items[0].role, items[0].company].filter(Boolean).join(' · ') }}</p>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
46
|
+
<div v-for="(item, i) in items.slice(1)" :key="i" :class="['p-7 border', cardRound]" :style="{ backgroundColor: textColor + '03', borderColor: textColor + '08' }">
|
|
47
|
+
<p class="text-sm italic leading-relaxed mb-5" :style="{ opacity: 0.7 }">"{{ item.quote }}"</p>
|
|
48
|
+
<div class="flex items-center gap-3">
|
|
49
|
+
<img v-if="item.avatar" :src="item.avatar" class="w-8 h-8 rounded-full object-cover" />
|
|
50
|
+
<div v-else class="w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-black" :style="{ backgroundColor: textColor + '12' }">{{ (item.name || '?')[0] }}</div>
|
|
51
|
+
<div>
|
|
52
|
+
<p class="text-[10px] font-black uppercase">{{ item.name }}</p>
|
|
53
|
+
<p v-if="item.role" class="text-[9px]" :style="{ opacity: 0.4 }">{{ item.role }}</p>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- WALL / MINIMAL -->
|
|
61
|
+
<div v-else class="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6">
|
|
62
|
+
<div v-for="(item, i) in items" :key="i" :class="['break-inside-avoid p-6 border mb-6', cardRound]" :style="{ backgroundColor: textColor + '04', borderColor: textColor + '08' }">
|
|
63
|
+
<div v-if="item.rating" class="flex gap-0.5 mb-3">
|
|
64
|
+
<span v-for="s in 5" :key="s" class="text-[10px]" :style="{ opacity: s <= item.rating ? 1 : 0.15 }">★</span>
|
|
65
|
+
</div>
|
|
66
|
+
<p class="text-sm italic leading-relaxed mb-4" :style="{ opacity: 0.7 }">"{{ item.quote }}"</p>
|
|
67
|
+
<div class="flex items-center gap-2.5">
|
|
68
|
+
<img v-if="item.avatar" :src="item.avatar" class="w-7 h-7 rounded-full object-cover" />
|
|
69
|
+
<div v-else class="w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black" :style="{ backgroundColor: textColor + '15' }">{{ (item.name || '?')[0] }}</div>
|
|
70
|
+
<p class="text-[10px] font-black uppercase">{{ item.name }}</p>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<script setup>
|
|
80
|
+
import { computed } from "vue";
|
|
81
|
+
const props = defineProps({
|
|
82
|
+
layout: { type: String, default: "grid" },
|
|
83
|
+
title: { type: String, default: "What our clients say" },
|
|
84
|
+
titleTag: { type: String, default: "h2" },
|
|
85
|
+
badge: { type: String, default: "" },
|
|
86
|
+
description: { type: String, default: "" },
|
|
87
|
+
items: { type: Array, default: () => [] },
|
|
88
|
+
bgColor: { type: String, default: "#ffffff" },
|
|
89
|
+
textColor: { type: String, default: "#000000" },
|
|
90
|
+
spacing: { type: String, default: "normal" },
|
|
91
|
+
radius: { type: String, default: "rounded" }
|
|
92
|
+
});
|
|
93
|
+
const sectionPad = computed(() => ({ compact: "py-8 px-4 md:px-8", normal: "py-14 px-6 md:px-12", spacious: "py-24 px-8 md:px-16" })[props.spacing] || "py-14 px-6 md:px-12");
|
|
94
|
+
const cardRound = computed(() => ({ sharp: "rounded-xl", rounded: "rounded-3xl", pill: "rounded-[40px]" })[props.radius] || "rounded-3xl");
|
|
95
|
+
</script>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section :class="[sectionPad]" :style="{ backgroundColor: bgColor, color: textColor }">
|
|
3
|
+
|
|
4
|
+
<!-- SPLIT -->
|
|
5
|
+
<div v-if="layout === 'split'" :class="['max-w-6xl mx-auto flex flex-col md:flex-row gap-12 items-center', reverse ? 'md:flex-row-reverse' : '']">
|
|
6
|
+
<div class="flex-1 space-y-6">
|
|
7
|
+
<span v-if="badge" class="inline-block text-[10px] font-bold tracking-[0.5em] uppercase px-3 py-1 rounded-full border" :style="{ borderColor: textColor + '25', color: textColor + 'aa' }">{{ badge }}</span>
|
|
8
|
+
<component :is="titleTag" class="text-3xl md:text-4xl font-black uppercase tracking-tighter leading-[0.9]">{{ title }}</component>
|
|
9
|
+
<p class="leading-relaxed text-sm max-w-md" :style="{ opacity: 0.55 }">{{ content }}</p>
|
|
10
|
+
<div v-if="ctas && ctas.length" class="flex flex-wrap gap-3 pt-1">
|
|
11
|
+
<NuxtLink v-for="(cta, i) in ctas" :key="i" :to="cta.link || '#'"
|
|
12
|
+
class="inline-flex items-center gap-2.5 px-6 py-3 rounded-full text-[11px] font-black uppercase tracking-widest transition-all hover:gap-5"
|
|
13
|
+
:style="i === 0 ? { backgroundColor: textColor, color: bgColor } : { border: `1px solid ${textColor}40`, color: textColor }">
|
|
14
|
+
{{ cta.text }}<span>→</span>
|
|
15
|
+
</NuxtLink>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div :class="['flex-1 overflow-hidden relative', imageContainerClass]">
|
|
19
|
+
<img v-if="image" :src="image" :alt="imageAlt || title" class="w-full h-full object-cover transition-transform duration-700 hover:scale-105" />
|
|
20
|
+
<div v-else class="w-full h-full" :style="{ backgroundColor: textColor + '08' }"></div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<!-- STACKED -->
|
|
25
|
+
<div v-else-if="layout === 'stacked'" class="max-w-3xl mx-auto space-y-8">
|
|
26
|
+
<div :class="['w-full overflow-hidden relative', imageContainerClass]">
|
|
27
|
+
<img v-if="image" :src="image" class="w-full h-full object-cover" />
|
|
28
|
+
<div v-else class="w-full h-full" :style="{ backgroundColor: textColor + '08' }"></div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="space-y-5">
|
|
31
|
+
<span v-if="badge" class="inline-block text-[10px] font-bold tracking-[0.5em] uppercase px-3 py-1 rounded-full border" :style="{ borderColor: textColor + '25', color: textColor + 'aa' }">{{ badge }}</span>
|
|
32
|
+
<component :is="titleTag" class="text-3xl md:text-4xl font-black uppercase tracking-tighter leading-[0.9]">{{ title }}</component>
|
|
33
|
+
<p class="leading-relaxed text-sm" :style="{ opacity: 0.55 }">{{ content }}</p>
|
|
34
|
+
<div v-if="ctas && ctas.length" class="flex flex-wrap gap-3 pt-1">
|
|
35
|
+
<NuxtLink v-for="(cta, i) in ctas" :key="i" :to="cta.link || '#'"
|
|
36
|
+
class="inline-flex items-center gap-2.5 px-6 py-3 rounded-full text-[11px] font-black uppercase tracking-widest transition-all hover:gap-5"
|
|
37
|
+
:style="i === 0 ? { backgroundColor: textColor, color: bgColor } : { border: `1px solid ${textColor}40`, color: textColor }">
|
|
38
|
+
{{ cta.text }}<span>→</span>
|
|
39
|
+
</NuxtLink>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- WIDE -->
|
|
45
|
+
<div v-else :class="['max-w-6xl mx-auto flex flex-col md:flex-row gap-10 items-center', reverse ? 'md:flex-row-reverse' : '']">
|
|
46
|
+
<div :class="['md:w-2/3 w-full overflow-hidden relative', imageContainerClass]">
|
|
47
|
+
<img v-if="image" :src="image" class="w-full h-full object-cover transition-transform duration-700 hover:scale-105" />
|
|
48
|
+
<div v-else class="w-full h-full" :style="{ backgroundColor: textColor + '08' }"></div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="md:w-1/3 space-y-5">
|
|
51
|
+
<span v-if="badge" class="inline-block text-[10px] font-bold tracking-[0.5em] uppercase px-3 py-1 rounded-full border" :style="{ borderColor: textColor + '25', color: textColor + 'aa' }">{{ badge }}</span>
|
|
52
|
+
<component :is="titleTag" class="text-2xl md:text-3xl font-black uppercase tracking-tighter leading-[0.9]">{{ title }}</component>
|
|
53
|
+
<p class="leading-relaxed text-sm" :style="{ opacity: 0.55 }">{{ content }}</p>
|
|
54
|
+
<div v-if="ctas && ctas.length" class="flex flex-wrap gap-3 pt-1">
|
|
55
|
+
<NuxtLink v-for="(cta, i) in ctas" :key="i" :to="cta.link || '#'"
|
|
56
|
+
class="inline-flex items-center gap-2.5 px-6 py-3 rounded-full text-[11px] font-black uppercase tracking-widest transition-all hover:gap-5"
|
|
57
|
+
:style="i === 0 ? { backgroundColor: textColor, color: bgColor } : { border: `1px solid ${textColor}40`, color: textColor }">
|
|
58
|
+
{{ cta.text }}<span>→</span>
|
|
59
|
+
</NuxtLink>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
</section>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<script setup>
|
|
68
|
+
import { computed } from "vue";
|
|
69
|
+
const props = defineProps({
|
|
70
|
+
layout: { type: String, default: "split" },
|
|
71
|
+
title: { type: String, default: "Our Vision" },
|
|
72
|
+
titleTag: { type: String, default: "h2" },
|
|
73
|
+
badge: { type: String, default: "" },
|
|
74
|
+
content: { type: String, default: "" },
|
|
75
|
+
image: { type: String, default: "" },
|
|
76
|
+
imageAlt: { type: String, default: "" },
|
|
77
|
+
imageStyle: { type: String, default: "rounded" },
|
|
78
|
+
imageFit: { type: String, default: "cover" },
|
|
79
|
+
imageShadow: { type: Boolean, default: false },
|
|
80
|
+
reverse: { type: Boolean, default: false },
|
|
81
|
+
bgColor: { type: String, default: "#ffffff" },
|
|
82
|
+
textColor: { type: String, default: "#000000" },
|
|
83
|
+
ctas: { type: Array, default: () => [] },
|
|
84
|
+
spacing: { type: String, default: "normal" },
|
|
85
|
+
radius: { type: String, default: "rounded" }
|
|
86
|
+
});
|
|
87
|
+
const sectionPad = computed(() => ({ compact: "py-8 px-4 md:px-8", normal: "py-14 px-6 md:px-12", spacious: "py-24 px-8 md:px-16" })[props.spacing] || "py-14 px-6 md:px-12");
|
|
88
|
+
const cardRound = computed(() => ({ sharp: "rounded-xl", rounded: "rounded-3xl", pill: "rounded-[40px]" })[props.radius] || "rounded-3xl");
|
|
89
|
+
const imageContainerClass = computed(() => {
|
|
90
|
+
const r = cardRound.value;
|
|
91
|
+
const styles = { rounded: `aspect-[4/3] ${r}`, square: "aspect-square rounded-xl", circle: "aspect-square rounded-full max-w-[360px] mx-auto", portrait: `aspect-[3/4] ${r} max-w-sm mx-auto` };
|
|
92
|
+
return styles[props.imageStyle] ?? styles.rounded;
|
|
93
|
+
});
|
|
94
|
+
</script>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const useAdminBasePath: () => string;
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Default section definitions for the visual editor.
|
|
3
|
-
* Keys must match component names in the consuming app's components/sections/ directory.
|
|
4
|
-
* e.g. HeroSection → components/sections/HeroSection.vue → <SectionsHeroSection />
|
|
5
|
-
*
|
|
6
|
-
* Add your own sections via the `extraSections` module option in nuxt.config.ts.
|
|
7
|
-
*/
|
|
8
1
|
export declare const DEFAULT_SECTIONS: Record<string, any>;
|
|
9
2
|
export declare const useAdminSections: () => {
|
|
10
3
|
[x: string]: any;
|
|
@@ -23,6 +23,16 @@ const animationOptions = [
|
|
|
23
23
|
{ value: "typewriter", label: "Typewriter", icon: "\u258C" },
|
|
24
24
|
{ value: "float", label: "Float", icon: "\u301C" }
|
|
25
25
|
];
|
|
26
|
+
const spacingOptions = [
|
|
27
|
+
{ value: "compact", label: "Compact", icon: "\u2194" },
|
|
28
|
+
{ value: "normal", label: "Normal", icon: "\u27F7" },
|
|
29
|
+
{ value: "spacious", label: "Large", icon: "\u2195" }
|
|
30
|
+
];
|
|
31
|
+
const radiusOptions = [
|
|
32
|
+
{ value: "sharp", label: "Sharp", icon: "\u25A1" },
|
|
33
|
+
{ value: "rounded", label: "Rounded", icon: "\u25A2" },
|
|
34
|
+
{ value: "pill", label: "Pill", icon: "\u25EF" }
|
|
35
|
+
];
|
|
26
36
|
const i18n0 = (fr = "") => ({ fr, ar: "", en: "" });
|
|
27
37
|
export const DEFAULT_SECTIONS = {
|
|
28
38
|
HeroSection: {
|
|
@@ -42,7 +52,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
42
52
|
bgColor: "#3d35ff",
|
|
43
53
|
textColor: "#ffffff",
|
|
44
54
|
ctaText: i18n0("Get Started"),
|
|
45
|
-
ctaLink: "/"
|
|
55
|
+
ctaLink: "/",
|
|
56
|
+
spacing: "normal",
|
|
57
|
+
radius: "rounded"
|
|
46
58
|
},
|
|
47
59
|
groups: [
|
|
48
60
|
{ key: "design", label: "Design" },
|
|
@@ -71,6 +83,7 @@ export const DEFAULT_SECTIONS = {
|
|
|
71
83
|
ctaLink: { type: "text", label: "Button URL", group: "design" },
|
|
72
84
|
bgColor: { type: "color", label: "Background", group: "design" },
|
|
73
85
|
textColor: { type: "color", label: "Text Color", group: "design" },
|
|
86
|
+
radius: { type: "cards", label: "Arrondis", group: "design", options: radiusOptions },
|
|
74
87
|
animation: { type: "cards", label: "Animation", group: "animation", options: animationOptions }
|
|
75
88
|
}
|
|
76
89
|
},
|
|
@@ -91,7 +104,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
91
104
|
reverse: false,
|
|
92
105
|
bgColor: "#ffffff",
|
|
93
106
|
textColor: "#000000",
|
|
94
|
-
ctas: []
|
|
107
|
+
ctas: [],
|
|
108
|
+
spacing: "normal",
|
|
109
|
+
radius: "rounded"
|
|
95
110
|
},
|
|
96
111
|
fields: {
|
|
97
112
|
layout: { type: "cards", label: "Layout", options: [
|
|
@@ -121,7 +136,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
121
136
|
link: { type: "text", label: "URL" }
|
|
122
137
|
} },
|
|
123
138
|
bgColor: { type: "color", label: "Background" },
|
|
124
|
-
textColor: { type: "color", label: "Text Color" }
|
|
139
|
+
textColor: { type: "color", label: "Text Color" },
|
|
140
|
+
spacing: { type: "cards", label: "Espacement", options: spacingOptions },
|
|
141
|
+
radius: { type: "cards", label: "Arrondis", options: radiusOptions }
|
|
125
142
|
}
|
|
126
143
|
},
|
|
127
144
|
ServiceGrid: {
|
|
@@ -138,7 +155,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
138
155
|
itemTag: "h3",
|
|
139
156
|
items: [],
|
|
140
157
|
bgColor: "#ffffff",
|
|
141
|
-
textColor: "#000000"
|
|
158
|
+
textColor: "#000000",
|
|
159
|
+
spacing: "normal",
|
|
160
|
+
radius: "rounded"
|
|
142
161
|
},
|
|
143
162
|
fields: {
|
|
144
163
|
layout: { type: "cards", label: "Layout", options: [
|
|
@@ -161,7 +180,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
161
180
|
image: { type: "image", label: "Icon/Image" }
|
|
162
181
|
} },
|
|
163
182
|
bgColor: { type: "color", label: "Background" },
|
|
164
|
-
textColor: { type: "color", label: "Text Color" }
|
|
183
|
+
textColor: { type: "color", label: "Text Color" },
|
|
184
|
+
spacing: { type: "cards", label: "Espacement", options: spacingOptions },
|
|
185
|
+
radius: { type: "cards", label: "Arrondis", options: radiusOptions }
|
|
165
186
|
}
|
|
166
187
|
},
|
|
167
188
|
Features: {
|
|
@@ -171,7 +192,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
171
192
|
title: i18n0("Why choose us?"),
|
|
172
193
|
items: [],
|
|
173
194
|
bgColor: "#ffffff",
|
|
174
|
-
textColor: "#000000"
|
|
195
|
+
textColor: "#000000",
|
|
196
|
+
spacing: "normal",
|
|
197
|
+
radius: "rounded"
|
|
175
198
|
},
|
|
176
199
|
fields: {
|
|
177
200
|
title: { type: "text", label: "Section Title", i18n: true },
|
|
@@ -180,7 +203,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
180
203
|
description: { type: "textarea", label: "Description", i18n: true }
|
|
181
204
|
} },
|
|
182
205
|
bgColor: { type: "color", label: "Background" },
|
|
183
|
-
textColor: { type: "color", label: "Text Color" }
|
|
206
|
+
textColor: { type: "color", label: "Text Color" },
|
|
207
|
+
spacing: { type: "cards", label: "Espacement", options: spacingOptions },
|
|
208
|
+
radius: { type: "cards", label: "Arrondis", options: radiusOptions }
|
|
184
209
|
}
|
|
185
210
|
},
|
|
186
211
|
FAQ: {
|
|
@@ -190,14 +215,18 @@ export const DEFAULT_SECTIONS = {
|
|
|
190
215
|
title: i18n0("FAQ"),
|
|
191
216
|
items: [],
|
|
192
217
|
bgColor: "#F9F9FB",
|
|
193
|
-
textColor: "#000000"
|
|
218
|
+
textColor: "#000000",
|
|
219
|
+
spacing: "normal",
|
|
220
|
+
radius: "rounded"
|
|
194
221
|
},
|
|
195
222
|
fields: {
|
|
196
223
|
title: { type: "text", label: "Title", i18n: true },
|
|
197
224
|
items: { type: "list", label: "Questions", itemFields: {
|
|
198
225
|
question: { type: "text", label: "Question", i18n: true },
|
|
199
226
|
answer: { type: "textarea", label: "Answer", i18n: true }
|
|
200
|
-
} }
|
|
227
|
+
} },
|
|
228
|
+
spacing: { type: "cards", label: "Espacement", options: spacingOptions },
|
|
229
|
+
radius: { type: "cards", label: "Arrondis", options: radiusOptions }
|
|
201
230
|
}
|
|
202
231
|
},
|
|
203
232
|
Process: {
|
|
@@ -211,7 +240,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
211
240
|
description: i18n0(),
|
|
212
241
|
items: [],
|
|
213
242
|
bgColor: "#ffffff",
|
|
214
|
-
textColor: "#000000"
|
|
243
|
+
textColor: "#000000",
|
|
244
|
+
spacing: "normal",
|
|
245
|
+
radius: "rounded"
|
|
215
246
|
},
|
|
216
247
|
fields: {
|
|
217
248
|
layout: { type: "cards", label: "Layout", options: [
|
|
@@ -231,7 +262,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
231
262
|
description: { type: "textarea", label: "Description", i18n: true }
|
|
232
263
|
} },
|
|
233
264
|
bgColor: { type: "color", label: "Background" },
|
|
234
|
-
textColor: { type: "color", label: "Text Color" }
|
|
265
|
+
textColor: { type: "color", label: "Text Color" },
|
|
266
|
+
spacing: { type: "cards", label: "Espacement", options: spacingOptions },
|
|
267
|
+
radius: { type: "cards", label: "Arrondis", options: radiusOptions }
|
|
235
268
|
}
|
|
236
269
|
},
|
|
237
270
|
Testimonials: {
|
|
@@ -245,7 +278,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
245
278
|
description: i18n0(),
|
|
246
279
|
items: [],
|
|
247
280
|
bgColor: "#ffffff",
|
|
248
|
-
textColor: "#000000"
|
|
281
|
+
textColor: "#000000",
|
|
282
|
+
spacing: "normal",
|
|
283
|
+
radius: "rounded"
|
|
249
284
|
},
|
|
250
285
|
fields: {
|
|
251
286
|
layout: { type: "cards", label: "Layout", options: [
|
|
@@ -267,7 +302,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
267
302
|
avatar: { type: "image", label: "Photo" }
|
|
268
303
|
} },
|
|
269
304
|
bgColor: { type: "color", label: "Background" },
|
|
270
|
-
textColor: { type: "color", label: "Text Color" }
|
|
305
|
+
textColor: { type: "color", label: "Text Color" },
|
|
306
|
+
spacing: { type: "cards", label: "Espacement", options: spacingOptions },
|
|
307
|
+
radius: { type: "cards", label: "Arrondis", options: radiusOptions }
|
|
271
308
|
}
|
|
272
309
|
},
|
|
273
310
|
Pricing: {
|
|
@@ -281,7 +318,9 @@ export const DEFAULT_SECTIONS = {
|
|
|
281
318
|
description: i18n0(),
|
|
282
319
|
items: [],
|
|
283
320
|
bgColor: "#ffffff",
|
|
284
|
-
textColor: "#000000"
|
|
321
|
+
textColor: "#000000",
|
|
322
|
+
spacing: "normal",
|
|
323
|
+
radius: "rounded"
|
|
285
324
|
},
|
|
286
325
|
fields: {
|
|
287
326
|
layout: { type: "cards", label: "Layout", options: [
|
|
@@ -303,27 +342,39 @@ export const DEFAULT_SECTIONS = {
|
|
|
303
342
|
highlighted: { type: "select", label: "Highlight", options: [{ value: false, label: "No" }, { value: true, label: "Yes" }] }
|
|
304
343
|
} },
|
|
305
344
|
bgColor: { type: "color", label: "Background" },
|
|
306
|
-
textColor: { type: "color", label: "Text Color" }
|
|
345
|
+
textColor: { type: "color", label: "Text Color" },
|
|
346
|
+
spacing: { type: "cards", label: "Espacement", options: spacingOptions },
|
|
347
|
+
radius: { type: "cards", label: "Arrondis", options: radiusOptions }
|
|
307
348
|
}
|
|
308
349
|
},
|
|
309
350
|
ContactForm: {
|
|
310
351
|
label: "Contact Form",
|
|
311
352
|
icon: "\u{1F4E9}",
|
|
312
353
|
defaultProps: {
|
|
354
|
+
layout: "split",
|
|
313
355
|
title: i18n0("Let's talk"),
|
|
314
356
|
subtitle: i18n0("Describe your project and we'll get back to you within 24h."),
|
|
315
357
|
badge: i18n0(),
|
|
316
358
|
button_text: i18n0("Send"),
|
|
317
359
|
bgColor: "#ffffff",
|
|
318
|
-
textColor: "#000000"
|
|
360
|
+
textColor: "#000000",
|
|
361
|
+
spacing: "normal",
|
|
362
|
+
radius: "rounded"
|
|
319
363
|
},
|
|
320
364
|
fields: {
|
|
365
|
+
layout: { type: "cards", label: "Layout", options: [
|
|
366
|
+
{ value: "split", label: "Split", icon: "\u25E7" },
|
|
367
|
+
{ value: "centered", label: "Centered", icon: "\u25C9" },
|
|
368
|
+
{ value: "minimal", label: "Minimal", icon: "\u2014" }
|
|
369
|
+
] },
|
|
321
370
|
badge: { type: "text", label: "Badge", i18n: true },
|
|
322
371
|
title: { type: "text", label: "Title", i18n: true },
|
|
323
372
|
subtitle: { type: "text", label: "Subtitle", i18n: true },
|
|
324
373
|
button_text: { type: "text", label: "Button", i18n: true },
|
|
325
374
|
bgColor: { type: "color", label: "Background" },
|
|
326
|
-
textColor: { type: "color", label: "Text Color" }
|
|
375
|
+
textColor: { type: "color", label: "Text Color" },
|
|
376
|
+
spacing: { type: "cards", label: "Espacement", options: spacingOptions },
|
|
377
|
+
radius: { type: "cards", label: "Arrondis", options: radiusOptions }
|
|
327
378
|
}
|
|
328
379
|
}
|
|
329
380
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface SectionPreset {
|
|
2
|
+
layout?: string;
|
|
3
|
+
spacing?: string;
|
|
4
|
+
radius?: string;
|
|
5
|
+
animation?: string;
|
|
6
|
+
bgColor?: string;
|
|
7
|
+
textColor?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BrandSettings {
|
|
10
|
+
id: number;
|
|
11
|
+
brand_name?: string;
|
|
12
|
+
tagline?: string;
|
|
13
|
+
vision?: string;
|
|
14
|
+
logo_url?: string;
|
|
15
|
+
primary_color?: string;
|
|
16
|
+
secondary_color?: string;
|
|
17
|
+
accent_color?: string;
|
|
18
|
+
font_headline?: string;
|
|
19
|
+
font_body?: string;
|
|
20
|
+
ai_context?: string;
|
|
21
|
+
section_presets?: Record<string, SectionPreset>;
|
|
22
|
+
updated_at?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const useAdminSettings: () => {
|
|
25
|
+
settings: any;
|
|
26
|
+
loading: any;
|
|
27
|
+
saving: any;
|
|
28
|
+
load: () => Promise<void>;
|
|
29
|
+
save: (updates: Partial<BrandSettings>) => Promise<void>;
|
|
30
|
+
applyPresets: (sectionType: string, defaultProps: Record<string, any>) => Record<string, any>;
|
|
31
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ref } from "#imports";
|
|
2
|
+
import { useSupabaseClient } from "#imports";
|
|
3
|
+
export const useAdminSettings = () => {
|
|
4
|
+
const supabase = useSupabaseClient();
|
|
5
|
+
const settings = ref(null);
|
|
6
|
+
const loading = ref(false);
|
|
7
|
+
const saving = ref(false);
|
|
8
|
+
const load = async () => {
|
|
9
|
+
loading.value = true;
|
|
10
|
+
const { data } = await supabase.from("brand_settings").select("*").eq("id", 1).maybeSingle();
|
|
11
|
+
settings.value = data || { id: 1 };
|
|
12
|
+
loading.value = false;
|
|
13
|
+
};
|
|
14
|
+
const save = async (updates) => {
|
|
15
|
+
saving.value = true;
|
|
16
|
+
await supabase.from("brand_settings").upsert({ id: 1, ...updates });
|
|
17
|
+
settings.value = { ...settings.value || { id: 1 }, ...updates };
|
|
18
|
+
saving.value = false;
|
|
19
|
+
};
|
|
20
|
+
const applyPresets = (sectionType, defaultProps) => {
|
|
21
|
+
const presets = settings.value?.section_presets?.[sectionType];
|
|
22
|
+
if (!presets) return defaultProps;
|
|
23
|
+
const merged = { ...defaultProps };
|
|
24
|
+
const styleKeys = ["layout", "spacing", "radius", "animation", "bgColor", "textColor"];
|
|
25
|
+
for (const key of styleKeys) {
|
|
26
|
+
if (presets[key] !== void 0) merged[key] = presets[key];
|
|
27
|
+
}
|
|
28
|
+
return merged;
|
|
29
|
+
};
|
|
30
|
+
return { settings, loading, saving, load, save, applyPresets };
|
|
31
|
+
};
|
|
@@ -4,10 +4,11 @@ export default defineNuxtRouteMiddleware((to) => {
|
|
|
4
4
|
const config = useRuntimeConfig().public.adminCms;
|
|
5
5
|
const user = useSupabaseUser();
|
|
6
6
|
const loginRoute = config?.loginRoute || "/login";
|
|
7
|
-
|
|
7
|
+
const basePath = config?.basePath || "/admin";
|
|
8
|
+
if (to.path.startsWith(basePath) && !user.value) {
|
|
8
9
|
return navigateTo(loginRoute);
|
|
9
10
|
}
|
|
10
11
|
if (to.path === loginRoute && user.value) {
|
|
11
|
-
return navigateTo(
|
|
12
|
+
return navigateTo(basePath);
|
|
12
13
|
}
|
|
13
14
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed, watch, definePageMeta, useRoute, useRuntimeConfig, useAsyncData, useHead, useSupabaseClient, useNuxtApp } from "#imports";
|
|
2
|
+
import { ref, computed, watch, onMounted, definePageMeta, useRoute, useRuntimeConfig, useAsyncData, useHead, useSupabaseClient, useNuxtApp } from "#imports";
|
|
3
3
|
import { useAdminSections } from "../../../composables/useAdminSections";
|
|
4
|
+
import { useAdminSettings, useAdminBasePath } from "#imports";
|
|
4
5
|
definePageMeta({ layout: false });
|
|
5
6
|
const route = useRoute();
|
|
6
7
|
const supabase = useSupabaseClient();
|
|
@@ -10,6 +11,9 @@ const bucket = config?.storageBucket || "site";
|
|
|
10
11
|
const pageId = route.params.id;
|
|
11
12
|
const CMS_COMPONENTS = useAdminSections();
|
|
12
13
|
const { vueApp } = useNuxtApp();
|
|
14
|
+
const adminSettings = useAdminSettings();
|
|
15
|
+
const basePath = useAdminBasePath();
|
|
16
|
+
onMounted(() => adminSettings.load());
|
|
13
17
|
const resolveSection = (type) => vueApp.component(`Sections${type}`) || vueApp.component(type) || null;
|
|
14
18
|
const { data: page } = await useAsyncData(`admin-edit-${pageId}`, async () => {
|
|
15
19
|
const { data } = await supabase.from(table).select("*").eq("id", pageId).maybeSingle();
|
|
@@ -180,7 +184,8 @@ const generateWithAI = async () => {
|
|
|
180
184
|
fields,
|
|
181
185
|
currentProps,
|
|
182
186
|
prompt: aiPromptText.value,
|
|
183
|
-
itemFieldsConfig
|
|
187
|
+
itemFieldsConfig,
|
|
188
|
+
aiContext: adminSettings.settings.value?.ai_context || ""
|
|
184
189
|
}
|
|
185
190
|
});
|
|
186
191
|
const props = page.value.content[selectedBlockIndex.value].props;
|
|
@@ -229,7 +234,7 @@ const runSeoAudit = async () => {
|
|
|
229
234
|
try {
|
|
230
235
|
const result = await $fetch("/api/admin-cms/ai/seo-audit", {
|
|
231
236
|
method: "POST",
|
|
232
|
-
body: { pageTitle: page.value.title, pageType: page.value.type, content: page.value.content, seoConfig: page.value.seo_config }
|
|
237
|
+
body: { pageTitle: page.value.title, pageType: page.value.type, content: page.value.content, seoConfig: page.value.seo_config, aiContext: adminSettings.settings.value?.ai_context || "" }
|
|
233
238
|
});
|
|
234
239
|
seoAudit.value = result;
|
|
235
240
|
} catch (err) {
|
|
@@ -397,7 +402,7 @@ const savePage = async () => {
|
|
|
397
402
|
<!-- ── Top Bar ── -->
|
|
398
403
|
<header class="h-12 bg-white/80 backdrop-blur-md border-b px-6 flex items-center justify-between z-[60] shrink-0">
|
|
399
404
|
<div class="flex items-center gap-4">
|
|
400
|
-
<NuxtLink to="
|
|
405
|
+
<NuxtLink :to="basePath" class="text-gray-400 hover:text-black transition-colors">←</NuxtLink>
|
|
401
406
|
<div class="flex gap-1 bg-gray-100 p-1 rounded-md">
|
|
402
407
|
<button @click="isLeftSidebarOpen = !isLeftSidebarOpen" :class="['p-1.5 rounded transition-all', isLeftSidebarOpen ? 'bg-white shadow-sm text-[#3d35ff]' : 'text-gray-400']" title="Structure">
|
|
403
408
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 6h16M4 12h10M4 18h16"/></svg>
|
|
@@ -877,7 +882,7 @@ const savePage = async () => {
|
|
|
877
882
|
<h2 class="text-[12px] font-black uppercase text-center mb-12 tracking-[0.4em] italic text-[#3d35ff]">Nouvelle Section</h2>
|
|
878
883
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
|
879
884
|
<button v-for="(conf, type) in CMS_COMPONENTS" :key="type"
|
|
880
|
-
@click="page.content.push({ type, props: JSON.parse(JSON.stringify(conf.defaultProps)) });
|
|
885
|
+
@click="page.content.push({ type, props: JSON.parse(JSON.stringify(adminSettings.applyPresets(type, conf.defaultProps))) });
|
|
881
886
|
showAddModal = false;
|
|
882
887
|
selectedBlockIndex = page.content.length - 1"
|
|
883
888
|
class="flex flex-col items-center gap-6 p-8 border-2 border-gray-50 rounded-[40px] hover:border-[#3d35ff] transition-all group active:scale-95">
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, useHead, useRuntimeConfig, useSupabaseClient } from "#imports";
|
|
3
|
+
import { useAdminBasePath } from "#imports";
|
|
3
4
|
useHead({ title: "Tableau de bord \u2014 Admin" });
|
|
4
5
|
const config = useRuntimeConfig().public.adminCms;
|
|
5
6
|
const supabase = useSupabaseClient();
|
|
6
7
|
const tables = computed(() => config?.tables || { pages: "pages" });
|
|
8
|
+
const basePath = useAdminBasePath();
|
|
7
9
|
const pageTypes = computed(() => {
|
|
8
10
|
const types = config?.pageTypes || [];
|
|
9
11
|
return types.map((pt) => ({
|
|
@@ -74,7 +76,7 @@ const { data: stats } = await useAsyncData("admin-dashboard-stats", async () =>
|
|
|
74
76
|
<NuxtLink
|
|
75
77
|
v-for="type in pageTypes"
|
|
76
78
|
:key="type.id"
|
|
77
|
-
:to="
|
|
79
|
+
:to="`${basePath}/pages/${type.id}`"
|
|
78
80
|
class="group bg-white rounded-2xl p-6 border border-gray-100 shadow-sm hover:border-[#3d35ff]/30 hover:shadow-md transition-all"
|
|
79
81
|
>
|
|
80
82
|
<div class="flex items-start justify-between mb-4">
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref, computed, watch, useRoute, useRuntimeConfig, useAsyncData, useHead, navigateTo, useSupabaseClient } from "#imports";
|
|
3
|
+
import { useAdminBasePath } from "#imports";
|
|
3
4
|
const route = useRoute();
|
|
4
5
|
const supabase = useSupabaseClient();
|
|
5
6
|
const config = useRuntimeConfig().public.adminCms;
|
|
7
|
+
const basePath = useAdminBasePath();
|
|
6
8
|
const pageType = route.params.type;
|
|
7
9
|
const table = config?.tables?.pages || "pages";
|
|
8
10
|
const typeLabel = computed(
|
|
@@ -46,7 +48,7 @@ const handleCreate = async () => {
|
|
|
46
48
|
}
|
|
47
49
|
const { data, error } = await supabase.from(table).insert([{ title: newPageTitle.value, slug, type: pageType, status: "draft", content }]).select();
|
|
48
50
|
if (!error && data) {
|
|
49
|
-
navigateTo(
|
|
51
|
+
navigateTo(`${basePath}/editor/${data[0].id}`);
|
|
50
52
|
} else {
|
|
51
53
|
isCreating.value = false;
|
|
52
54
|
showToast("Ce slug est d\xE9j\xE0 utilis\xE9. Changez le titre.", "error");
|
|
@@ -106,7 +108,7 @@ useHead({ title: computed(() => `${typeLabel.value} \u2014 Admin`) });
|
|
|
106
108
|
<div class="mb-8 flex items-center justify-between">
|
|
107
109
|
<div>
|
|
108
110
|
<div class="flex items-center gap-2 text-[11px] text-gray-400 mb-2">
|
|
109
|
-
<NuxtLink to="
|
|
111
|
+
<NuxtLink :to="basePath" class="hover:text-gray-600 transition-colors">Tableau de bord</NuxtLink>
|
|
110
112
|
<span>/</span>
|
|
111
113
|
<span class="text-gray-600 font-medium">{{ typeLabel }}</span>
|
|
112
114
|
</div>
|
|
@@ -164,7 +166,7 @@ useHead({ title: computed(() => `${typeLabel.value} \u2014 Admin`) });
|
|
|
164
166
|
</td>
|
|
165
167
|
<td class="px-6 py-4">
|
|
166
168
|
<div class="flex items-center justify-end gap-1">
|
|
167
|
-
<NuxtLink :to="
|
|
169
|
+
<NuxtLink :to="`${basePath}/editor/${page.id}`"
|
|
168
170
|
class="px-3 py-1.5 rounded-lg text-[11px] font-bold bg-gray-100 text-gray-600 hover:bg-[#3d35ff] hover:text-white transition-all">
|
|
169
171
|
Éditer
|
|
170
172
|
</NuxtLink>
|