npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

cinqcinqdev-seo

v2.1.4

Published

A reusable Nuxt 3 admin CMS module with visual page editor powered by Supabase

Downloads

11,228

Readme

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

npm install cinqcinqdev-seo

2 — nuxt.config

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

# 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:

-- 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:

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):

-- 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.

<!-- 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):

<h1>{{ title }}</h1>

Rich text (HTML — supports bold, italic, colors, etc.):

<h1 v-html="title" />
<!-- or -->
<p v-html="subtitle" />

If the prop may contain HTML or plain text, always use v-html:

<!-- 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:

// 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 shortcutsCtrl+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

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:

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():

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 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 textinsertUnorderedList / 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:

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.

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 themesections/swiss-style/HeroSection.vueSectionsSwissStyleHeroSection
  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:

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 themesections/swiss-style/HeroSection.vueSectionsSwissStyleHeroSection
  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:

<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 API key (optional — only needed if features.ai: true)

Installation

See 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.

npm install cinqcinqdev-seo

Database Setup

See Your project checklist — step 4 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:
    • Titreseo_config.meta_title[lang] (the primary language value also sets the page's title column)
    • Descriptionseo_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:

[
  { 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:

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:

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).

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

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):

<!-- 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

// 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.

<!-- 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 componentcomponents/sections/StatsBar.vueSectionsStatsBar ← 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:

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:

{ "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:

{ "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
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:

<!-- /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:

// 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:

// 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:

// 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:

adminCms: {
  features: { ai: true },
}
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:

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:

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).

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).

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

useAdminBasePath()

Returns the configured admin base path string.

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

useAdminBranding()

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

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 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

// 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' },
        },
      },
    },
  },
})