# cinqcinqdev-seo

A Nuxt 3 admin CMS module with a visual page editor, AI content generation, SEO tooling, and multi-theme section components — powered by Supabase.

---

## Your project checklist

Everything you need to do **in the app that installs this package**.

### 1 — Install

```bash
npm install cinqcinqdev-seo
```

### 2 — nuxt.config

```js
export default defineNuxtConfig({
  modules: [
    '@nuxtjs/supabase',
    '@pinia/nuxt',
    '@nuxt/icon',
    'cinqcinqdev-seo',
  ],

  adminCms: {
    loginRoute: '/login',           // where unauthenticated users are sent
    branding: { name: 'My App' },

    // Languages shown in the editor i18n switcher and quick-edit modal
    // Default: ['fr', 'ar', 'en']
    langs: ['fr', 'en'],

    // Enable AI content generation and SEO audit
    // Requires OPENROUTER_API_KEY in .env
    features: { ai: true },
  },
})
```

### 3 — Environment variables

```env
# Required for the built-in sitemap (/api/admin-cms/sitemap.xml)
NUXT_PUBLIC_SITE_URL=https://www.yoursite.com

# Required only if features.ai is true
OPENROUTER_API_KEY=sk-or-...
```

### 4 — Supabase database

Run this SQL once in your Supabase SQL Editor to create all required tables:

```sql
-- Pages
CREATE TABLE IF NOT EXISTS public.pages (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  created_at timestamptz DEFAULT now(),
  updated_at timestamptz DEFAULT now(),
  title text NOT NULL,
  slug text NOT NULL UNIQUE,
  type text NOT NULL DEFAULT 'landing_page',
  seo_config jsonb DEFAULT '{
    "meta_title":       {"fr": "", "ar": "", "en": ""},
    "meta_description": {"fr": "", "ar": "", "en": ""},
    "og_image": "",
    "no_index": false
  }'::jsonb,
  content jsonb DEFAULT '[]'::jsonb,
  status text CHECK (status IN ('draft','published','archived')) DEFAULT 'draft',
  content_locked boolean DEFAULT false,
  published_at timestamptz,
  page_css text DEFAULT '',
  page_js text DEFAULT ''
);

ALTER TABLE public.pages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public_read_published" ON public.pages FOR SELECT USING (status = 'published');
CREATE POLICY "auth_full_access" ON public.pages FOR ALL TO authenticated USING (true) WITH CHECK (true);

CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ language 'plpgsql';
CREATE TRIGGER update_pages_modtime BEFORE UPDATE ON public.pages
  FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();

-- Brand settings (theme, colors, fonts, AI context)
CREATE TABLE IF NOT EXISTS public.brand_settings (
  id INT PRIMARY KEY DEFAULT 1,
  brand_name TEXT DEFAULT '',
  primary_color TEXT DEFAULT '#000000',
  secondary_color TEXT DEFAULT '#ffffff',
  accent_color TEXT DEFAULT '#F0F0F3',
  font_headline TEXT DEFAULT 'Inter',
  font_body TEXT DEFAULT 'Inter',
  ai_context TEXT DEFAULT '',
  section_presets JSONB DEFAULT '{}'::jsonb,
  theme TEXT CHECK (theme IN ('cinqcinq', 'swiss-style')),
  shortcut_colors JSONB DEFAULT '[]'::jsonb,
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT one_row_only CHECK (id = 1)
);
INSERT INTO public.brand_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;

-- Users (plan management)
CREATE TABLE IF NOT EXISTS public.users (
  id uuid PRIMARY KEY REFERENCES auth.users(id),
  plan text DEFAULT 'free',
  is_subscription_active boolean DEFAULT false,
  trial_expires_at timestamptz,
  created_at timestamptz DEFAULT now()
);

-- Page templates
CREATE TABLE IF NOT EXISTS public.pages_templates (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  created_at timestamptz DEFAULT now(),
  name text NOT NULL,
  content jsonb DEFAULT '[]'::jsonb,
  source_page_id uuid REFERENCES public.pages(id) ON DELETE SET NULL
);
ALTER TABLE public.pages_templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY "auth_full_access" ON public.pages_templates FOR ALL TO authenticated USING (true) WITH CHECK (true);
```

**If you already have a `pages` table and are upgrading**, run only the missing columns:

```sql
ALTER TABLE public.pages ADD COLUMN IF NOT EXISTS published_at timestamptz;
ALTER TABLE public.pages ADD COLUMN IF NOT EXISTS page_css text DEFAULT '';
ALTER TABLE public.pages ADD COLUMN IF NOT EXISTS page_js text DEFAULT '';
ALTER TABLE public.brand_settings ADD COLUMN IF NOT EXISTS theme TEXT CHECK (theme IN ('cinqcinq', 'swiss-style'));
ALTER TABLE public.brand_settings ADD COLUMN IF NOT EXISTS shortcut_colors JSONB DEFAULT '[]'::jsonb;
```

### 5 — Supabase Storage bucket

Create a bucket named `site` (or change `storageBucket` in your config):

```sql
-- In the Supabase SQL editor
CREATE POLICY "Public read" ON storage.objects FOR SELECT USING (bucket_id = 'site');
CREATE POLICY "Authenticated upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'site');
CREATE POLICY "Authenticated delete" ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'site');
```

### 6 — Frontend page renderer (`pages/[...slug].vue`)

This file renders every CMS page on the public site. It must be a **catch-all** route (three dots) so subdirectory slugs like `/faq/pricing` work.

```vue
<!-- pages/[...slug].vue -->
<script setup>
const route = useRoute()
const supabase = useSupabaseClient()
const { settings, load } = useAdminSettings()

// Join path segments: ['faq', 'pricing'] → 'faq/pricing'
const slug = (route.params.slug).join('/')

const [{ data: page }] = await Promise.all([
  useAsyncData(`page-${slug}`, async () => {
    const { data } = await supabase
      .from('pages')
      .select('*')
      .eq('slug', slug)
      .eq('status', 'published')
      .single()
    return data
  }),
  load(),
])

if (!page.value) throw createError({ statusCode: 404, statusMessage: 'Page not found' })

// Inject page-specific CSS generated by the AI or written manually in the editor
if (page.value.page_css) {
  useHead({ style: [{ innerHTML: page.value.page_css, id: `page-css-${slug}` }] })
}

// Inject page-specific JS (optional — only if you want JS to run on the public site)
if (page.value.page_js) {
  useHead({ script: [{ innerHTML: page.value.page_js, id: `page-js-${slug}` }] })
}

useSeoMeta({
  title:       page.value.seo_config?.meta_title?.fr       || page.value.title,
  description: page.value.seo_config?.meta_description?.fr || '',
  ogImage:     page.value.seo_config?.og_image             || '',
})
</script>

<template>
  <AdminCmsDynamicRenderer
    :content="page.content"
    :selected-id="null"
    :theme="settings?.theme"
    :palette="settings?.shortcut_colors"
  />
</template>
```

