diff --git a/docs/plans/2026-02-27-sensualmoment-frontend.md b/docs/plans/2026-02-27-sensualmoment-frontend.md new file mode 100644 index 0000000..e95ee3d --- /dev/null +++ b/docs/plans/2026-02-27-sensualmoment-frontend.md @@ -0,0 +1,138 @@ +# Sensualmoment.de - Full-Stack Setup Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Set up the complete CMS content (seed script) and Next.js frontend for sensualmoment.de (Boudoir Photography website, Tenant 13). + +**Architecture:** Payload CMS multi-tenant backend provides all content via REST API. Next.js 16 + Tailwind v4 frontend fetches from `pl.porwoll.tech` (staging) using `@c2s/payload-contracts`. The frontend is a single-page-feel site with alternating dark/light sections, luxury boudoir aesthetic. CMS seed populates all content into existing collections (no new collections needed). + +**Tech Stack:** Payload CMS 3.77, Next.js 16, Tailwind CSS v4, TypeScript, payload-contracts, Google Fonts (Playfair Display, Cormorant Garamond, Josefin Sans) + +**Design Reference:** `docs/sensualmoments/` contains the full design briefing, HTML prototype, and color scheme. + +**Color Palette:** +- Dark Wine: `#2A1520` (dark sections, hero, nav) +- Blush Nude: `#D4A9A0` (accent, buttons, links) +- Bordeaux: `#8B3A4A` (headlines on light, hover) +- Deep Navy: `#151B2B` (packages section, footer) +- Creme: `#F8F4F0` (light sections) +- Espresso: `#3D2F30` (body text on light) + +**Fonts:** +- Playfair Display (headlines) +- Cormorant Garamond (body/prose) +- Josefin Sans (UI/labels/nav) + +--- + +## Phase 1: CMS Seed Script (sv-payload) + +### Task 1: Create the seed script skeleton + +**Files:** +- Create: `scripts/seed-sensualmoment.ts` + +**Step 1:** Create the seed script with imports, helpers, and main function following the established pattern from `scripts/seed-zweitmeinung.ts`. + +Key content to seed: +- 1 site-settings doc (contact info, SEO defaults) +- 4 social-links (Instagram, Pinterest, Facebook, WhatsApp) +- 3 testimonials +- 12+ FAQs across 4 categories +- 1 navigation (mainMenu + footerMenu) +- 1 contact form (via forms plugin) +- 7+ pages with block layouts: + - `home` (hero-block, image-text-block, image-slider-block, testimonials-block, pricing-block, posts-list-block, contact-form-block, cta-block) + - `ueber-mich` (hero-block, text-block, card-grid-block, cta-block) + - `galerie` (hero-block, image-slider-block) + - `pakete` (hero-block, pricing-block, faq-block, cta-block) + - `journal` (hero-block, posts-list-block) + - `kontakt` (hero-block, contact-form-block) + - `faq` (hero-block, faq-block) + - `impressum` (text-block) + - `datenschutz` (text-block) + - `agb` (text-block) + +**Step 2:** Run the seed: `npx tsx scripts/seed-sensualmoment.ts` + +**Step 3:** Verify in admin panel at pl.porwoll.tech/admin that content appears under tenant sensualmoment. + +**Step 4:** Commit +```bash +git add scripts/seed-sensualmoment.ts +git commit -m "feat: add seed script for sensualmoment.de (tenant 13)" +``` + +--- + +## Phase 2: Frontend Setup (sv-frontend via Codex CLI) + +### Task 2: Configure the Next.js project foundation + +The frontend repo exists at `~/frontend.sensualmoment.de/` on sv-frontend with a bare Next.js 16 scaffold. + +**Codex CLI tasks to delegate (all run on sv-frontend):** + +#### Task 2a: Project configuration +- Install `@c2s/payload-contracts` (git dependency) +- Add `transpilePackages: ["@c2s/payload-contracts"]` to next.config.ts +- Configure Google Fonts (Playfair Display, Cormorant Garamond, Josefin Sans) via next/font/google +- Set up Tailwind v4 CSS with the color palette as CSS custom properties +- Create `.env.local` with `NEXT_PUBLIC_CMS_URL=https://pl.porwoll.tech` and `NEXT_PUBLIC_TENANT_ID=13` +- Create `server.js` (CommonJS, for Plesk Passenger deployment) +- Add `pnpm-workspace.yaml` with `onlyBuiltDependencies` + +#### Task 2b: Layout & shared components +- `src/app/layout.tsx` - Root layout with fonts, metadata, Navigation + Footer +- `src/components/Navigation.tsx` - Fixed nav with scroll effect (transparent -> Dark Wine) +- `src/components/Footer.tsx` - Deep Navy footer with 4 columns +- `src/components/Logo.tsx` - Wordmark logo (Sensual / Moment / Photography) +- `src/lib/api.ts` - CMS API client (fetch from payload-contracts or direct fetch) + +#### Task 2c: Homepage sections +- `src/app/page.tsx` - Homepage with all 7 sections +- `src/components/sections/Hero.tsx` - Fullscreen hero with gradient overlays +- `src/components/sections/AboutPreview.tsx` - 2-column about section +- `src/components/sections/GalleryPreview.tsx` - Asymmetric 4-column grid +- `src/components/sections/Testimonials.tsx` - 3-card testimonial grid +- `src/components/sections/Packages.tsx` - 3-tier pricing cards (navy bg) +- `src/components/sections/BlogPreview.tsx` - 3-card blog grid +- `src/components/sections/Contact.tsx` - 2-column contact + form + +#### Task 2d: Inner pages +- `src/app/ueber-mich/page.tsx` +- `src/app/galerie/page.tsx` - Filterable masonry grid +- `src/app/pakete/page.tsx` - Full pricing + FAQ accordion +- `src/app/journal/page.tsx` - Blog listing with pagination +- `src/app/journal/[slug]/page.tsx` - Blog detail +- `src/app/kontakt/page.tsx` +- `src/app/faq/page.tsx` - Grouped FAQ accordion +- `src/app/impressum/page.tsx` +- `src/app/datenschutz/page.tsx` +- `src/app/agb/page.tsx` + +#### Task 2e: Animations & responsive +- Scroll-reveal IntersectionObserver component +- Nav scroll transition logic +- Mobile responsive (burger menu at <=900px) +- All responsive breakpoints from prototype + +--- + +## Phase 3: Deployment Preparation + +### Task 3: Plesk + CI/CD setup +- Verify git repo on Hetzner 1 (Plesk) is configured +- Set up webhook for auto-deploy +- Configure PM2 on sv-frontend for staging (port TBD) +- Create `.github/workflows/deploy-staging.yml` + +--- + +## Execution Strategy + +**Phase 1** (Seed Script): Execute directly on sv-payload - Claude writes the seed script. + +**Phase 2** (Frontend): Delegate to Codex CLI on sv-frontend with detailed prompts. Can run in parallel sessions per task group. + +**Phase 3** (Deploy): Manual setup after frontend is ready. diff --git a/scripts/seed-sensualmoment.ts b/scripts/seed-sensualmoment.ts new file mode 100644 index 0000000..6148b1e --- /dev/null +++ b/scripts/seed-sensualmoment.ts @@ -0,0 +1,1147 @@ +/** + * Sensualmoment (Tenant 13) Seed Script + * + * Populates tenant 13 with all content for sensualmoment.de: + * - Site Settings + * - Social Links (4) + * - Testimonials (3) + * - FAQs (12) + * - Navigation (mainMenu + footerMenu) + * - Contact Form + * - Pages (10: home, ueber-mich, galerie, pakete, journal, kontakt, faq, impressum, datenschutz, agb) + * + * Run with: npx tsx scripts/seed-sensualmoment.ts + */ + +import { getPayload } from 'payload' +import config from '../src/payload.config' + +const TENANT_ID = 13 + +// ── Rich Text Helpers (Lexical format) ────────────────────── + +function createRichText(content: string | string[]): object { + const paragraphs = Array.isArray(content) ? content : [content] + return { + root: { + type: 'root', + children: paragraphs.map((text) => ({ + type: 'paragraph', + children: [{ type: 'text', text }], + })), + direction: 'ltr', + format: '', + indent: 0, + version: 1, + }, + } +} + +function createRichTextComplex( + blocks: Array< + | { type: 'heading'; tag: string; text: string } + | { type: 'paragraph'; text: string } + | { type: 'paragraph-bold'; text: string } + | { type: 'paragraph-italic'; text: string } + | { type: 'bullets'; items: string[] } + >, +): object { + return { + root: { + type: 'root', + children: blocks.map((block) => { + if (block.type === 'heading') { + return { + type: 'heading', + tag: block.tag, + children: [{ type: 'text', text: block.text }], + } + } + if (block.type === 'paragraph-bold') { + return { + type: 'paragraph', + children: [{ type: 'text', text: block.text, format: 1 }], + } + } + if (block.type === 'paragraph-italic') { + return { + type: 'paragraph', + children: [{ type: 'text', text: block.text, format: 2 }], + } + } + if (block.type === 'bullets') { + return { + type: 'list', + listType: 'bullet', + children: block.items.map((item) => ({ + type: 'listitem', + children: [{ type: 'text', text: item }], + })), + } + } + return { + type: 'paragraph', + children: [{ type: 'text', text: block.text }], + } + }), + direction: 'ltr', + format: '', + indent: 0, + version: 1, + }, + } +} + +// ── Helper: Upsert ────────────────────────────────────────── + +async function upsert( + payload: any, + collection: string, + where: Record, + data: Record, +): Promise { + const existing = await payload.find({ + collection, + where: { ...where, tenant: { equals: TENANT_ID } }, + limit: 1, + }) + + if (existing.docs.length > 0) { + const doc = await payload.update({ + collection, + id: existing.docs[0].id, + data: { ...data, tenant: TENANT_ID }, + }) + return doc.id as number + } + + const doc = await payload.create({ + collection, + data: { ...data, tenant: TENANT_ID }, + }) + return doc.id as number +} + +// Lightweight page upsert using direct SQL check to avoid the massive 40+ block join query +async function upsertPage( + payload: any, + slug: string, + data: Record, +): Promise { + const pool = (payload as any).db?.pool + if (!pool) throw new Error('Cannot access DB pool') + + // Direct SQL with parameterized query + const result = await pool.query( + 'SELECT p.id FROM pages p JOIN pages_locales pl ON pl."_parent_id" = p.id WHERE pl.slug = $1 AND p.tenant_id = $2 LIMIT 1', + [slug, TENANT_ID], + ) + + const existingId = result?.rows?.[0]?.id + + if (existingId) { + const doc = await payload.update({ + collection: 'pages', + id: existingId, + data: { ...data, tenant: TENANT_ID }, + }) + return doc.id as number + } + + const doc = await payload.create({ + collection: 'pages', + data: { ...data, tenant: TENANT_ID }, + }) + return doc.id as number +} + +// ── Main Seed Function ────────────────────────────────────── + +async function seed() { + console.log('🚀 Starting sensualmoment.de (Tenant 13) Seed...\n') + + const payload = await getPayload({ config }) + + // Verify tenant exists + const tenant = await payload.findByID({ collection: 'tenants', id: TENANT_ID }) + if (!tenant) { + console.error(`❌ Tenant ${TENANT_ID} not found!`) + process.exit(1) + } + console.log(`✓ Tenant "${tenant.name}" (ID: ${TENANT_ID}) found\n`) + + // ════════════════════════════════════════════ + // 1. SITE SETTINGS + // ════════════════════════════════════════════ + console.log('--- 1. Site Settings ---') + + await upsert(payload, 'site-settings', {}, { + siteName: 'Sensual Moment Photography', + siteTagline: 'Boudoir Photography · Dein Moment der Selbstliebe', + contact: { + email: 'hello@sensualmoment.de', + phone: '+49 123 456 78 90', + }, + address: { + street: 'Musterstraße 12', + zip: '12345', + city: 'Stadt', + state: 'Nordrhein-Westfalen', + country: 'Deutschland', + }, + footer: { + copyrightText: '© 2026 Sensual Moment Photography. Alle Rechte vorbehalten.', + showSocialLinks: true, + }, + seo: { + defaultMetaTitle: 'Sensual Moment – Boudoir Photography', + defaultMetaDescription: + 'Dein Boudoir-Shooting als Akt der Selbstliebe. Professionelle Boudoir-Fotografie in geschützter Atmosphäre. Empowerment statt Sexualisierung.', + }, + }) + console.log('✓ Site Settings created/updated') + + // ════════════════════════════════════════════ + // 2. SOCIAL LINKS + // ════════════════════════════════════════════ + console.log('\n--- 2. Social Links ---') + + const socialLinks = [ + { platform: 'instagram', url: 'https://instagram.com/sensualmoment.de', isActive: true }, + { platform: 'facebook', url: 'https://facebook.com/sensualmoment.de', isActive: true }, + ] + + for (const link of socialLinks) { + await upsert(payload, 'social-links', { platform: { equals: link.platform } }, link) + console.log(`✓ Social Link "${link.platform}"`) + } + + // ════════════════════════════════════════════ + // 3. TESTIMONIALS + // ════════════════════════════════════════════ + console.log('\n--- 3. Testimonials ---') + + const testimonials = [ + { + quote: + 'Ich habe mich noch nie so schön gefühlt. Die Atmosphäre war so vertrauensvoll, dass ich komplett loslassen konnte. Die Bilder sind unfassbar.', + author: 'Sandra, 42', + role: 'Kundin', + rating: 5, + isActive: true, + order: 1, + }, + { + quote: + 'Anfangs war ich nervös, aber nach fünf Minuten fühlte es sich an wie ein Abend mit einer guten Freundin. Die Ergebnisse haben mich zu Tränen gerührt.', + author: 'Katrin, 38', + role: 'Kundin', + rating: 5, + isActive: true, + order: 2, + }, + { + quote: + 'Ein Geschenk an mich selbst, das ich jeder Frau empfehlen würde. Professionell, einfühlsam und mit unglaublichem Blick für Details.', + author: 'Maria, 51', + role: 'Kundin', + rating: 5, + isActive: true, + order: 3, + }, + ] + + for (const t of testimonials) { + await upsert(payload, 'testimonials', { author: { equals: t.author } }, t) + console.log(`✓ Testimonial "${t.author}"`) + } + + // ════════════════════════════════════════════ + // 4. FAQS + // ════════════════════════════════════════════ + console.log('\n--- 4. FAQs ---') + + const faqs = [ + // Vor dem Shooting + { + question: 'Wie läuft ein Boudoir-Shooting ab?', + answer: createRichText([ + 'Wir starten mit einem kostenlosen Vorgespräch, in dem wir deine Wünsche und Vorstellungen besprechen. Am Tag des Shootings wirst du professionell gestylt und dann entspannt in verschiedenen Posen fotografiert.', + 'Die Atmosphäre ist immer vertrauensvoll und respektvoll. Du bestimmst jederzeit, wie weit du gehen möchtest. Nach dem Shooting wählst du deine Lieblingsbilder in einer persönlichen Bildauswahl-Session.', + ]), + category: 'vor-dem-shooting', + order: 1, + isActive: true, + }, + { + question: 'Was soll ich zum Shooting mitbringen?', + answer: createRichText([ + 'Bring 3–5 Outfits mit, die dir ein gutes Gefühl geben: Dessous, ein übergroßes Hemd, ein schönes Kleid oder auch nur ein Tuch. Weniger ist oft mehr.', + 'Außerdem empfehle ich bequeme Schuhe für die Anreise, deine Lieblingsmusik und gute Laune. Alles andere – von Styling bis Requisiten – ist vor Ort vorhanden.', + ]), + category: 'vor-dem-shooting', + order: 2, + isActive: true, + }, + { + question: 'Muss ich Model-Erfahrung haben?', + answer: createRichText( + 'Absolut nicht! Die allermeisten meiner Kundinnen stehen zum ersten Mal vor einer professionellen Kamera. Ich leite dich durch jede Pose und sorge dafür, dass du dich wohl fühlst. Es geht nicht um perfekte Posen, sondern um echte Momente.', + ), + category: 'vor-dem-shooting', + order: 3, + isActive: true, + }, + // Während des Shootings + { + question: 'Wie lange dauert ein Shooting?', + answer: createRichText( + 'Je nach Paket dauert das Shooting zwischen 60 Minuten und einem halben Tag. Ich nehme mir immer genug Zeit, damit du ankommen und dich entspannen kannst. Wir arbeiten ohne Zeitdruck.', + ), + category: 'waehrend-des-shootings', + order: 4, + isActive: true, + }, + { + question: 'Kann ich eine Begleitperson mitbringen?', + answer: createRichText( + 'Natürlich! Viele Kundinnen bringen ihre beste Freundin oder Partnerin mit. Wichtig ist, dass du dich wohl fühlst. In meinem Studio ist auch ein separater Wartebereich vorhanden.', + ), + category: 'waehrend-des-shootings', + order: 5, + isActive: true, + }, + { + question: 'Was ist, wenn ich mich unwohl fühle?', + answer: createRichText( + 'Dein Wohlbefinden hat absolute Priorität. Wir machen jederzeit Pausen, du kannst jede Pose ablehnen und wir besprechen vorher genau, was für dich okay ist und was nicht. Du hast immer die volle Kontrolle.', + ), + category: 'waehrend-des-shootings', + order: 6, + isActive: true, + }, + // Nachher + { + question: 'Wann bekomme ich meine Bilder?', + answer: createRichText( + 'Innerhalb von 2–3 Wochen nach dem Shooting lade ich deine fertig bearbeiteten Bilder in deine private Online-Galerie hoch. Dort kannst du in Ruhe deine Favoriten auswählen und Abzüge oder ein Fotobuch bestellen.', + ), + category: 'nachher', + order: 7, + isActive: true, + }, + { + question: 'Werden die Bilder retuschiert?', + answer: createRichText( + 'Jedes Bild wird professionell bearbeitet: Licht, Farbe und Kontraste werden optimiert. Auf Wunsch entferne ich temporäre Hautunreinheiten. Ich stehe für eine natürliche Bearbeitung – du sollst dich auf den Bildern wiedererkennen, nicht wie eine andere Person aussehen.', + ), + category: 'nachher', + order: 8, + isActive: true, + }, + { + question: 'Dürfen die Bilder veröffentlicht werden?', + answer: createRichText( + 'Niemals ohne deine ausdrückliche, schriftliche Einwilligung. Deine Bilder sind privat und bleiben es auch. Wenn du möchtest, dass ausgewählte Bilder in meinem Portfolio erscheinen, besprechen wir das individuell.', + ), + category: 'nachher', + order: 9, + isActive: true, + }, + // Allgemein + { + question: 'Was kostet ein Boudoir-Shooting?', + answer: createRichText([ + 'Meine Pakete starten ab 299 € (Entdecken), über 499 € (Erleben – das beliebteste Paket) bis 799 € (Zelebrieren – der halbe Tag).', + 'Jedes Paket beinhaltet Styling-Beratung, professionelle Bearbeitung und eine private Online-Galerie. Details findest du auf meiner Pakete-Seite.', + ]), + category: 'allgemein', + order: 10, + isActive: true, + }, + { + question: 'Gibt es Geschenkgutscheine?', + answer: createRichText( + 'Ja! Ein Boudoir-Shooting ist ein wundervolles Geschenk. Ich erstelle dir einen personalisierten Geschenkgutschein für jedes Paket. Schreib mir einfach eine Nachricht.', + ), + category: 'allgemein', + order: 11, + isActive: true, + }, + { + question: 'Wo findet das Shooting statt?', + answer: createRichText( + 'Die Shootings finden in meinem privaten Studio statt – ein geschützter, warmer Raum, der speziell für Boudoir-Fotografie eingerichtet ist. Auf Wunsch sind auch Outdoor- oder Hotel-Shootings möglich (Aufpreis nach Absprache).', + ), + category: 'allgemein', + order: 12, + isActive: true, + }, + ] + + for (const faq of faqs) { + await upsert(payload, 'faqs', { question: { equals: faq.question } }, faq) + console.log(`✓ FAQ "${faq.question.substring(0, 40)}..."`) + } + + // ════════════════════════════════════════════ + // 5. NAVIGATION + // ════════════════════════════════════════════ + console.log('\n--- 5. Navigation ---') + + await upsert(payload, 'navigations', {}, { + title: 'Hauptnavigation', + mainMenu: [ + { label: 'Über mich', type: 'custom', url: '/ueber-mich' }, + { label: 'Galerie', type: 'custom', url: '/galerie' }, + { label: 'Pakete', type: 'custom', url: '/pakete' }, + { label: 'Journal', type: 'custom', url: '/journal' }, + { label: 'FAQ', type: 'custom', url: '/faq' }, + { label: 'Kontakt', type: 'custom', url: '/kontakt' }, + ], + footerMenu: [ + { label: 'Impressum', linkType: 'custom', url: '/impressum' }, + { label: 'Datenschutz', linkType: 'custom', url: '/datenschutz' }, + { label: 'AGB', linkType: 'custom', url: '/agb' }, + ], + }) + console.log('✓ Navigation created/updated') + + // ════════════════════════════════════════════ + // 6. CONTACT FORM + // ════════════════════════════════════════════ + console.log('\n--- 6. Contact Form ---') + + const existingForm = await payload.find({ + collection: 'forms', + where: { tenant: { equals: TENANT_ID } }, + limit: 1, + }) + + let contactFormId: number | null = null + + if (existingForm.docs.length > 0) { + contactFormId = existingForm.docs[0].id as number + console.log(`✓ Contact form already exists (ID: ${contactFormId})`) + } else { + const form = await payload.create({ + collection: 'forms', + data: { + title: 'Kontaktformular', + tenant: TENANT_ID, + fields: [ + { + blockType: 'text', + name: 'name', + label: 'Name', + required: true, + width: 50, + }, + { + blockType: 'email', + name: 'email', + label: 'E-Mail', + required: true, + width: 50, + }, + { + blockType: 'text', + name: 'phone', + label: 'Telefon (optional)', + required: false, + width: 50, + }, + { + blockType: 'select', + name: 'paket', + label: 'Welches Paket interessiert dich?', + required: false, + width: 50, + options: [ + { label: 'Entdecken (ab 299 €)', value: 'entdecken' }, + { label: 'Erleben (ab 499 €)', value: 'erleben' }, + { label: 'Zelebrieren (ab 799 €)', value: 'zelebrieren' }, + { label: 'Noch unsicher', value: 'unsicher' }, + ], + }, + { + blockType: 'textarea', + name: 'message', + label: 'Deine Nachricht', + required: true, + width: 100, + }, + ], + submitButtonLabel: 'Nachricht senden', + confirmationType: 'message', + confirmationMessage: createRichText( + 'Vielen Dank für deine Nachricht! Ich melde mich innerhalb von 24 Stunden bei dir. Ich freue mich auf unser Kennenlernen.', + ), + } as any, + }) + contactFormId = form.id as number + console.log(`✓ Contact form created (ID: ${contactFormId})`) + } + + // ════════════════════════════════════════════ + // 7. PAGES + // ════════════════════════════════════════════ + console.log('\n--- 7. Pages ---') + + const pages = [ + // ── Home ── + { + title: 'Startseite', + slug: 'home', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Sensual Moment', + subline: 'Boudoir Photography · Dein Moment der Selbstliebe', + alignment: 'center', + overlay: true, + cta: { + text: 'Dein Shooting buchen', + link: '/kontakt', + style: 'primary', + }, + }, + { + blockType: 'text-block', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Jede Frau verdient es, sich selbst zu feiern' }, + { + type: 'paragraph', + text: 'Ich glaube daran, dass wahre Schönheit nicht inszeniert werden muss – sie muss nur sichtbar gemacht werden. In meinem Studio schaffe ich einen geschützten Raum, in dem du dich fallen lassen kannst.', + }, + { + type: 'paragraph', + text: 'Mit einfühlsamer Anleitung und einem Blick für das Besondere entstehen Bilder, die deine Stärke, Sinnlichkeit und Einzigartigkeit einfangen – authentisch und mit Respekt.', + }, + { + type: 'paragraph', + text: 'Kein Shooting gleicht dem anderen, denn keine Frau gleicht der anderen.', + }, + { + type: 'paragraph-italic', + text: '— Dein Name', + }, + ]), + }, + { + blockType: 'testimonials-block', + title: 'Was meine Kundinnen sagen', + subtitle: 'Erfahrungen', + displayMode: 'all', + layout: 'grid', + columns: '3', + showRating: false, + showQuoteIcon: true, + backgroundColor: 'light', + }, + { + blockType: 'pricing', + title: 'Pakete & Preise', + subtitle: 'Investition in dich', + pricingType: 'one-time', + currency: '€', + showCurrencyBefore: false, + layout: 'cards', + plans: [ + { + name: 'Entdecken', + price: 299, + priceLabel: 'Ab 299 €', + description: 'Der perfekte Einstieg in die Welt der Boudoir-Fotografie.', + featured: false, + features: [ + { text: '60 Min. Shooting', included: true }, + { text: 'Styling-Beratung vorab', included: true }, + { text: '10 bearbeitete Bilder', included: true }, + { text: 'Private Online-Galerie', included: true }, + { text: '5 Feinabzüge 13×18', included: true }, + ], + cta: { text: 'Anfragen', link: '/kontakt' }, + }, + { + name: 'Erleben', + price: 499, + priceLabel: 'Ab 499 €', + description: 'Das beliebteste Paket für ein unvergessliches Erlebnis.', + featured: true, + badge: 'Beliebtestes Paket', + features: [ + { text: '120 Min. Shooting', included: true }, + { text: 'Professionelles Styling', included: true }, + { text: '25 bearbeitete Bilder', included: true }, + { text: 'Private Online-Galerie', included: true }, + { text: 'Hochwertiges Fotobuch', included: true }, + { text: 'Alle digitalen Dateien', included: true }, + ], + cta: { text: 'Anfragen', link: '/kontakt' }, + }, + { + name: 'Zelebrieren', + price: 799, + priceLabel: 'Ab 799 €', + description: 'Das ultimative Erlebnis für besondere Momente.', + featured: false, + features: [ + { text: 'Halber Tag (4 Std.)', included: true }, + { text: 'Styling + Visagistin', included: true }, + { text: 'Alle bearbeiteten Bilder', included: true }, + { text: 'Luxus-Leinenalbum', included: true }, + { text: '3 Wandbilder nach Wahl', included: true }, + { text: 'Behind-the-Scenes Video', included: true }, + ], + cta: { text: 'Anfragen', link: '/kontakt' }, + }, + ], + style: { + bg: 'dark', + cardStyle: 'bordered', + }, + }, + { + blockType: 'cta-block', + headline: 'Bereit für deinen Moment?', + description: + 'Schreib mir unverbindlich – ich melde mich innerhalb von 24 Stunden bei dir. Gemeinsam besprechen wir in einem kostenlosen Vorgespräch, wie dein perfektes Shooting aussehen kann.', + backgroundColor: 'accent', + buttons: [ + { + text: 'Jetzt Shooting buchen', + link: '/kontakt', + style: 'primary', + }, + ], + }, + ], + seo: { + metaTitle: 'Sensual Moment – Boudoir Photography · Dein Moment der Selbstliebe', + metaDescription: + 'Professionelle Boudoir-Fotografie in geschützter Atmosphäre. Empowerment statt Sexualisierung. Jetzt dein Shooting buchen.', + }, + }, + + // ── Über mich ── + { + title: 'Über mich', + slug: 'ueber-mich', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Über mich', + subline: 'Die Frau hinter der Kamera', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Meine Geschichte' }, + { + type: 'paragraph', + text: 'Mein Name ist [Dein Name] und ich bin leidenschaftliche Boudoir-Fotografin. Was als persönliches Projekt begann, ist heute meine Berufung: Frauen dabei zu helfen, sich in ihrer Haut wohlzufühlen und ihre einzigartige Schönheit zu entdecken.', + }, + { + type: 'paragraph', + text: 'Jedes Shooting ist für mich eine Begegnung auf Augenhöhe. Ich schaffe einen Raum des Vertrauens, in dem du dich fallen lassen kannst – ohne Druck, ohne Erwartungen, ganz in deinem Tempo.', + }, + { + type: 'paragraph', + text: 'Boudoir-Fotografie ist für mich kein Genre – es ist eine Haltung. Es geht darum, die Stärke, Verletzlichkeit und Sinnlichkeit jeder einzelnen Frau sichtbar zu machen. Authentisch, respektvoll und mit Liebe zum Detail.', + }, + ]), + }, + { + blockType: 'card-grid-block', + headline: 'Meine Werte', + columns: '3', + cards: [ + { + mediaType: 'icon', + icon: 'heart', + iconPosition: 'top', + title: 'Empathie', + description: 'Ich begegne jeder Kundin mit Wärme und Einfühlungsvermögen. Dein Wohlbefinden steht immer an erster Stelle.', + }, + { + mediaType: 'icon', + icon: 'shield', + iconPosition: 'top', + title: 'Vertrauen', + description: 'Was im Studio passiert, bleibt im Studio. Deine Bilder werden niemals ohne deine ausdrückliche Zustimmung geteilt.', + }, + { + mediaType: 'icon', + icon: 'star', + iconPosition: 'top', + title: 'Authentizität', + description: 'Keine übertriebene Retusche, keine aufgesetzten Posen. Ich zeige dich, wie du wirklich bist – und das ist wunderschön.', + }, + ], + }, + { + blockType: 'cta-block', + headline: 'Lass uns kennenlernen', + description: 'In einem kostenlosen Vorgespräch erzähle ich dir mehr über meine Arbeit und wir besprechen deine Wünsche.', + backgroundColor: 'dark', + buttons: [ + { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + ], + }, + ], + seo: { + metaTitle: 'Über mich – Sensual Moment Photography', + metaDescription: + 'Lerne die Fotografin hinter Sensual Moment kennen. Professionelle Boudoir-Fotografie mit Herz, Empathie und einem Blick für das Besondere.', + }, + }, + + // ── Galerie ── + { + title: 'Galerie', + slug: 'galerie', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Portfolio', + subline: 'Momente der Selbstliebe – ausgewählte Arbeiten mit Einverständnis meiner Kundinnen', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + content: createRichTextComplex([ + { + type: 'paragraph', + text: 'Jedes Bild erzählt eine Geschichte von Mut, Verletzlichkeit und Stärke. Hier findest du eine Auswahl meiner Arbeiten in verschiedenen Stilen – von klassisch elegant bis künstlerisch dramatisch.', + }, + { + type: 'paragraph', + text: 'Alle Bilder werden ausschließlich mit dem ausdrücklichen Einverständnis meiner Kundinnen gezeigt.', + }, + ]), + }, + { + blockType: 'cta-block', + headline: 'Möchtest du auch so strahlen?', + description: 'Jede Frau hat ihre eigene Geschichte. Lass uns gemeinsam deine erzählen.', + backgroundColor: 'accent', + buttons: [ + { + text: 'Shooting buchen', + link: '/kontakt', + style: 'primary', + }, + ], + }, + ], + seo: { + metaTitle: 'Galerie – Sensual Moment Boudoir Portfolio', + metaDescription: + 'Entdecke ausgewählte Boudoir-Fotografien: klassisch, artistisch, elegant, natürlich. Jedes Bild erzählt eine Geschichte von Stärke und Sinnlichkeit.', + }, + }, + + // ── Pakete & Preise ── + { + title: 'Pakete & Preise', + slug: 'pakete', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Investition in dich', + subline: 'Wähle das Paket, das zu dir passt – jedes Shooting ist ein Geschenk an dich selbst.', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + content: createRichText( + 'Jedes Paket ist so gestaltet, dass du dich von Anfang an wohlfühlst. Von der Styling-Beratung vorab bis zur fertigen Online-Galerie – alles ist inklusive. Du musst dich um nichts kümmern, außer dich auf deinen Moment zu freuen.', + ), + }, + { + blockType: 'pricing', + title: 'Meine Pakete', + pricingType: 'one-time', + currency: '€', + showCurrencyBefore: false, + layout: 'cards', + plans: [ + { + name: 'Entdecken', + price: 299, + priceLabel: 'Ab 299 €', + description: 'Der perfekte Einstieg.', + featured: false, + features: [ + { text: '60 Min. Shooting', included: true }, + { text: 'Styling-Beratung vorab', included: true }, + { text: '10 bearbeitete Bilder', included: true }, + { text: 'Private Online-Galerie', included: true }, + { text: '5 Feinabzüge 13×18', included: true }, + ], + cta: { text: 'Anfragen', link: '/kontakt' }, + }, + { + name: 'Erleben', + price: 499, + priceLabel: 'Ab 499 €', + description: 'Das beliebteste Paket.', + featured: true, + badge: 'Beliebtestes Paket', + features: [ + { text: '120 Min. Shooting', included: true }, + { text: 'Professionelles Styling', included: true }, + { text: '25 bearbeitete Bilder', included: true }, + { text: 'Private Online-Galerie', included: true }, + { text: 'Hochwertiges Fotobuch', included: true }, + { text: 'Alle digitalen Dateien', included: true }, + ], + cta: { text: 'Anfragen', link: '/kontakt' }, + }, + { + name: 'Zelebrieren', + price: 799, + priceLabel: 'Ab 799 €', + description: 'Das ultimative Erlebnis.', + featured: false, + features: [ + { text: 'Halber Tag (4 Std.)', included: true }, + { text: 'Styling + Visagistin', included: true }, + { text: 'Alle bearbeiteten Bilder', included: true }, + { text: 'Luxus-Leinenalbum', included: true }, + { text: '3 Wandbilder nach Wahl', included: true }, + { text: 'Behind-the-Scenes Video', included: true }, + ], + cta: { text: 'Anfragen', link: '/kontakt' }, + }, + ], + style: { + bg: 'dark', + cardStyle: 'bordered', + }, + }, + { + blockType: 'faq-block', + title: 'Häufige Fragen zu Paketen & Preisen', + sourceMode: 'collection', + displayMode: 'category', + category: 'allgemein', + layout: 'accordion', + expandFirst: true, + allowMultipleOpen: false, + enableSchemaOrg: true, + backgroundColor: 'light', + }, + { + blockType: 'cta-block', + headline: 'Noch Fragen?', + description: 'Jedes Shooting ist individuell. Schreib mir und wir finden gemeinsam das perfekte Paket für dich.', + backgroundColor: 'dark', + buttons: [ + { + text: 'Unverbindlich anfragen', + link: '/kontakt', + style: 'primary', + }, + ], + }, + ], + seo: { + metaTitle: 'Pakete & Preise – Boudoir-Shooting bei Sensual Moment', + metaDescription: + 'Boudoir-Shooting ab 299 €. Drei Pakete für jeden Anspruch: Entdecken, Erleben, Zelebrieren. Professionelles Styling und bearbeitete Bilder inklusive.', + }, + }, + + // ── Journal (Blog) ── + { + title: 'Journal', + slug: 'journal', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Journal', + subline: 'Gedanken & Geschichten rund um Boudoir, Selbstliebe und Empowerment', + alignment: 'center', + overlay: true, + }, + { + blockType: 'posts-list-block', + title: 'Aktuelle Beiträge', + displayMode: 'latest', + columns: '3', + showImage: true, + showDate: true, + showExcerpt: true, + showAuthor: false, + postsPerPage: 9, + layout: 'grid', + }, + ], + seo: { + metaTitle: 'Journal – Sensual Moment Blog', + metaDescription: + 'Tipps fürs Boudoir-Shooting, Behind-the-Scenes-Einblicke und Geschichten über Selbstliebe. Der Blog von Sensual Moment Photography.', + }, + }, + + // ── Kontakt ── + { + title: 'Kontakt', + slug: 'kontakt', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Kontakt', + subline: 'Bereit für deinen Moment? Schreib mir unverbindlich.', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + content: createRichTextComplex([ + { + type: 'paragraph', + text: 'Ich melde mich innerhalb von 24 Stunden bei dir. Gemeinsam besprechen wir in einem kostenlosen Vorgespräch, wie dein perfektes Shooting aussehen kann.', + }, + ]), + }, + ...(contactFormId + ? [ + { + blockType: 'contact-form-block', + form: contactFormId, + headline: 'Schreib mir', + }, + ] + : []), + { + blockType: 'text-block', + content: createRichTextComplex([ + { type: 'heading', tag: 'h3', text: 'Kontaktdaten' }, + { type: 'paragraph', text: 'E-Mail: hello@sensualmoment.de' }, + { type: 'paragraph', text: 'Telefon: +49 123 456 78 90' }, + { type: 'paragraph', text: 'Studio: Musterstraße 12, 12345 Stadt' }, + { type: 'heading', tag: 'h3', text: 'Social Media' }, + { type: 'paragraph', text: 'Instagram · Pinterest · Facebook' }, + ]), + }, + ], + seo: { + metaTitle: 'Kontakt – Sensual Moment Photography', + metaDescription: + 'Nimm Kontakt auf für dein persönliches Boudoir-Shooting. Kostenloses Vorgespräch, unverbindliche Anfrage. Ich freue mich auf dich.', + }, + }, + + // ── FAQ ── + { + title: 'FAQ – Häufige Fragen', + slug: 'faq', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Häufige Fragen', + subline: 'Alles, was du vor deinem Boudoir-Shooting wissen möchtest', + alignment: 'center', + overlay: true, + }, + { + blockType: 'faq-block', + title: 'Vor dem Shooting', + sourceMode: 'collection', + displayMode: 'category', + category: 'vor-dem-shooting', + layout: 'accordion', + expandFirst: true, + allowMultipleOpen: false, + enableSchemaOrg: true, + backgroundColor: 'white', + }, + { + blockType: 'faq-block', + title: 'Während des Shootings', + sourceMode: 'collection', + displayMode: 'category', + category: 'waehrend-des-shootings', + layout: 'accordion', + expandFirst: false, + enableSchemaOrg: true, + backgroundColor: 'light', + }, + { + blockType: 'faq-block', + title: 'Nach dem Shooting', + sourceMode: 'collection', + displayMode: 'category', + category: 'nachher', + layout: 'accordion', + expandFirst: false, + enableSchemaOrg: true, + backgroundColor: 'white', + }, + { + blockType: 'faq-block', + title: 'Allgemeines', + sourceMode: 'collection', + displayMode: 'category', + category: 'allgemein', + layout: 'accordion', + expandFirst: false, + enableSchemaOrg: true, + backgroundColor: 'light', + }, + { + blockType: 'cta-block', + headline: 'Deine Frage nicht dabei?', + description: 'Schreib mir – ich beantworte alle Fragen gerne persönlich.', + backgroundColor: 'dark', + buttons: [ + { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + ], + }, + ], + seo: { + metaTitle: 'FAQ – Häufige Fragen zum Boudoir-Shooting', + metaDescription: + 'Antworten auf alle Fragen rund um dein Boudoir-Shooting: Ablauf, Vorbereitung, Kosten, Bildbearbeitung und mehr.', + }, + }, + + // ── Impressum ── + { + title: 'Impressum', + slug: 'impressum', + status: 'published', + layout: [ + { + blockType: 'text-block', + content: createRichTextComplex([ + { type: 'heading', tag: 'h1', text: 'Impressum' }, + { type: 'heading', tag: 'h2', text: 'Angaben gemäß § 5 TMG' }, + { type: 'paragraph', text: '[Dein Name]' }, + { type: 'paragraph', text: 'Sensual Moment Photography' }, + { type: 'paragraph', text: 'Musterstraße 12' }, + { type: 'paragraph', text: '12345 Stadt' }, + { type: 'heading', tag: 'h2', text: 'Kontakt' }, + { type: 'paragraph', text: 'E-Mail: hello@sensualmoment.de' }, + { type: 'paragraph', text: 'Telefon: +49 123 456 78 90' }, + { type: 'heading', tag: 'h2', text: 'Umsatzsteuer-ID' }, + { type: 'paragraph', text: 'Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz: [Deine USt-IdNr.]' }, + { type: 'heading', tag: 'h2', text: 'Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV' }, + { type: 'paragraph', text: '[Dein Name], Musterstraße 12, 12345 Stadt' }, + ]), + }, + ], + seo: { + metaTitle: 'Impressum – Sensual Moment Photography', + metaDescription: 'Impressum und rechtliche Angaben von Sensual Moment Photography.', + }, + }, + + // ── Datenschutz ── + { + title: 'Datenschutz', + slug: 'datenschutz', + status: 'published', + layout: [ + { + blockType: 'text-block', + content: createRichTextComplex([ + { type: 'heading', tag: 'h1', text: 'Datenschutzerklärung' }, + { type: 'heading', tag: 'h2', text: '1. Datenschutz auf einen Blick' }, + { type: 'paragraph', text: 'Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen.' }, + { type: 'heading', tag: 'h2', text: '2. Verantwortliche Stelle' }, + { type: 'paragraph', text: '[Dein Name], Sensual Moment Photography, Musterstraße 12, 12345 Stadt. E-Mail: hello@sensualmoment.de' }, + { type: 'heading', tag: 'h2', text: '3. Datenerfassung auf dieser Website' }, + { type: 'paragraph', text: 'Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Kontaktdaten finden Sie im Impressum.' }, + { type: 'heading', tag: 'h2', text: '4. Ihre Rechte' }, + { type: 'paragraph', text: 'Sie haben jederzeit das Recht auf Auskunft, Berichtigung und Löschung Ihrer gespeicherten Daten. Wenden Sie sich dazu an die oben genannte verantwortliche Stelle.' }, + { type: 'paragraph', text: '[Vollständige Datenschutzerklärung wird vom Rechtsanwalt erstellt und hier eingefügt.]' }, + ]), + }, + ], + seo: { + metaTitle: 'Datenschutz – Sensual Moment Photography', + metaDescription: 'Datenschutzerklärung von Sensual Moment Photography.', + }, + }, + + // ── AGB ── + { + title: 'Allgemeine Geschäftsbedingungen', + slug: 'agb', + status: 'published', + layout: [ + { + blockType: 'text-block', + content: createRichTextComplex([ + { type: 'heading', tag: 'h1', text: 'Allgemeine Geschäftsbedingungen' }, + { type: 'heading', tag: 'h2', text: '§ 1 Geltungsbereich' }, + { type: 'paragraph', text: 'Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen Sensual Moment Photography ([Dein Name]) und ihren Kundinnen.' }, + { type: 'heading', tag: 'h2', text: '§ 2 Buchung und Vertragsschluss' }, + { type: 'paragraph', text: 'Eine Buchung kommt durch schriftliche Bestätigung (E-Mail) seitens der Fotografin zustande. Eine Anzahlung von 50% des Paketpreises ist innerhalb von 7 Tagen nach Buchungsbestätigung fällig.' }, + { type: 'heading', tag: 'h2', text: '§ 3 Stornierung' }, + { type: 'paragraph', text: 'Stornierungen sind bis 14 Tage vor dem Shooting kostenfrei möglich. Bei späterer Stornierung wird die Anzahlung einbehalten. Terminverschiebungen sind einmalig kostenfrei möglich.' }, + { type: 'heading', tag: 'h2', text: '§ 4 Bildrechte und Veröffentlichung' }, + { type: 'paragraph', text: 'Die Fotografin behält das Urheberrecht an allen Aufnahmen. Eine Veröffentlichung durch die Fotografin erfolgt ausschließlich mit schriftlicher Einwilligung der Kundin.' }, + { type: 'paragraph', text: '[Vollständige AGB werden vom Rechtsanwalt erstellt und hier eingefügt.]' }, + ]), + }, + ], + seo: { + metaTitle: 'AGB – Sensual Moment Photography', + metaDescription: 'Allgemeine Geschäftsbedingungen von Sensual Moment Photography.', + }, + }, + ] + + let pageCount = 0 + for (const page of pages) { + await upsertPage(payload, page.slug, page) + console.log(`✓ Page "${page.title}" (/${page.slug})`) + pageCount++ + } + + // ════════════════════════════════════════════ + // SUMMARY + // ════════════════════════════════════════════ + console.log('\n════════════════════════════════════════════') + console.log('✅ Sensualmoment.de Seed complete!') + console.log('════════════════════════════════════════════') + console.log(` - Site Settings: 1`) + console.log(` - Social Links: ${socialLinks.length}`) + console.log(` - Testimonials: ${testimonials.length}`) + console.log(` - FAQs: ${faqs.length}`) + console.log(` - Navigation: 1`) + console.log(` - Contact Form: 1 (ID: ${contactFormId})`) + console.log(` - Pages: ${pageCount}`) + console.log('') + + process.exit(0) +} + +seed().catch((err) => { + console.error('❌ Seed failed:', err) + process.exit(1) +}) diff --git a/src/app/(frontend)/api/posts/route.ts b/src/app/(frontend)/api/posts/route.ts index c0d5476..9604885 100644 --- a/src/app/(frontend)/api/posts/route.ts +++ b/src/app/(frontend)/api/posts/route.ts @@ -46,11 +46,27 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const category = searchParams.get('category')?.trim() const type = searchParams.get('type')?.trim() as 'blog' | 'news' | 'press' | 'announcement' | undefined - const tenantParam = searchParams.get('tenant') + const tenantParam = searchParams.get('tenant') || searchParams.get('where[tenant][equals]') const pageParam = searchParams.get('page') const limitParam = searchParams.get('limit') const localeParam = searchParams.get('locale')?.trim() + // Tenant is required for tenant isolation + if (!tenantParam) { + return NextResponse.json( + { error: 'Tenant ID is required. Use ?tenant= parameter.' }, + { status: 400 }, + ) + } + + const tenantId = parseInt(tenantParam, 10) + if (isNaN(tenantId) || tenantId < 1) { + return NextResponse.json( + { error: 'Invalid tenant ID' }, + { status: 400 }, + ) + } + // Validate locale const validLocales = ['de', 'en'] const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' @@ -70,15 +86,6 @@ export async function GET(request: NextRequest) { Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT), MAX_LIMIT ) - const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined - - // Validate tenant ID if provided - if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) { - return NextResponse.json( - { error: 'Invalid tenant ID' }, - { status: 400 } - ) - } // Get payload instance const payload = await getPayload({ config }) diff --git a/src/app/(frontend)/api/search/route.ts b/src/app/(frontend)/api/search/route.ts index cc0dd5a..2f4e4e3 100644 --- a/src/app/(frontend)/api/search/route.ts +++ b/src/app/(frontend)/api/search/route.ts @@ -53,6 +53,22 @@ export async function GET(request: NextRequest) { const offsetParam = searchParams.get('offset') const localeParam = searchParams.get('locale')?.trim() + // Tenant is required for tenant isolation + if (!tenantParam) { + return NextResponse.json( + { error: 'Tenant ID is required. Use ?tenant= parameter.' }, + { status: 400 }, + ) + } + + const tenantId = parseInt(tenantParam, 10) + if (isNaN(tenantId) || tenantId < 1) { + return NextResponse.json( + { error: 'Invalid tenant ID' }, + { status: 400 }, + ) + } + // Validate locale const validLocales = ['de', 'en'] const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' @@ -87,15 +103,6 @@ export async function GET(request: NextRequest) { MAX_LIMIT ) const offset = Math.max(0, parseInt(offsetParam || '0', 10) || 0) - const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined - - // Validate tenant ID if provided - if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) { - return NextResponse.json( - { error: 'Invalid tenant ID' }, - { status: 400 } - ) - } // Get payload instance const payload = await getPayload({ config }) diff --git a/src/app/(frontend)/api/search/suggestions/route.ts b/src/app/(frontend)/api/search/suggestions/route.ts index 06500cd..9966c69 100644 --- a/src/app/(frontend)/api/search/suggestions/route.ts +++ b/src/app/(frontend)/api/search/suggestions/route.ts @@ -51,6 +51,22 @@ export async function GET(request: NextRequest) { const limitParam = searchParams.get('limit') const localeParam = searchParams.get('locale')?.trim() + // Tenant is required for tenant isolation + if (!tenantParam) { + return NextResponse.json( + { error: 'Tenant ID is required. Use ?tenant= parameter.' }, + { status: 400 }, + ) + } + + const tenantId = parseInt(tenantParam, 10) + if (isNaN(tenantId) || tenantId < 1) { + return NextResponse.json( + { error: 'Invalid tenant ID' }, + { status: 400 }, + ) + } + // Validate locale const validLocales = ['de', 'en'] const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' @@ -80,15 +96,6 @@ export async function GET(request: NextRequest) { Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT), MAX_LIMIT ) - const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined - - // Validate tenant ID if provided - if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) { - return NextResponse.json( - { error: 'Invalid tenant ID' }, - { status: 400 } - ) - } // Get payload instance const payload = await getPayload({ config }) diff --git a/src/blocks/StatsBlock.ts b/src/blocks/StatsBlock.ts index 1ecb52c..6bceafd 100644 --- a/src/blocks/StatsBlock.ts +++ b/src/blocks/StatsBlock.ts @@ -318,6 +318,20 @@ export const StatsBlock: Block = { condition: (_, siblingData) => siblingData?.showIcon, }, }, + { + name: 'iconAlignment', + type: 'select', + defaultValue: 'left', + label: 'Icon-Ausrichtung', + options: [ + { label: 'Linksbündig', value: 'left' }, + { label: 'Zentriert', value: 'center' }, + { label: 'Rechtsbündig', value: 'right' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.showIcon, + }, + }, { name: 'dividers', type: 'checkbox', diff --git a/src/lib/tenantAccess.ts b/src/lib/tenantAccess.ts index 24f2483..75d6364 100644 --- a/src/lib/tenantAccess.ts +++ b/src/lib/tenantAccess.ts @@ -80,25 +80,12 @@ function getTenantIdFromQuery(req: PayloadRequest): number | null { * method resolved the tenant ID. */ export const tenantScopedPublicRead: Access = async ({ req }) => { - // Authentifizierte Admins dürfen alles lesen - if (req.user) { - return true - } + const hasUser = !!req.user + const hostTenantId = await getTenantIdFromHost(req) + const queryTenantId = getTenantIdFromQuery(req) + const tenantId = hostTenantId ?? queryTenantId - // Anonyme Requests: Tenant aus Domain oder Query-Parameter ermitteln - const tenantId = (await getTenantIdFromHost(req)) ?? getTenantIdFromQuery(req) - - if (!tenantId) { - // Weder gültige Domain noch Tenant-Parameter → kein Zugriff - return false - } - - // Nur Dokumente des eigenen Tenants zurückgeben - return { - tenant: { - equals: tenantId, - }, - } + return hasUser ? true : tenantId ? { tenant: { equals: tenantId } } : false } /**