npm package: @kustomizerr/visual-editor
Description: Visual page editor for Kustomizer storefronts — drag-and-drop page builder for headless Shopify stores
Latest version: 0.1.0

# @kustomizerr/visual-editor

Drag-and-drop page builder for headless Shopify storefronts. Angular 21+, SSR-ready.

```bash
npm install @kustomizerr/visual-editor @ngrx/store
```

## Minimal Setup (in-memory, no backend)

This is the fastest way to get the editor running — pages live in memory, no auth needed:

```typescript
// app.config.ts
import { provideStore } from '@ngrx/store';
import { provideVisualEditorStore, provideVisualEditor, provideEditorComponents } from '@kustomizerr/visual-editor';
import { myComponents } from './editor-components';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideStore(),
    provideVisualEditorStore(),
    provideVisualEditor({ pages: { useInMemory: true } }),
    provideEditorComponents(myComponents),
  ],
};
```

```typescript
// app.routes.ts
export const routes: Routes = [
  {
    path: 'admin',
    loadComponent: () => import('@kustomizerr/visual-editor').then(m => m.AdminPanelComponent),
    children: [
      { path: '', loadComponent: () => import('@kustomizerr/visual-editor').then(m => m.PageManagerComponent) },
      { path: 'pages/:pageId', loadComponent: () => import('@kustomizerr/visual-editor').then(m => m.VisualEditorComponent) },
    ],
  },
  { path: '', redirectTo: 'admin', pathMatch: 'full' },
];
```

That's it. Open `/admin` and start building pages.

## Production Setup (with Kustomizer backend)

Add `api`, `STORE_DOMAIN`, `HttpClient`, and a public page renderer for SSR:

```typescript
// app.config.ts
import { provideHttpClient, withFetch } from '@angular/common/http';
import { STORE_DOMAIN } from '@kustomizerr/visual-editor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()),
    provideStore(),
    provideVisualEditorStore(),
    { provide: STORE_DOMAIN, useValue: 'your-store.myshopify.com' },
    provideVisualEditor({ api: { apiUrl: 'https://kustomizer.xyz' } }),
    provideEditorComponents(myComponents),
  ],
};
```

```typescript
// app.routes.ts — add public page route
export const routes: Routes = [
  {
    path: 'admin',
    loadComponent: () => import('@kustomizerr/visual-editor').then(m => m.AdminPanelComponent),
    children: [
      { path: '', loadComponent: () => import('@kustomizerr/visual-editor').then(m => m.PageManagerComponent) },
      { path: 'pages/:pageId', loadComponent: () => import('@kustomizerr/visual-editor').then(m => m.VisualEditorComponent) },
    ],
  },
  {
    path: 'pages/:slug',
    loadComponent: () => import('./pages/page-renderer.component').then(m => m.PageRendererComponent),
  },
  { path: '', redirectTo: 'admin', pathMatch: 'full' },
];
```

```typescript
// app.routes.server.ts — SSR config
export const serverRoutes: ServerRoute[] = [
  { path: 'admin/**', renderMode: RenderMode.Client },
  { path: 'pages/:slug', renderMode: RenderMode.Server },
  { path: '**', renderMode: RenderMode.Prerender },
];
```

```typescript
// pages/page-renderer.component.ts
import { DynamicRendererComponent, VisualEditorFacade, Page } from '@kustomizerr/visual-editor';

@Component({
  imports: [DynamicRendererComponent],
  template: `
    @for (section of page()?.sections; track section.id) {
      <lib-dynamic-renderer [element]="section" [context]="{ isEditor: false }" />
    }
  `,
})
export class PageRendererComponent implements OnInit {
  private readonly route = inject(ActivatedRoute);
  private readonly facade = inject(VisualEditorFacade);
  readonly page = signal<Page | null>(null);

  ngOnInit() {
    const slug = this.route.snapshot.paramMap.get('slug');
    if (slug) this.facade.getPublishedPageBySlug(slug).subscribe(p => this.page.set(p));
  }
}
```

> The default routes are `/admin` and `/admin/pages/:pageId`. If you need different paths, pass `navigation.routerConfig` to `provideVisualEditor()`.

---

## Creating Components

Two files per component: a **definition** (schema for the editor) and a **component** (Angular rendering).

### Example: Hero Section

```typescript
// hero-section.definition.ts
import { ComponentDefinition } from '@kustomizerr/visual-editor';
import { HeroComponent } from './hero.component';

export const heroDefinition: ComponentDefinition = {
  type: 'hero',
  name: 'Hero Banner',
  category: 'layout',
  component: HeroComponent,
  isSection: true,          // top-level container (use isBlock: true for nested elements)
  draggable: true,
  deletable: true,

  props: {
    title:   { type: 'string', label: 'Title',      defaultValue: 'Hello',   group: 'Content' },
    bgColor: { type: 'color',  label: 'Background', defaultValue: '#1a1a2e', group: 'Style' },
    bgImage: { type: 'image',  label: 'Image',      defaultValue: '',        group: 'Style' },
  },

  // Optional: define drop zones for child blocks
  slots: [{
    name: 'content',
    label: 'Blocks',
    constraints: { allowedTypes: ['text-block', 'button-block'], maxItems: 5 },
    droppable: true,
  }],
};
```

```typescript
// hero.component.ts
import { DynamicRendererComponent, EditorElement } from '@kustomizerr/visual-editor';

@Component({
  imports: [DynamicRendererComponent],
  template: `
    <section [style.backgroundColor]="bgColor()">
      <h1>{{ title() }}</h1>
      @for (child of _children(); track child.id) {
        <lib-dynamic-renderer [element]="child" [context]="_context()" />
      }
    </section>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeroComponent {
  readonly title = input('Hello');        // prop names MUST match definition keys
  readonly bgColor = input('#1a1a2e');
  readonly bgImage = input('');

  readonly _elementId = input('');        // injected by the editor
  readonly _children = input<EditorElement[]>([]);
  readonly _context = input<Record<string, unknown>>({});
}
```

### Register components

```typescript
// editor-components/index.ts
import { heroDefinition } from './hero/hero.definition';
import { textBlockDefinition } from './blocks/text-block.definition';

export const myComponents = [heroDefinition, textBlockDefinition];
```

### Prop types

| Type | Control | Extra |
|------|---------|-------|
| `string` | Text input | |
| `textarea` | Textarea | |
| `richtext` | Rich text editor | |
| `number` | Number input | `validation: { min, max }` |
| `boolean` | Toggle | |
| `color` | Color picker | |
| `image` | File picker | |
| `url` | URL input | |
| `select` | Dropdown | `options: [{ label, value }]` |
| `range` | Slider | `validation: { min, max }`, `step` |

Props support `group` (sidebar tabs), `order`, `validation`, and conditional visibility via `condition`.

### Section vs Block

| | `isSection: true` | `isBlock: true` |
|---|---|---|
| Where | "Add Section" panel | Inside section slots |
| `blockScope` | — | `'theme'` (all) or `'section'` (specific) |

---

## Config Reference

```typescript
provideVisualEditor({
  api: { apiUrl: '...' },               // enables backend mode + auth
  pages: { useInMemory: true },          // in-memory mode (no api needed)
  navigation: {
    routerConfig: {                      // defaults shown:
      pageListPath: '/admin',
      pageEditPath: '/admin/pages/:pageId',
      pageIdParam: 'pageId',
    },
  },
  ui: {
    showBackButton: true,
    showPublishButtons: true,
    showSaveButton: true,
  },
})
```

## Build & Publish

```bash
ng build visual-editor
cd dist/visual-editor
npm publish --access public
```