> **Multilingual:** swap `?.fr` for the active locale from `useI18n()` to serve the correct language.
> **Scheduled publishing:** add `.lte('published_at', new Date().toISOString())` to the Supabase query to hide future-dated pages.

### 6b — Rendering rich text (HTML) in your section components

The editor stores text field values as **HTML** when the user applies formatting (bold, italic, colors, font sizes). To render this correctly on the public site, your custom section components must use **`v-html`** instead of mustache `{{ }}` for text props.

**Plain text (no formatting):**
```vue
<h1>{{ title }}</h1>
```

**Rich text (HTML — supports bold, italic, colors, etc.):**
```vue
<h1 v-html="title" />
<!-- or -->
<p v-html="subtitle" />
```

**If the prop may contain HTML or plain text**, always use `v-html`:
```vue
<!-- Title with dynamic semantic tag and HTML rendering -->
<component :is="titleTag" v-html="title" class="..." />

<!-- Subtitle -->
<p v-if="subtitle" v-html="subtitle" class="..." />

<!-- List items (bullets, features) -->
<li v-for="(item, i) in parsedBullets" :key="i" v-html="item" />
```

> **Security note:** `v-html` renders raw HTML. Since prop values come from your own CMS editor (not from public user input), this is safe. Never use `v-html` with untrusted external data.

**The built-in `cinqcinq` and `swiss-style` section components** already support plain text props via `{{ }}`. To enable HTML formatting in those components, override them in your `components/sections/` directory using the same component name, and use `v-html` there.

### 7 — Setup page

Go to `/admin/setup` (or your configured `basePath`) and fill in:

- **Theme** — choose `cinqcinq` or `swiss-style`
- **AI context** — describe your website (industry, tone, services). Every AI call uses this as its system context.
- **Primary / Secondary / Accent color** — used by the AI CSS/JS generator to keep generated code on-brand
- **Font Headline / Font Body** — same — the AI uses these when generating CSS
- **Shortcut colors** — your quick-pick palette in every color picker

### 8 — Editor preview shell (navbar & footer)

By default the editor preview shows only the page sections. To make the preview look **exactly like the real frontend** — with your site's navbar on top and footer at the bottom — add `previewShell` to your config:

```js
// nuxt.config.ts
adminCms: {
  previewShell: {
    navbar: 'AppNavbar',   // the Vue component name Nuxt auto-imports your navbar as
    footer: 'AppFooter',   // the Vue component name Nuxt auto-imports your footer as
  },
},
```

**Requirements:**
- The components must be globally available — i.e. placed inside `~/components/` so Nuxt auto-imports them.
- Use the exact name Nuxt resolves them as (usually the filename in PascalCase, e.g. `components/AppNavbar.vue` → `'AppNavbar'`).
- Both keys are optional — omit `navbar` to show only the footer, or omit `footer` to show only the navbar.
- The components receive **no props** — they render exactly as they would at the top/bottom of a real page. If your navbar or footer relies on a store or composable for its data, make sure that store is initialized in your app's `app.vue` or a plugin.

**Example:**

| Your component file | `previewShell` name |
|---|---|
| `components/AppNavbar.vue` | `'AppNavbar'` |
| `components/layout/SiteFooter.vue` | `'LayoutSiteFooter'` |
| `components/TheHeader.vue` | `'TheHeader'` |

---

## What's New in v1.0.71

### AI CSS and JS generation

A new AI panel appears in the **SEO tab** of the visual editor, above the Custom CSS and Custom JS editors.

**How to use:**
1. Type a description of the design or behavior you want — e.g.:
   - *"Animated particles background using the primary color of the site"*
   - *"Sticky header that shrinks and adds a shadow when the user scrolls down"*
   - *"Smooth horizontal scroll section with overflow-x auto"*
2. Click one of three buttons:
   - **`</> CSS`** — generates CSS only
   - **`⚡ JS`** — generates JavaScript only
   - **`CSS + JS`** — generates both at once
3. The generated code is written directly into the respective editor. Review it, tweak it if needed, then save.

**Brand-aware:** the AI automatically receives your site's primary color, secondary color, accent color, headline font, and body font from `/admin/setup`. Generated code uses these values — not arbitrary colors.

**Merge, not replace:** if there is already code in the editor, the AI adds to it rather than overwriting it (unless you explicitly ask it to replace everything).

**Requires:** `features.ai: true` in your config and `OPENROUTER_API_KEY` in your `.env`.

---

## What's New in v1.0.65–v1.0.70

### 8 new section types — both themes

The section library grows from 20 to **28 built-in types**. All 8 new sections ship in both `cinqcinq` and `swiss-style`:

| Type | Description |
|---|---|
| `Stats` | Animated counter grid — numbers count up when the section enters the viewport |
| `Timeline` | Vertical timeline with `vertical` (date column + dot line) and `alternating` layouts |
| `Tabs` | Content tabs with `horizontal` (border-bottom bar) and `vertical` (filled sidebar) layouts |
| `VideoBlock` | YouTube, Vimeo, or direct video file — embed auto-detected from URL |
| `ImageGallery` | Responsive image gallery with `grid` (1px gap) and `masonry` (CSS columns) layouts + lightbox |
| `Spacer` | Configurable vertical whitespace with an optional divider line |
| `Embed` | Generic iframe embed (maps, calendars, third-party widgets, etc.) |
| `CustomHtml` | Raw HTML block — inject any markup directly into the page |

**Editor section picker** — sections are now organised into four groups:

| Group | Sections |
|---|---|
| Content | Hero types, Text/Visual, Services, Features, Process, Testimonials, FAQ, Stats, Timeline, Tabs, RichText, TableOfContents |
| Media | VideoBlock, ImageGallery |
| Pricing & Info | Pricing, Comparison, DataTable, Quote, Newsletter, RelatedPages, BlogHero, ContactForm, CallToAction |
| Utility | Spacer, Embed, CustomHtml |

---

### Undo / Redo in the editor

The editor now has full undo/redo support:

- **50-step history** — snapshots are taken 800 ms after the last change (debounced) so rapid edits are grouped
- **Keyboard shortcuts** — `Ctrl+Z` / `Cmd+Z` to undo, `Ctrl+Y` / `Ctrl+Shift+Z` / `Cmd+Shift+Z` to redo
- **Toolbar buttons** — undo and redo arrows in the top bar (disabled when the stack is empty)
- History is per-session and resets on page load

