Merge branch 'develop'

This commit is contained in:
Martin Porwoll 2026-02-27 15:22:58 +00:00
commit f10b62c0f1
7 changed files with 1353 additions and 46 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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=<id> 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 })

View file

@ -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=<id> 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 })

View file

@ -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=<id> 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 })

View file

@ -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',

View file

@ -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
}
/**