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.
Files changed (54) hide show
  1. package/README.md +554 -113
  2. package/dist/module.d.mts +6 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +14 -6
  5. package/dist/runtime/assets/admin-tw.css +1 -1
  6. package/dist/runtime/components/admin/Navbar.vue +6 -5
  7. package/dist/runtime/components/admin/Sidebar.vue +21 -8
  8. package/dist/runtime/components/sections/ContactForm.d.vue.ts +10 -0
  9. package/dist/runtime/components/sections/ContactForm.vue +138 -0
  10. package/dist/runtime/components/sections/ContactForm.vue.d.ts +10 -0
  11. package/dist/runtime/components/sections/FAQ.d.vue.ts +3 -0
  12. package/dist/runtime/components/sections/FAQ.vue +46 -0
  13. package/dist/runtime/components/sections/FAQ.vue.d.ts +3 -0
  14. package/dist/runtime/components/sections/Features.d.vue.ts +3 -0
  15. package/dist/runtime/components/sections/Features.vue +41 -0
  16. package/dist/runtime/components/sections/Features.vue.d.ts +3 -0
  17. package/dist/runtime/components/sections/HeroSection.d.vue.ts +3 -0
  18. package/dist/runtime/components/sections/HeroSection.vue +133 -0
  19. package/dist/runtime/components/sections/HeroSection.vue.d.ts +3 -0
  20. package/dist/runtime/components/sections/Pricing.d.vue.ts +3 -0
  21. package/dist/runtime/components/sections/Pricing.vue +108 -0
  22. package/dist/runtime/components/sections/Pricing.vue.d.ts +3 -0
  23. package/dist/runtime/components/sections/Process.d.vue.ts +3 -0
  24. package/dist/runtime/components/sections/Process.vue +95 -0
  25. package/dist/runtime/components/sections/Process.vue.d.ts +3 -0
  26. package/dist/runtime/components/sections/ServiceGrid.d.vue.ts +3 -0
  27. package/dist/runtime/components/sections/ServiceGrid.vue +97 -0
  28. package/dist/runtime/components/sections/ServiceGrid.vue.d.ts +3 -0
  29. package/dist/runtime/components/sections/Testimonials.d.vue.ts +3 -0
  30. package/dist/runtime/components/sections/Testimonials.vue +95 -0
  31. package/dist/runtime/components/sections/Testimonials.vue.d.ts +3 -0
  32. package/dist/runtime/components/sections/TextVisual.d.vue.ts +3 -0
  33. package/dist/runtime/components/sections/TextVisual.vue +94 -0
  34. package/dist/runtime/components/sections/TextVisual.vue.d.ts +3 -0
  35. package/dist/runtime/composables/useAdminBasePath.d.ts +1 -0
  36. package/dist/runtime/composables/useAdminBasePath.js +5 -0
  37. package/dist/runtime/composables/useAdminSections.d.ts +0 -7
  38. package/dist/runtime/composables/useAdminSections.js +68 -17
  39. package/dist/runtime/composables/useAdminSettings.d.ts +31 -0
  40. package/dist/runtime/composables/useAdminSettings.js +31 -0
  41. package/dist/runtime/middleware/admin-auth.js +3 -2
  42. package/dist/runtime/pages/admin/editor/[id].vue +10 -5
  43. package/dist/runtime/pages/admin/index.vue +3 -1
  44. package/dist/runtime/pages/admin/pages/[type].vue +5 -3
  45. package/dist/runtime/pages/admin/personas/[id].vue +3 -2
  46. package/dist/runtime/pages/admin/personas/index.vue +3 -2
  47. package/dist/runtime/pages/admin/setup.d.vue.ts +3 -0
  48. package/dist/runtime/pages/admin/setup.vue +220 -0
  49. package/dist/runtime/pages/admin/setup.vue.d.ts +3 -0
  50. package/dist/runtime/pages/admin/strategies/[id].vue +3 -2
  51. package/dist/runtime/pages/admin/strategies/index.vue +3 -2
  52. package/dist/runtime/server/api/ai/generate.post.js +8 -3
  53. package/dist/runtime/server/api/ai/seo-audit.post.js +8 -3
  54. 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,3 @@
1
+ declare const _default: typeof __VLS_export;
2
+ export default _default;
3
+ declare const __VLS_export: any;
@@ -0,0 +1,3 @@
1
+ declare const _default: typeof __VLS_export;
2
+ export default _default;
3
+ declare const __VLS_export: any;
@@ -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,3 @@
1
+ declare const _default: typeof __VLS_export;
2
+ export default _default;
3
+ declare const __VLS_export: any;
@@ -0,0 +1 @@
1
+ export declare const useAdminBasePath: () => string;
@@ -0,0 +1,5 @@
1
+ import { useRuntimeConfig } from "#imports";
2
+ export const useAdminBasePath = () => {
3
+ const config = useRuntimeConfig().public.adminCms;
4
+ return config?.basePath || "/admin";
5
+ };
@@ -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
- if (to.path.startsWith("/admin") && !user.value) {
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("/admin");
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="/admin" class="text-gray-400 hover:text-black transition-colors">←</NuxtLink>
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="`/admin/pages/${type.id}`"
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(`/admin/editor/${data[0].id}`);
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="/admin" class="hover:text-gray-600 transition-colors">Tableau de bord</NuxtLink>
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="`/admin/editor/${page.id}`"
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>