feat: add FAQ collection and block

- Add FAQs collection with question/answer, categories, and ordering
- Add FAQBlock with collection and inline source modes
- Support multiple layouts: accordion, grid, list, two-column
- Schema.org FAQPage structured data support for SEO
- Multi-tenant enabled via plugin configuration
- Update documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-10 05:34:41 +00:00
parent 9e589b895c
commit 16be4cf553
9 changed files with 16236 additions and 2 deletions

View file

@ -48,6 +48,7 @@ Internet → 37.24.237.181 → Caddy (443) → Payload (3000)
│ │ ├── Categories.ts
│ │ ├── Portfolios.ts
│ │ ├── PortfolioCategories.ts
│ │ ├── FAQs.ts
│ │ ├── EmailLogs.ts
│ │ ├── AuditLogs.ts
│ │ └── ...
@ -400,6 +401,7 @@ SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 10;
| Portfolios | portfolios | Portfolio-Galerien (Fotografie) |
| PortfolioCategories | portfolio-categories | Kategorien für Portfolios |
| Testimonials | testimonials | Kundenbewertungen |
| FAQs | faqs | Häufig gestellte Fragen (FAQ) |
| NewsletterSubscribers | newsletter-subscribers | Newsletter mit Double Opt-In |
| SocialLinks | social-links | Social Media Links |
| Forms | forms | Formular-Builder |
@ -444,4 +446,4 @@ pnpm test:coverage
- `docs/anleitungen/TODO.md` - Task-Liste & Roadmap
- `docs/anleitungen/SECURITY.md` - Sicherheitsrichtlinien
*Letzte Aktualisierung: 09.12.2025*
*Letzte Aktualisierung: 10.12.2025*

258
src/blocks/FAQBlock.ts Normal file
View file