---

### Per-breakpoint visibility

Every block now has a **Visibility** panel in the Properties sidebar (below Border). Toggle:

- **Hide on Desktop** — hides the section on screens ≥ 1024 px
- **Hide on Tablet** — hides the section on screens 768–1023 px
- **Hide on Mobile** — hides the section on screens < 768 px

Stored as `hideOnDesktop`, `hideOnTablet`, `hideOnMobile` boolean props on the block. `DynamicRenderer` applies the corresponding CSS classes automatically.

---

### Shape Dividers

Every block has a **Shape Dividers** panel. Configure a decorative SVG divider at the top and/or bottom of any section:

- **5 shapes** — Wave, Triangle, Curve, Slant, Zigzag
- **Color** — color picker for the divider fill
- **Height** — px height of the divider

Stored as `dividerTop` and `dividerBottom` objects on the block. Rendered by `DynamicRenderer` as absolutely-positioned SVGs overlapping adjacent sections.

---

### Font Family in the Typography panel

The **Typography** panel in the Properties sidebar now includes a **Font Family** text input. Enter any Google Font name or CSS font stack (e.g. `Inter`, `Playfair Display`, `Georgia, serif`). Stored as `fontFamily` on the block and applied via inline style in the rendered section.

---

### Block Meta (anchor, CSS class, note)

At the bottom of every block's Properties panel there is a **Block Meta** section with three fields:

| Field | Stored as | Purpose |
|---|---|---|
| Anchor ID | `blockAnchor` | Sets `id` on the section element for in-page `#` links |
| CSS Class | `blockCssClass` | Adds a custom class to the section wrapper |
| Internal Note | `blockNote` | Developer/editor note — never rendered on the front end |

---

### Per-page CSS and JS (SEO tab)

The **SEO tab** in the editor now has two collapsible code editors at the bottom:

- **Page CSS** — CSS injected into `<style>` in the document `<head>` while editing (live preview) and saved to the `page_css` column in Supabase
- **Page JS** — JavaScript saved to the `page_js` column; load it from your frontend as needed

#### Required migration

```sql
ALTER TABLE public.pages
  ADD COLUMN IF NOT EXISTS page_css text DEFAULT '';

ALTER TABLE public.pages
  ADD COLUMN IF NOT EXISTS page_js text DEFAULT '';
```

---

### Quick-edit modal on the page list

Every row in the page list (`/admin/pages/:type`) now has a **pencil icon** that opens a modal to edit the page's metadata without going into the full editor.

**What you can edit:**