@ -0,0 +1,258 @@
import type { Block } from 'payload'
/**
* FAQ Block
*
* Zeigt FAQs entweder aus der FAQs Collection oder als inline-definierte Fragen.
* Unterstützt verschiedene Layouts (Accordion, Grid, Liste) und
* generiert automatisch Schema.org FAQPage Structured Data.
*/
export const FAQBlock: Block = {
slug: 'faq-block',
labels: {
singular: 'FAQ',
plural: 'FAQs',
},
fields: [
{
name: 'title',
type: 'text',
defaultValue: 'Häufig gestellte Fragen',
label: 'Überschrift',
localized: true,
},
{
name: 'subtitle',
type: 'text',
label: 'Untertitel',
localized: true,
},
{
name: 'sourceMode',
type: 'select',
defaultValue: 'collection',
label: 'Quelle',
options: [
{ label: 'Aus FAQ Collection', value: 'collection' },
{ label: 'Inline (nur auf dieser Seite)', value: 'inline' },
],
},
// Collection Mode Fields
{
name: 'displayMode',
type: 'select',
defaultValue: 'all',
label: 'Auswahl',
options: [
{ label: 'Alle aktiven FAQs', value: 'all' },
{ label: 'Nur hervorgehobene', value: 'featured' },
{ label: 'Nach Kategorie', value: 'category' },
{ label: 'Handverlesene Auswahl', value: 'selected' },
],
admin: {
condition: (data, siblingData) => siblingData?.sourceMode === 'collection',
},
},
{
name: 'category',
type: 'text',
label: 'Kategorie filtern',
admin: {
condition: (data, siblingData) =>
siblingData?.sourceMode === 'collection' && siblingData?.displayMode === 'category',
description: 'Zeigt nur FAQs mit dieser Kategorie',
},
},
{
name: 'selectedFAQs',
type: 'relationship',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
relationTo: 'faqs' as any,
hasMany: true,
label: 'FAQs auswählen',
admin: {
condition: (data, siblingData) =>
siblingData?.sourceMode === 'collection' && siblingData?.displayMode === 'selected',
},
},
{
name: 'limit',
type: 'number',
defaultValue: 10,
min: 1,
max: 50,
label: 'Maximale Anzahl',
admin: {
condition: (data, siblingData) =>
siblingData?.sourceMode === 'collection' &&
(siblingData?.displayMode === 'all' || siblingData?.displayMode === 'category'),
},
},
// Inline Mode Fields
{
name: 'inlineFAQs',
type: 'array',
label: 'FAQs',
labels: {
singular: 'FAQ',
plural: 'FAQs',
},
admin: {
condition: (data, siblingData) => siblingData?.sourceMode === 'inline',
},
fields: [
{
name: 'question',
type: 'text',
required: true,
label: 'Frage',
localized: true,
},
{
name: 'answer',
type: 'richText',
required: true,
label: 'Antwort',
localized: true,
},
{
name: 'answerPlainText',
type: 'textarea',
label: 'Antwort (Nur-Text für Schema.org)',
localized: true,
admin: {
description: 'Kurzfassung als reiner Text für SEO Structured Data',
},
},
],
},
// Layout Options
{
name: 'layout',
type: 'select',
defaultValue: 'accordion',
label: 'Layout',
options: [
{ label: 'Accordion (aufklappbar)', value: 'accordion' },
{ label: 'Grid (Karten)', value: 'grid' },
{ label: 'Liste', value: 'list' },
{ label: 'Zweispaltig', value: 'two-column' },
],
},
{
name: 'columns',
type: 'select',
defaultValue: '2',
label: 'Spalten',
options: [
{ label: '2 Spalten', value: '2' },
{ label: '3 Spalten', value: '3' },
],
admin: {
condition: (data, siblingData) => siblingData?.layout === 'grid',
},
},
{
name: 'expandFirst',
type: 'checkbox',
defaultValue: true,
label: 'Erste Frage automatisch öffnen',
admin: {
condition: (data, siblingData) => siblingData?.layout === 'accordion',
},
},
{
name: 'allowMultipleOpen',
type: 'checkbox',
defaultValue: false,
label: 'Mehrere gleichzeitig öffnen erlauben',
admin: {
condition: (data, siblingData) => siblingData?.layout === 'accordion',
},
},
{
name: 'showCategory',
type: 'checkbox',
defaultValue: false,
label: 'Kategorie anzeigen',
},
{
name: 'showIcon',
type: 'checkbox',
defaultValue: false,
label: 'Icons anzeigen',
},
{
name: 'groupByCategory',
type: 'checkbox',
defaultValue: false,
label: 'Nach Kategorie gruppieren',
admin: {
condition: (data, siblingData) =>
siblingData?.sourceMode === 'collection' && siblingData?.displayMode !== 'category',
},
},
// Schema.org Options
{
name: 'enableSchemaOrg',
type: 'checkbox',
defaultValue: true,
label: 'Schema.org FAQPage generieren',
admin: {
description: 'Generiert SEO-optimiertes JSON-LD Structured Data',
},
},
// Styling
{
name: 'backgroundColor',
type: 'select',
defaultValue: 'white',
label: 'Hintergrund',
options: [
{ label: 'Weiß', value: 'white' },
{ label: 'Hell (Grau)', value: 'light' },
{ label: 'Dunkel', value: 'dark' },
{ label: 'Akzentfarbe', value: 'accent' },
],
},
// CTA
{
name: 'showContactCTA',
type: 'checkbox',
defaultValue: false,
label: 'Kontakt-CTA anzeigen',
admin: {
description: 'Zeigt einen "Frage nicht gefunden? Kontaktieren Sie uns" Button',
},
},
{
name: 'contactCTAText',
type: 'text',
defaultValue: 'Ihre Frage war nicht dabei?',
label: 'CTA Text',
localized: true,
admin: {
condition: (data, siblingData) => siblingData?.showContactCTA,
},
},
{
name: 'contactCTALink',
type: 'text',
defaultValue: '/kontakt',
label: 'CTA Link',
admin: {
condition: (data, siblingData) => siblingData?.showContactCTA,
},
},
{
name: 'contactCTAButtonText',
type: 'text',
defaultValue: 'Kontaktieren Sie uns',
label: 'CTA Button Text',
localized: true,
admin: {
condition: (data, siblingData) => siblingData?.showContactCTA,
},
},
],
}

View file

@ -14,3 +14,4 @@ export { PostsListBlock } from './PostsListBlock'
export { TestimonialsBlock } from './TestimonialsBlock'
export { NewsletterBlock } from './NewsletterBlock'
export { ProcessStepsBlock } from './ProcessStepsBlock'
export { FAQBlock } from './FAQBlock'