- **Slug** — the URL path
- **Per-language fields** — for each configured language (`langs`), a titled card with:
  - **Titre** — maps to `seo_config.meta_title[lang]` (also sets the page's `title` column from the primary language)
  - **Description** — maps to `seo_config.meta_description[lang]`
- **Date de publication** — sets `published_at`

The modal does **not** close when clicking the backdrop — you must click "Annuler" or "Enregistrer" to dismiss it.

---

## What's New in v1.0.61

### Creation date + publish date in page lists

Every page list (`/admin/pages/:type`) now shows two date fields:

**Creation date** — displayed under the page slug in the "Page" column. Formatted as `DD Mon YYYY` using the `fr-FR` locale (e.g. `24 août 2026`). Read directly from `created_at`.

**Publication date picker** — a new "Publication" column contains a `<input type="date">` for each row. Selecting a date immediately saves it to the `published_at` column in Supabase and shows a confirmation toast. When a date is already set, the formatted date is displayed below the input in green. Clearing the input removes the date (sets `published_at` to `null`).

#### Required migration

Add the `published_at` column to your `pages` table if you are upgrading:

```sql
ALTER TABLE public.pages
  ADD COLUMN IF NOT EXISTS published_at timestamptz;
```

Your frontend can use this date to implement scheduled publishing — e.g. only render pages where `published_at <= now()`:

```ts
const { data } = await supabase
  .from('pages')
  .select('*')
  .eq('slug', slug)
  .eq('status', 'published')
  .lte('published_at', new Date().toISOString())
  .single()
```

---

## What's New in v1.0.57

### Per-page language selection + built-in sitemap

**Language selector in the editor SEO panel** — each page now has a "Langues générées" toggle section. Activate/deactivate individual languages (fr, ar, en or whatever `langs` are configured). The selection is stored in `seo_config.langs` and controls which locales are included in the sitemap. At least one language must stay active.

**Built-in sitemap endpoint** at `GET /api/admin-cms/sitemap.xml`:
- Reads all `published` pages, skips `no_index` pages
- Respects per-page language selection
- Generates `<url>` blocks with `hreflang` alternates for multi-language sites
- Single-language sites get plain `/{slug}` URLs
- Multi-language sites get `/{lang}/{slug}` URLs (matches `@nuxtjs/i18n` prefix strategy)
- Requires `NUXT_PUBLIC_SITE_URL` or `SITE_URL` env var for absolute `<loc>` values
- Cached for 1 hour

See [Per-page Language Selection & Sitemap](#per-page-language-selection--sitemap) for full usage.

---

## What's New in v1.0.35

### RichText editor — font size and list fixes

Two bugs in the rich text toolbar are fixed:

- **Font size not applying to selected text** — when the user clicked the font size number input, focus left the contenteditable, clearing the browser's active selection. The saved range could become stale after Vue's reactive re-render. The fix tracks the active contenteditable element (`activeRteEl`) and explicitly re-focuses it + restores the saved range before applying the size span.

- **Bullet / numbered list not working on selected text** — `insertUnorderedList` / `insertOrderedList` silently did nothing if the selection had disappeared (e.g. after a prior toolbar interaction). `execRteCmd` now checks for an empty selection and restores it before executing the command.

Both fixes share a new `restoreRteSelection()` helper that re-focuses the correct contenteditable and re-adds the cloned range with a `try/catch` for stale-range errors.

---

## What's New in v1.0.34

### `TableOfContents` section — both themes

A new `TableOfContents` section is now available in both `cinqcinq` and `swiss-style`. Supports three layouts: `sidebar` (title left, items right), `inline` (centered header + stacked list), and `grid` (card grid). Items have `label`, `anchor`, and optional `description` fields. Clicking an item smooth-scrolls to the target `#id` on the page.

The section definition in `useAdminSections` is updated with the full set of standard props (`spacing`, `radius`, `textSize`, `titleTag`, `marginTop`, `marginBottom`, `paddingX`) and uses CSS variable color defaults so palette swatches work out of the box.

---

## What's New in v1.0.24

### Shortcut colors are now CSS variables

Each palette slot defined in `/setup` is injected as a CSS variable (`--sc-0`, `--sc-1`…) into the page head. When you click a swatch in the editor, the section stores `var(--sc-0)` — not the raw hex. **Changing the color in `/setup` now instantly updates every section that used that swatch**, with no per-block edits needed.

- `DynamicRenderer` accepts a new `palette` prop — pass `settings?.shortcut_colors` so CSS variables also resolve on the public-facing site
- `<input type="color">` still shows the visual hex (resolved from the palette) and overrides with a custom hex when used
- Old plain hex values stored in section props continue to work as-is

### Named palette slots

Shortcut colors now have a **name** (e.g. "Primary", "Accent") editable directly in `/setup`. The name is shown as a tooltip on every swatch in the editor.

---

## What's New in v1.0.23

### Configurable shortcut colors

The `/setup` page now has a **Shortcut Colors** section. Add and remove colors from your quick-pick palette — those swatches appear in every color picker in the editor (background, button, text, overlay, etc.). Defaults to `#3d35ff / #ffffff / #000000 / #F9F9FB` if nothing is configured. Saved to `brand_settings.shortcut_colors`.

### AI context fix

The AI system prompt no longer hardcodes "web agency CMS". When an AI context is set in `/setup`, it is now used as the **primary business context** that drives every content generation and SEO audit call — not just appended after a generic persona.

### Save without theme

The Save button in `/setup` no longer requires a theme to be selected. You can now save your AI context, shortcut colors, and component defaults independently of the theme choice.

### Database: add `shortcut_colors` column

If you are upgrading, run this migration in Supabase:

```sql
ALTER TABLE public.brand_settings
  ADD COLUMN IF NOT EXISTS shortcut_colors JSONB DEFAULT '[]'::jsonb;
```

---

## What's New in v2

### Built-in section component library

All 19 section types now ship inside the module in **two fully independent design themes**. You no longer need to build section Vue files from scratch — just pick a theme on the Setup page and your sections render immediately.

### `theme` prop on `DynamicRenderer`

`AdminCmsDynamicRenderer` now accepts a `theme` prop. Pass `settings?.theme` so your front-facing pages render the correct theme. See [Using DynamicRenderer in front-facing pages](#using-dynamicrenderer-in-front-facing-pages).

### Theme-aware component resolution

Both the visual editor and `DynamicRenderer` follow this priority when resolving a section:

1. **Your app's override** — `~/components/sections/HeroSection.vue` (registered as `SectionsHeroSection`) takes precedence over everything
2. **Module theme** — `sections/swiss-style/HeroSection.vue` → `SectionsSwissStyleHeroSection`
3. **Bare name fallback**

This means you can override any individual section per project without losing the theme for the rest.

### Theme selection on Setup

The Setup page has a visual theme picker. The selected theme is saved to `brand_settings.theme`.

### Database: add `theme` column

If you are upgrading from v1, run this migration in Supabase:

```sql
ALTER TABLE public.brand_settings
  ADD COLUMN IF NOT EXISTS theme TEXT CHECK (theme IN ('cinqcinq', 'swiss-style'));
```

---

## Features

- Visual 3-panel page editor (structure / live preview / properties)
- **Multi-theme section library** — choose between `cinqcinq` (modern, rounded) and `swiss-style` (typographic, grid-based)
- **28 built-in section types** across 4 groups (Content, Media, Pricing & Info, Utility)
- **Undo / Redo** — 50-step history with keyboard shortcuts (`Ctrl+Z` / `Ctrl+Y`)
- **Per-breakpoint visibility** — hide any block on desktop, tablet, or mobile independently
- **Shape Dividers** — decorative SVG dividers at the top/bottom of any section
- **Block Meta** — anchor ID, custom CSS class, and internal note per block
- **Per-page CSS & JS** — inject custom styles and scripts saved to the database
- **Quick-edit modal** — edit slug, multilingual SEO title/description, and publish date from the page list without entering the editor
- AI content generation per section via OpenRouter (fr / ar / en simultaneously)
- AI SEO audit with multilingual meta title + description suggestions
- Project setup page: theme selection + AI context brief + configurable shortcut colors + per-component style defaults
- Multilingual content out of the box (`{ fr, ar, en }` i18n objects)
- Supabase-backed pages, brand settings, and image uploads
- Configurable `basePath` — no conflict with existing `/admin` routes
- Pre-compiled Tailwind CSS scoped to `[data-admin-cms]` — no Tailwind config needed in host app

---

## Themes

The module ships two built-in design themes applied to all 19 section types. The active theme is selected once in the **Setup** page and stored in `brand_settings.theme`.

### `cinqcinq` (default / original)
Modern style with rounded corners, card shadows, and smooth animations. Clean and accessible.

### `swiss-style`
International Typographic Style. Strict 1px border grids, sharp rectangular buttons, tight uppercase typography (`letter-spacing: -0.04em`), ghost large numbers as structural markers, and subtle scroll-reveal fade animations (`sv-in` / `sv-out`). No rounded corners anywhere.

### Theme resolution priority (per section render)
1. **Consuming app override** — `~/components/sections/HeroSection.vue` → registered as `SectionsHeroSection`
2. **Module theme** — `sections/swiss-style/HeroSection.vue` → `SectionsSwissStyleHeroSection`
3. **Bare name fallback** — component name as-is

### Using `DynamicRenderer` in front-facing pages

Pass the active theme as a prop so sections render with the correct theme:

```vue
<script setup>
const { settings, load } = useAdminSettings()
await load()
</script>

<template>
  <AdminCmsDynamicRenderer
    :content="page.content"
    :selected-id="null"
    :theme="settings?.theme"
    :palette="settings?.shortcut_colors"
  />
</template>
```

The `palette` prop injects the shortcut colors as CSS variables (`--sc-0`, `--sc-1`…) into `<head>`, so any section whose color was set via a palette swatch renders with the correct color on the public site.

---

## Requirements

- Nuxt 3
- `@nuxtjs/supabase`
- `@pinia/nuxt`
- `@nuxt/icon`
- An [OpenRouter](https://openrouter.ai) API key (optional — only needed if `features.ai: true`)

---

## Installation

See [Your project checklist](#your-project-checklist) at the top of this document for the full step-by-step setup including database, environment variables, and the frontend page renderer.

```bash
npm install cinqcinqdev-seo
```

---

## Database Setup

See [Your project checklist — step 4](#4--supabase-database) for the full SQL. The checklist at the top of this document is the canonical reference and is always up to date.

---

## Admin Routes

With default `basePath: '/admin'`:

| Route | Description |
|---|---|
| `/admin` | Dashboard — page type cards with counts |
| `/admin/setup` | Project setup — AI context + component style defaults |
| `/admin/pages/:type` | Page list for a given type (create / delete / quick-edit) |
| `/admin/editor/:id` | Visual editor for a page |
| `/admin/account` | User profile + subscription plan |

## Page List (`/admin/pages/:type`)

The page list shows all pages of a given type in a compact table grouped by slug directory. Columns:

| Column | Content |
|---|---|
| Page | Title, slug (monospace), creation date |
| SEO | "Optimisé" (green) when `seo_config.meta_title` is filled, "Incomplet" (amber) otherwise |
| Statut | Published (green badge) or Brouillon (grey badge) |
| Publication | Formatted publication date in green (published) or amber (scheduled draft); `—` when unset |
| Actions | Pencil (quick-edit), editor link, template, delete |

### Quick-edit modal

Click the pencil icon on any row to open the **quick-edit modal** — a focused dialog for editing page metadata without entering the full editor.

Fields available:

- **Slug** — the URL path stored in the database
- **Per-language cards** — one card per configured language (`langs`), each with:
  - **Titre** → `seo_config.meta_title[lang]` (the primary language value also sets the page's `title` column)
  - **Description** → `seo_config.meta_description[lang]`
- **Date de publication** — sets `published_at`

The modal does **not** close on backdrop click. Use "Annuler" to discard or "Enregistrer" to save.

### Page templates

Click the grid icon on any row to save the page's current content as a **template**. Give it a name — it will be available in the "Nouvelle page" modal when selecting "Template" as the starting point.

---

## Configuration Reference

All options go under the `adminCms` key in `nuxt.config.js`.

| Option | Type | Default | Description |
|---|---|---|---|
| `basePath` | `string` | `'/admin'` | Base path for all admin routes |
| `loginRoute` | `string` | `'/login'` | Redirect unauthenticated users here |
| `branding.name` | `string` | `'Admin CMS'` | Name shown in the sidebar |
| `branding.logoUrl` | `string` | `''` | Logo URL shown in the sidebar |
| `pageTypes` | `PageTypeConfig[]` | 6 defaults | Page type cards on the dashboard |
| `langs` | `string[]` | `['fr', 'ar', 'en']` | Languages available in the editor i18n switcher and the quick-edit modal |
| `plans` | `PlanConfig[]` | `[]` | Subscription plans on the account page |
| `tables.pages` | `string` | `'pages'` | Supabase table name for pages |
| `tables.users` | `string` | `'users'` | Supabase table name for users |
| `storageBucket` | `string` | `'site'` | Supabase storage bucket for image uploads |
| `navSections` | `NavSectionItem[]` | `[]` | Extra sidebar links |
| `extraSections` | `Record<string, SectionConfig>` | `{}` | Custom section types — appear in the editor picker and field panel automatically |
| `features.ai` | `boolean` | `false` | Enable AI content generation and SEO audit (requires `OPENROUTER_API_KEY`) |

### Default page types

If `pageTypes` is empty, these are used:

```js
[
  { id: 'landing_page',   label: 'Landing Pages' },
  { id: 'service_page',   label: 'Pages de Service' },
  { id: 'about_page',     label: 'À Propos' },
  { id: 'product_page',   label: 'Produits' },
  { id: 'blog_article',   label: 'Articles' },
  { id: 'portfolio_item', label: 'Portfolio' },
]
```

Override entirely:

```js
adminCms: {
  pageTypes: [
    { id: 'landing_page', label: 'Landing Pages', desc: 'Conversion pages' },
    { id: 'blog_article', label: 'Blog', desc: 'Articles & news' },
  ],
}
```

Each entry accepts: `id`, `label`, `icon` (emoji), `svgPath` (SVG path string for sidebar icon), `desc` (shown on dashboard card).

---

## Avoiding Route Conflicts

If your app already uses `/admin`, change the base path:

```js
adminCms: {
  basePath: '/cms',
}
```

All routes, sidebar links, redirects, and the auth middleware automatically use `/cms` instead. Nothing in your app is touched.

---

## Project Setup (`/admin/setup`)

The setup page is the first thing to fill in when starting a new project.

### Theme selection

A visual picker lets you choose between `cinqcinq` and `swiss-style`. The choice is saved to `brand_settings.theme` and used by the editor and `DynamicRenderer` to resolve section components.

### AI Context

A free-text field where you describe the website: industry, target audience, tone of voice, key services. Saved to `brand_settings.ai_context`.

This context becomes the **primary business context for every AI call** (content generation and SEO audit). The AI writes copy tailored to your actual project, not a generic "web agency" persona.

### Shortcut Colors

A named color palette where each slot has a **name** (e.g. "Primary") and a **hex value**. Each slot is injected as a CSS variable (`--sc-0`, `--sc-1`, `--sc-2`…) into the page.

When you click a palette swatch in any editor color picker, the stored value is the CSS variable reference (`var(--sc-0)`) — not the raw hex. This means **changing the color in `/setup` instantly updates every section that used that swatch**, with no need to re-edit each block.

Saved to `brand_settings.shortcut_colors`. Defaults to Primary / White / Black / Light if nothing is configured.

> **Frontend pages:** pass the palette to `DynamicRenderer` so CSS variables also resolve on the public-facing site (see [Using DynamicRenderer](#using-dynamicrenderer-in-front-facing-pages)).

### Component Style Defaults

For each built-in section type you can set:

- **Layout variant** (e.g. `centered`, `split`, `bento`, `grid`)
- **Animation** (fade up, blur, reveal, typewriter, etc.)
- **Border radius** (sharp / rounded / pill)
- **Spacing** (compact / normal / spacious)
- **Background and text colors**

Saved to `brand_settings.section_presets`. When you add a section in the visual editor, these defaults are applied automatically so every new section matches the project's style from the start.

---

## Visual Editor (`/admin/editor/:id`)

Three-panel layout:

**Left — Structure**
- List of all content blocks
- Reorder / delete blocks
- "Add section" button — opens a grouped modal (Content / Media / Pricing & Info / Utility)
- Toggle between Structure view and SEO view

**Center — Live Preview**
- Renders the actual section components
- Click any block to select it
- Language switcher (fr / ar / en) to preview each locale

**Right — Properties**
- Edit the selected block's fields (layout, colors, text, images, lists)
- Fields are grouped by tabs: **Block** (content fields), **Style** (spacing, colors, typography), **SEO** (page-level meta)
- AI button: type a prompt → generates all text fields in all three languages at once
- In SEO mode: AI SEO audit that scores the page and suggests optimised meta titles + descriptions

### Undo / Redo

| Action | Keyboard shortcut |
|---|---|
| Undo | `Ctrl+Z` / `Cmd+Z` |
| Redo | `Ctrl+Y` / `Ctrl+Shift+Z` / `Cmd+Shift+Z` |
| Save | `Ctrl+S` / `Cmd+S` |

Snapshots are debounced (800 ms after the last change). Up to 50 steps are retained per session. Toolbar buttons in the top bar reflect the available state.

### Properties panels (Block tab)

After the content fields for the selected block, the following panels are always present:

**Typography** — font size scale, font weight, text alignment, and font family (any Google Font name or CSS stack).

**Visibility** — hide the block on specific breakpoints without deleting it:
- Hide on Desktop (≥ 1024 px)
- Hide on Tablet (768–1023 px)
- Hide on Mobile (< 768 px)

**Shape Dividers** — add a decorative SVG divider at the top and/or bottom of the section. Choose from Wave, Triangle, Curve, Slant, or Zigzag. Set color and height independently for each edge.

**Block Meta** — internal metadata that does not affect the visual output:
- **Anchor ID** (`blockAnchor`) — sets `id` on the section element for `#hash` links
- **CSS Class** (`blockCssClass`) — adds a custom class to the section wrapper
- **Note** (`blockNote`) — internal note for editors; never rendered

### SEO tab — Page CSS and Page JS

Two collapsible code editors at the bottom of the SEO panel:

- **Page CSS** — injected live into `<style>` in the editor preview and saved to `page_css` in Supabase. Use it for page-specific overrides that don't belong in a global stylesheet.
- **Page JS** — saved to `page_js` in Supabase. Load and execute from your frontend page as needed.

#### Required migration

```sql
ALTER TABLE public.pages
  ADD COLUMN IF NOT EXISTS page_css text DEFAULT '';

ALTER TABLE public.pages
  ADD COLUMN IF NOT EXISTS page_js text DEFAULT '';
```

### Content locking

If a page has `content_locked = true` in the database, the editor shows only the SEO panel. The page layout is treated as fixed (defined directly in your Vue page file) and cannot be edited via the CMS. Useful for hand-crafted pages like the homepage.

---

## Section Components

The module ships **28 built-in section types** in both themes. No setup beyond choosing a theme is required.

### Content

| Type | Description |
|---|---|
| `HeroSection` | Hero with title, subtitle, badge, CTA, image |
| `HeroSlider` | Full-screen hero slider |
| `HeroFan` | Fan-layout hero |
| `TextVisual` | Text + image (split / stacked / wide) |
| `ServiceGrid` | Services in grid, list, cards, or minimal layout |
| `Features` | Feature highlights with icons |
| `Process` | Step-by-step process (steps / horizontal / timeline / cards) |
| `Testimonials` | Customer testimonials (grid / featured / wall) |
| `FAQ` | Accordion FAQ |
| `Stats` | Animated counter grid (counts up on scroll-reveal) |
| `Timeline` | Vertical or alternating timeline with dates and descriptions |
| `Tabs` | Content organised into horizontal or vertical tabs |
| `RichText` | Free rich-text block |
| `TableOfContents` | Anchor-linked table of contents (sidebar / inline / grid) |

### Media

| Type | Description |
|---|---|
| `VideoBlock` | YouTube, Vimeo, or direct video — embed auto-detected from URL; supports autoplay, loop, muted, poster, and aspect ratio |
| `ImageGallery` | Responsive image gallery — `grid` (1px border gap) or `masonry` (CSS columns) layout — with a full-screen lightbox |

### Pricing & Info

| Type | Description |
|---|---|
| `Pricing` | Pricing plans (cards / minimal / table) |
| `Comparison` | Side-by-side comparison table |
| `DataTable` | Generic data table |
| `BlogHero` | Blog article hero |
| `Newsletter` | Newsletter sign-up |
| `Quote` | Pull quote / blockquote |
| `RelatedPages` | Related pages / links grid |
| `ContactForm` | Contact form (split / centered / minimal) |
| `CallToAction` | CTA banner |

### Utility

| Type | Description |
|---|---|
| `Spacer` | Configurable vertical whitespace with an optional horizontal rule |
| `Embed` | Generic `<iframe>` embed — maps, calendars, booking widgets, etc. |
| `CustomHtml` | Raw HTML block — inject any markup directly into the page |

Each section supports: layout variants, spacing, border radius, animation, background color, text color, per-breakpoint visibility, shape dividers, block anchor, and multilingual text fields.

### Overriding a built-in section

You can override any individual section for a specific project by placing a Vue file in your app's `components/sections/` directory. It takes priority over the module theme.

```
components/
  sections/
    HeroSection.vue   ← overrides the theme version for this project only
```

Each component receives its stored props directly via `v-bind`. Handle i18n fields (stored as `{ fr, ar, en }` objects):

```vue
<!-- components/sections/HeroSection.vue -->
<script setup>
const props = defineProps(['title', 'subtitle', 'bgColor', 'textColor', 'ctaText', 'ctaLink', 'layout', 'animation'])

const { locale } = useI18n()

// Helper: resolve i18n field to a string
const t = (field) => {
  if (!field || typeof field === 'string') return field || ''
  return field[locale.value] || field.fr || Object.values(field).find(Boolean) || ''
}
</script>

<template>
  <section :style="{ background: bgColor, color: textColor }">
    <h1>{{ t(title) }}</h1>
    <p>{{ t(subtitle) }}</p>
    <NuxtLink v-if="ctaLink" :to="ctaLink">{{ t(ctaText) }}</NuxtLink>
  </section>
</template>
```

### Adding custom section types

Two steps: register the schema in `nuxt.config.js`, then create the Vue component.

#### 1 — Register the schema

```js
// nuxt.config.js
adminCms: {
  extraSections: {
    StatsBar: {
      label: 'Stats Bar',
      icon: '📊',
      // Which group it appears in inside the editor picker.
      // Built-in groups: 'Hero', 'Content', 'Showcase', 'Pricing & Info', 'Conversion'
      // Any other string creates a new group automatically.
      group: 'Content',
      defaultProps: {
        items: [],
        bgColor: '#0d0d0d',
        textColor: '#ffffff',
      },
      fields: {
        items: {
          type: 'list',
          label: 'Stats',
          itemFields: {
            value: { type: 'text', label: 'Number' },
            label: { type: 'text', label: 'Label', i18n: true },
          },
        },
        bgColor:   { type: 'color', label: 'Background' },
        textColor: { type: 'color', label: 'Text Color' },
      },
    },
  },
}
```

The key (`StatsBar`) becomes the section `type` stored in the database and matched to the component name.

#### 2 — Create the Vue component

Place the file at `components/sections/StatsBar.vue`. Nuxt auto-imports it as `SectionsStatsBar`, which is exactly what `DynamicRenderer` looks for.

```vue
<!-- components/sections/StatsBar.vue -->
<template>
  <section :style="{ backgroundColor: bgColor, color: textColor }" class="py-16 px-8">
    <div class="flex flex-wrap gap-12 justify-center">
      <div v-for="(item, i) in items" :key="i" class="text-center">
        <p class="text-5xl font-black">{{ item.value }}</p>
        <p class="text-sm mt-1 opacity-60">{{ t(item.label) }}</p>
      </div>
    </div>
  </section>
</template>

<script setup>
const props = defineProps({
  items:     { type: Array,  default: () => [] },
  bgColor:   { type: String, default: '#0d0d0d' },
  textColor: { type: String, default: '#ffffff' },
})

// i18n helper — DynamicRenderer already unwraps {fr,ar,en} for you,
// but if you render outside DynamicRenderer, use this:
const t = (v) => {
  if (!v || typeof v === 'string') return v || ''
  return v[useNuxtApp().$i18n?.locale?.value] || v.fr || Object.values(v).find(Boolean) || ''
}
</script>
```

#### How it works

- `DynamicRenderer` resolves `Sections{Type}` with this priority:
  1. **Your app's component** — `components/sections/StatsBar.vue` → `SectionsStatsBar` ← this one
  2. Module theme (e.g. `SectionsSwissStyleStatsBar`) — only for built-in types
  3. Bare component name fallback
- The editor reads the schema from `extraSections` (merged via `useAdminSections()`) to render the field panel and populate the "Add section" modal
- Fields defined in `fields` appear in the editor's **Content** and **Style** tabs automatically
- `_style` (CSS overrides) and `customCss` are added to every section automatically — no need to declare them

#### Custom group

If `group` is not one of the five built-in group names, a new group is created automatically at the bottom of the picker sidebar:

```js
extraSections: {
  MyInteractiveMap: {
    label: 'Interactive Map',
    group: 'My Components',  // → new group appears in the picker
    defaultProps: { ... },
    fields: { ... },
  },
}
```

#### AI content generation

Text fields with `i18n: true` are automatically included in the AI "Generate" prompt — no extra configuration needed. Markdown fields (type `'markdown'`) are also filled by the AI in plain markdown syntax.

### Field types

| Type | Description |
|---|---|
| `text` | Single-line input. Add `i18n: true` for fr/ar/en tabs |
| `textarea` | Multi-line input. Add `i18n: true` for fr/ar/en tabs |
| `color` | Color picker + hex input |
| `image` | Image URL input + Supabase storage upload button |
| `select` | Dropdown. Requires `options: [{ value, label }]` |
| `cards` | Visual card picker. Requires `options: [{ value, label, icon? }]` |
| `list` | Repeatable list. Requires `itemFields: { fieldName: FieldConfig }` |

---

## Multilingual Content

Text fields with `i18n: true` are stored as:

```json
{ "fr": "Titre français", "ar": "العنوان", "en": "English title" }
```

Plain fields (colors, images, URLs, booleans) remain scalar values.

**Fallback rule:** current locale → `fr` → first non-empty value.

---

## Per-page Language Selection & Sitemap

### Choosing which languages to generate

Every page has a **"Langues générées"** section in the editor's SEO panel (right sidebar → SEO tab → scroll to bottom). Toggle each language on/off individually. At least one language must remain active.

- When **all languages are active** (default), the page behaves as before
- When **a language is deactivated**, it is excluded from the sitemap and your frontend can skip rendering that locale

The selection is stored in `seo_config.langs` as a string array:

```json
{ "langs": ["fr", "en"] }
```

### Built-in sitemap endpoint

The module exposes a ready-made sitemap at:

```
GET /api/admin-cms/sitemap.xml
```

It reads all `published` pages, respects `no_index`, applies the per-page language selection, and returns a valid XML sitemap with `hreflang` alternates.

**URL format used by the sitemap:**

| Site config | URL pattern |
|---|---|
| Single language (`langs: ['fr']`) | `/{slug}` |
| Multiple languages | `/{lang}/{slug}` (e.g. `/fr/about`, `/en/about`) |

> This matches the `prefix` strategy of `@nuxtjs/i18n`. If you use a different strategy, generate your own sitemap from the `/api/admin-cms/sitemap.xml` data or fetch pages directly from Supabase.

### Required environment variable

The sitemap needs to know your public site URL to build absolute `<loc>` values:

```env
# .env
NUXT_PUBLIC_SITE_URL=https://www.mysite.com
# or
SITE_URL=https://www.mysite.com
```

### Using the sitemap in production

**Option A — point your sitemap index directly at the endpoint:**

```xml
<!-- /sitemap_index.xml -->
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://www.mysite.com/api/admin-cms/sitemap.xml</loc>
  </sitemap>
</sitemapindex>
```

**Option B — proxy it to `/sitemap.xml`** by adding a server route in your app:

```ts
// server/routes/sitemap.xml.ts
import { defineEventHandler, proxyRequest } from 'h3'
export default defineEventHandler((event) =>
  proxyRequest(event, 'http://localhost:3000/api/admin-cms/sitemap.xml')
)
```

**Option C — use it alongside `@nuxtjs/sitemap`** by registering it as a source:

```ts
// nuxt.config.ts
sitemap: {
  sitemaps: {
    pages: { urls: '/api/admin-cms/sitemap.xml' },
  },
}
```

### Configuring available languages

The languages available in the per-page toggle come from your module config:

```ts
// nuxt.config.ts
adminCms: {
  langs: ['fr', 'en'],          // only FR and EN available in the editor
}
```

Defaults to `['fr', 'ar', 'en']` when not specified.

---

## AI Features

AI features are **disabled by default**. To enable them, set `features.ai: true` in your config and add `OPENROUTER_API_KEY` to your `.env`:

```js
adminCms: {
  features: { ai: true },
}
```

```env
OPENROUTER_API_KEY=sk-or-...
```

Uses `google/gemini-2.5-flash` via OpenRouter.

### Content generation

1. Select a block in the editor
2. Click the AI button (wand icon)
3. Type a prompt — e.g. "Emphasise 10 years of expertise and affordable prices"
4. All text fields (title, subtitle, items, etc.) are filled in fr + ar + en simultaneously

The AI always receives the **AI Context** from `/admin/setup` as a system prompt, ensuring output is always relevant to the specific project.

### SEO audit

In SEO mode (left panel toggle), click "Analyser avec l'IA" to:
- Get a score (0–100)
- Receive an optimised meta title and meta description in fr / ar / en
- See a list of issues (errors, warnings, info)
- Get actionable suggestions

Apply the AI suggestions with one click, then adjust as needed.

---

## Sidebar Extra Links

Add custom links to the sidebar's "Contenu" section:

```js
adminCms: {
  navSections: [
    {
      label: 'Messages',
      to: '/admin/contact',
      svgPath: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>',
    },
  ],
}
```

`to` can point to any route in your app — module pages or your own custom pages.

---

## Subscription Plans (Account Page)

Show subscription plans on the `/admin/account` page:

```js
adminCms: {
  plans: [
    {
      value: 'starter',
      title: 'Starter',
      subtitle: 'For small projects',
      price: '0 DZD',
      features: ['5 pages', '1 user', 'Basic SEO'],
    },
    {
      value: 'pro',
      title: 'Pro',
      subtitle: 'For growing agencies',
      price: '4 900 DZD/mo',
      features: ['Unlimited pages', 'AI generation', 'Priority support'],
      popular: true,
    },
  ],
}
```

Plan state (`plan`, `is_subscription_active`, `trial_expires_at`) is read from the `users` table.

---

## Auto-imported Composables

These are available everywhere in your app without importing:

### `useAdminSettings()`

Load and save the `brand_settings` row (AI context, section presets, colors, fonts).

```ts
const { settings, loading, saving, load, save, applyPresets } = useAdminSettings()

await load()
// settings.value → { ai_context, section_presets, primary_color, ... }

await save({ ai_context: 'Web agency in Algiers...' })

// Apply saved style presets when adding a new section
const props = applyPresets('HeroSection', defaultProps)
// → merges layout, spacing, radius, animation, bgColor, textColor from brand_settings
```

### `useAdminSections()`

Returns the merged section config (built-ins + `extraSections`).

```ts
const sections = useAdminSections()
// { HeroSection: { label, icon, defaultProps, fields }, ... }
```

### `useAdminBasePath()`

Returns the configured admin base path string.

```ts
const basePath = useAdminBasePath()
// '/admin' or whatever is configured
```

### `useAdminBranding()`

Load and save branding data from a `branding` table (separate from `brand_settings`).

```ts
const { fetchBranding, saveBranding } = useAdminBranding()
const data = await fetchBranding()
await saveBranding({ brand_name: 'My Agency', color_primary: '#3d35ff' })
```

---

## Styling

The module ships a pre-compiled Tailwind CSS file scoped to `[data-admin-cms]`.

- Admin styles never leak into your app
- No Tailwind config required in the consuming project
- If your app uses `@nuxtjs/tailwindcss`, the module automatically extends its content paths so admin classes are never purged
- The section **preview** area in the editor inherits your app's own body font, so sections look exactly as they do on the live site

---

## Auth

A global Nuxt route middleware is registered automatically:

- Redirects unauthenticated users from `basePath/*` to `loginRoute`
- Redirects authenticated users away from `loginRoute` to `basePath`

Auth state is read from `useSupabaseUser()` provided by `@nuxtjs/supabase`. Your login page and Supabase auth setup are your responsibility.

---

## Rendering Pages on the Frontend

See [Your project checklist — step 6](#6--frontend-page-renderer-pagessluevue) for the complete `pages/[...slug].vue` file including `page_css`/`page_js` injection, `useSeoMeta`, and `AdminCmsDynamicRenderer` usage.

### Key points

- Use `pages/[...slug].vue` (catch-all, three dots) — **not** `pages/[slug].vue`. A plain `[slug].vue` only matches single-segment paths and will return 404 for subdirectory slugs like `/faq/pricing`.
- Join the slug array: `(route.params.slug as string[]).join('/')`
- Pass `theme` and `palette` to `AdminCmsDynamicRenderer` so sections render with the correct design and palette swatch colors
- Inject `page.page_css` via `useHead` if you want per-page CSS to apply on the public site
- Inject `page.page_js` via `useHead` if you want per-page JS to run on the public site

### Subdirectory slugs

When a page is created with subdirectory `faq` and title `What is pricing`, the slug stored in the database is `faq/what-is-pricing`. The slash is just part of the slug string — there is no nested table or parent record.

---

## What's NOT Included

- Login / register pages — you provide these
- Email / webhook integrations
- Multi-tenancy / per-user page isolation — implement via Supabase RLS

---

## Full Example

```js
// nuxt.config.js
export default defineNuxtConfig({
  modules: [
    '@nuxtjs/supabase',
    '@pinia/nuxt',
    '@nuxt/icon',
    'cinqcinqdev-seo',
  ],

  supabase: {
    redirect: false,
  },

  adminCms: {
    basePath: '/admin',          // change if /admin is already taken
    loginRoute: '/login',

    branding: {
      name: 'My Agency',
      logoUrl: '/logo.svg',
    },

    storageBucket: 'site',

    tables: {
      pages: 'pages',
      users: 'users',
    },

    pageTypes: [
      { id: 'landing_page', label: 'Landing Pages', desc: 'Conversion pages' },
      { id: 'service_page', label: 'Services',      desc: 'Service pages' },
      { id: 'blog_article', label: 'Blog',           desc: 'Articles & news' },
    ],

    navSections: [
      {
        label: 'Contacts',
        to: '/admin/contact',
        svgPath: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>',
      },
    ],

    plans: [
      {
        value: 'starter',
        title: 'Starter',
        price: '0 DZD',
        features: ['5 pages', '1 user'],
      },
      {
        value: 'pro',
        title: 'Pro',
        price: '4 900 DZD/mo',
        features: ['Unlimited pages', 'AI generation', 'Priority support'],
        popular: true,
      },
    ],

    extraSections: {
      StatsBar: {
        label: 'Stats Bar',
        icon: '📊',
        defaultProps: {
          items: [],
          bgColor: '#0d0d0d',
          textColor: '#ffffff',
        },
        fields: {
          items: {
            type: 'list',
            label: 'Stats',
            itemFields: {
              value: { type: 'text', label: 'Number' },
              label: { type: 'text', label: 'Label', i18n: true },
            },
          },
          bgColor:   { type: 'color', label: 'Background' },
          textColor: { type: 'color', label: 'Text Color' },
        },
      },
    },
  },
})
```