117
src/collections/FAQs.ts Normal file
View file

@ -0,0 +1,117 @@
// src/collections/FAQs.ts
import type { CollectionConfig } from 'payload'
import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess'
/**
* FAQs Collection
*
* Häufig gestellte Fragen und Antworten, wiederverwendbar auf allen Seiten.
* Tenant-scoped für Multi-Tenant-Betrieb.
* Unterstützt Schema.org FAQPage Structured Data für SEO.
*/
export const FAQs: CollectionConfig = {
slug: 'faqs',
admin: {
useAsTitle: 'question',
group: 'Content',
defaultColumns: ['question', 'category', 'order', 'isActive'],
description: 'Häufig gestellte Fragen (FAQ)',
},
access: {
read: tenantScopedPublicRead,
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'question',
type: 'text',
required: true,
label: 'Frage',
localized: true,
admin: {
description: 'Die Frage, die beantwortet wird',
},
},
{
name: 'answer',
type: 'richText',
required: true,
label: 'Antwort',
localized: true,
admin: {
description: 'Die ausführliche Antwort auf die Frage',
},
},
{
name: 'answerPlainText',
type: 'textarea',
label: 'Antwort (Nur-Text)',
localized: true,
admin: {
description:
'Kurzfassung der Antwort als reiner Text für Schema.org Structured Data. Falls leer, wird die Rich-Text-Antwort verwendet.',
},
},
{
name: 'category',
type: 'text',
label: 'Kategorie',
localized: true,
admin: {
description: 'Optionale Kategorie zur Gruppierung (z.B. "Allgemein", "Preise", "Lieferung")',
},
},
{
name: 'icon',
type: 'text',
label: 'Icon',
admin: {
description: 'Optionaler Icon-Name (z.B. "question-circle", "info")',
},
},
{
name: 'relatedFAQs',
type: 'relationship',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
relationTo: 'faqs' as any,
hasMany: true,
label: 'Verwandte FAQs',
admin: {
description: 'Andere FAQs die thematisch zusammenhängen',
},
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
label: 'Aktiv/Sichtbar',
admin: {
position: 'sidebar',
description: 'Inaktive FAQs werden nicht angezeigt',
},
},
{
name: 'isFeatured',
type: 'checkbox',
defaultValue: false,
label: 'Hervorgehoben',
admin: {
position: 'sidebar',
description: 'Hervorgehobene FAQs werden prominent angezeigt',
},
},
{
name: 'order',
type: 'number',
defaultValue: 0,
label: 'Sortierung',
admin: {
position: 'sidebar',
description: 'Niedrigere Zahlen werden zuerst angezeigt',
},
},
],
}

View file

@ -15,6 +15,7 @@ import {
TestimonialsBlock,
NewsletterBlock,
ProcessStepsBlock,
FAQBlock,
} from '../blocks'
export const Pages: CollectionConfig = {
@ -97,6 +98,7 @@ export const Pages: CollectionConfig = {
TestimonialsBlock,
NewsletterBlock,
ProcessStepsBlock,
FAQBlock,
],
},
{

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,147 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_pages_blocks_faq_block_source_mode" AS ENUM('collection', 'inline');
CREATE TYPE "public"."enum_pages_blocks_faq_block_display_mode" AS ENUM('all', 'featured', 'category', 'selected');
CREATE TYPE "public"."enum_pages_blocks_faq_block_layout" AS ENUM('accordion', 'grid', 'list', 'two-column');
CREATE TYPE "public"."enum_pages_blocks_faq_block_columns" AS ENUM('2', '3');
CREATE TYPE "public"."enum_pages_blocks_faq_block_background_color" AS ENUM('white', 'light', 'dark', 'accent');
CREATE TABLE "pages_blocks_faq_block_inline_f_a_qs" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL
);
CREATE TABLE "pages_blocks_faq_block_inline_f_a_qs_locales" (
"question" varchar,
"answer" jsonb,
"answer_plain_text" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "pages_blocks_faq_block" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"source_mode" "enum_pages_blocks_faq_block_source_mode" DEFAULT 'collection',
"display_mode" "enum_pages_blocks_faq_block_display_mode" DEFAULT 'all',
"category" varchar,
"limit" numeric DEFAULT 10,
"layout" "enum_pages_blocks_faq_block_layout" DEFAULT 'accordion',
"columns" "enum_pages_blocks_faq_block_columns" DEFAULT '2',
"expand_first" boolean DEFAULT true,
"allow_multiple_open" boolean DEFAULT false,
"show_category" boolean DEFAULT false,
"show_icon" boolean DEFAULT false,
"group_by_category" boolean DEFAULT false,
"enable_schema_org" boolean DEFAULT true,
"background_color" "enum_pages_blocks_faq_block_background_color" DEFAULT 'white',
"show_contact_c_t_a" boolean DEFAULT false,
"contact_c_t_a_link" varchar DEFAULT '/kontakt',
"block_name" varchar
);
CREATE TABLE "pages_blocks_faq_block_locales" (
"title" varchar DEFAULT 'Häufig gestellte Fragen',
"subtitle" varchar,
"contact_c_t_a_text" varchar DEFAULT 'Ihre Frage war nicht dabei?',
"contact_c_t_a_button_text" varchar DEFAULT 'Kontaktieren Sie uns',
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "faqs" (
"id" serial PRIMARY KEY NOT NULL,
"tenant_id" integer,
"icon" varchar,
"is_active" boolean DEFAULT true,
"is_featured" boolean DEFAULT false,
"order" numeric DEFAULT 0,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "faqs_locales" (
"question" varchar NOT NULL,
"answer" jsonb NOT NULL,
"answer_plain_text" varchar,
"category" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
CREATE TABLE "faqs_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"faqs_id" integer
);
ALTER TABLE "pages_rels" ADD COLUMN "faqs_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "faqs_id" integer;
ALTER TABLE "pages_blocks_faq_block_inline_f_a_qs" ADD CONSTRAINT "pages_blocks_faq_block_inline_f_a_qs_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_faq_block"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_faq_block_inline_f_a_qs_locales" ADD CONSTRAINT "pages_blocks_faq_block_inline_f_a_qs_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_faq_block_inline_f_a_qs"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_faq_block" ADD CONSTRAINT "pages_blocks_faq_block_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_faq_block_locales" ADD CONSTRAINT "pages_blocks_faq_block_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_faq_block"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "faqs" ADD CONSTRAINT "faqs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "faqs_locales" ADD CONSTRAINT "faqs_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."faqs"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "faqs_rels" ADD CONSTRAINT "faqs_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."faqs"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "faqs_rels" ADD CONSTRAINT "faqs_rels_faqs_fk" FOREIGN KEY ("faqs_id") REFERENCES "public"."faqs"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "pages_blocks_faq_block_inline_f_a_qs_order_idx" ON "pages_blocks_faq_block_inline_f_a_qs" USING btree ("_order");
CREATE INDEX "pages_blocks_faq_block_inline_f_a_qs_parent_id_idx" ON "pages_blocks_faq_block_inline_f_a_qs" USING btree ("_parent_id");
CREATE UNIQUE INDEX "pages_blocks_faq_block_inline_f_a_qs_locales_locale_parent_i" ON "pages_blocks_faq_block_inline_f_a_qs_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "pages_blocks_faq_block_order_idx" ON "pages_blocks_faq_block" USING btree ("_order");
CREATE INDEX "pages_blocks_faq_block_parent_id_idx" ON "pages_blocks_faq_block" USING btree ("_parent_id");
CREATE INDEX "pages_blocks_faq_block_path_idx" ON "pages_blocks_faq_block" USING btree ("_path");
CREATE UNIQUE INDEX "pages_blocks_faq_block_locales_locale_parent_id_unique" ON "pages_blocks_faq_block_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "faqs_tenant_idx" ON "faqs" USING btree ("tenant_id");
CREATE INDEX "faqs_updated_at_idx" ON "faqs" USING btree ("updated_at");
CREATE INDEX "faqs_created_at_idx" ON "faqs" USING btree ("created_at");
CREATE UNIQUE INDEX "faqs_locales_locale_parent_id_unique" ON "faqs_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "faqs_rels_order_idx" ON "faqs_rels" USING btree ("order");
CREATE INDEX "faqs_rels_parent_idx" ON "faqs_rels" USING btree ("parent_id");
CREATE INDEX "faqs_rels_path_idx" ON "faqs_rels" USING btree ("path");
CREATE INDEX "faqs_rels_faqs_id_idx" ON "faqs_rels" USING btree ("faqs_id");
ALTER TABLE "pages_rels" ADD CONSTRAINT "pages_rels_faqs_fk" FOREIGN KEY ("faqs_id") REFERENCES "public"."faqs"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_faqs_fk" FOREIGN KEY ("faqs_id") REFERENCES "public"."faqs"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "pages_rels_faqs_id_idx" ON "pages_rels" USING btree ("faqs_id");
CREATE INDEX "payload_locked_documents_rels_faqs_id_idx" ON "payload_locked_documents_rels" USING btree ("faqs_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "pages_blocks_faq_block_inline_f_a_qs" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "pages_blocks_faq_block_inline_f_a_qs_locales" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "pages_blocks_faq_block" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "pages_blocks_faq_block_locales" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "faqs" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "faqs_locales" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "faqs_rels" DISABLE ROW LEVEL SECURITY;
DROP TABLE "pages_blocks_faq_block_inline_f_a_qs" CASCADE;
DROP TABLE "pages_blocks_faq_block_inline_f_a_qs_locales" CASCADE;
DROP TABLE "pages_blocks_faq_block" CASCADE;
DROP TABLE "pages_blocks_faq_block_locales" CASCADE;
DROP TABLE "faqs" CASCADE;
DROP TABLE "faqs_locales" CASCADE;
DROP TABLE "faqs_rels" CASCADE;
ALTER TABLE "pages_rels" DROP CONSTRAINT "pages_rels_faqs_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_faqs_fk";
DROP INDEX "pages_rels_faqs_id_idx";
DROP INDEX "payload_locked_documents_rels_faqs_id_idx";
ALTER TABLE "pages_rels" DROP COLUMN "faqs_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "faqs_id";
DROP TYPE "public"."enum_pages_blocks_faq_block_source_mode";
DROP TYPE "public"."enum_pages_blocks_faq_block_display_mode";
DROP TYPE "public"."enum_pages_blocks_faq_block_layout";
DROP TYPE "public"."enum_pages_blocks_faq_block_columns";
DROP TYPE "public"."enum_pages_blocks_faq_block_background_color";`)
}

View file

@ -4,6 +4,7 @@ import * as migration_20251206_071552_portfolio_collections from './20251206_071
import * as migration_20251206_134750_tenant_email_config from './20251206_134750_tenant_email_config';
import * as migration_20251206_141403_email_logs_collection from './20251206_141403_email_logs_collection';
import * as migration_20251207_205727_audit_logs_collection from './20251207_205727_audit_logs_collection';
import * as migration_20251210_052757_add_faqs_collection from './20251210_052757_add_faqs_collection';
export const migrations = [
{
@ -34,6 +35,11 @@ export const migrations = [
{
up: migration_20251207_205727_audit_logs_collection.up,
down: migration_20251207_205727_audit_logs_collection.down,
name: '20251207_205727_audit_logs_collection'
name: '20251207_205727_audit_logs_collection',
},
{
up: migration_20251210_052757_add_faqs_collection.up,
down: migration_20251210_052757_add_faqs_collection.down,
name: '20251210_052757_add_faqs_collection'
},
];

View file

@ -23,6 +23,7 @@ import { Posts } from './collections/Posts'
import { Categories } from './collections/Categories'
import { SocialLinks } from './collections/SocialLinks'
import { Testimonials } from './collections/Testimonials'
import { FAQs } from './collections/FAQs'
import { NewsletterSubscribers } from './collections/NewsletterSubscribers'
// Portfolio Collections
@ -128,6 +129,7 @@ export default buildConfig({
Categories,
SocialLinks,
Testimonials,
FAQs,
NewsletterSubscribers,
// Portfolio
PortfolioCategories,
@ -169,6 +171,7 @@ export default buildConfig({
// Type assertion für neue Collections bis payload-types.ts regeneriert wird
...({
testimonials: {},
faqs: {},
'newsletter-subscribers': {},
// Portfolio Collections
'portfolio-categories': {},