mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
Merge branch 'develop'
This commit is contained in:
commit
f10b62c0f1
7 changed files with 1353 additions and 46 deletions
138
docs/plans/2026-02-27-sensualmoment-frontend.md
Normal file
138
docs/plans/2026-02-27-sensualmoment-frontend.md
Normal 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.
|
||||||
1147
scripts/seed-sensualmoment.ts
Normal file
1147
scripts/seed-sensualmoment.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -46,11 +46,27 @@ export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const category = searchParams.get('category')?.trim()
|
const category = searchParams.get('category')?.trim()
|
||||||
const type = searchParams.get('type')?.trim() as 'blog' | 'news' | 'press' | 'announcement' | undefined
|
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 pageParam = searchParams.get('page')
|
||||||
const limitParam = searchParams.get('limit')
|
const limitParam = searchParams.get('limit')
|
||||||
const localeParam = searchParams.get('locale')?.trim()
|
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
|
// Validate locale
|
||||||
const validLocales = ['de', 'en']
|
const validLocales = ['de', 'en']
|
||||||
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
|
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),
|
Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
|
||||||
MAX_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
|
// Get payload instance
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,22 @@ export async function GET(request: NextRequest) {
|
||||||
const offsetParam = searchParams.get('offset')
|
const offsetParam = searchParams.get('offset')
|
||||||
const localeParam = searchParams.get('locale')?.trim()
|
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
|
// Validate locale
|
||||||
const validLocales = ['de', 'en']
|
const validLocales = ['de', 'en']
|
||||||
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
|
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
|
||||||
|
|
@ -87,15 +103,6 @@ export async function GET(request: NextRequest) {
|
||||||
MAX_LIMIT
|
MAX_LIMIT
|
||||||
)
|
)
|
||||||
const offset = Math.max(0, parseInt(offsetParam || '0', 10) || 0)
|
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
|
// Get payload instance
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,22 @@ export async function GET(request: NextRequest) {
|
||||||
const limitParam = searchParams.get('limit')
|
const limitParam = searchParams.get('limit')
|
||||||
const localeParam = searchParams.get('locale')?.trim()
|
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
|
// Validate locale
|
||||||
const validLocales = ['de', 'en']
|
const validLocales = ['de', 'en']
|
||||||
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
|
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),
|
Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
|
||||||
MAX_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
|
// Get payload instance
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,20 @@ export const StatsBlock: Block = {
|
||||||
condition: (_, siblingData) => siblingData?.showIcon,
|
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',
|
name: 'dividers',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
|
|
||||||
|
|
@ -80,25 +80,12 @@ function getTenantIdFromQuery(req: PayloadRequest): number | null {
|
||||||
* method resolved the tenant ID.
|
* method resolved the tenant ID.
|
||||||
*/
|
*/
|
||||||
export const tenantScopedPublicRead: Access = async ({ req }) => {
|
export const tenantScopedPublicRead: Access = async ({ req }) => {
|
||||||
// Authentifizierte Admins dürfen alles lesen
|
const hasUser = !!req.user
|
||||||
if (req.user) {
|
const hostTenantId = await getTenantIdFromHost(req)
|
||||||
return true
|
const queryTenantId = getTenantIdFromQuery(req)
|
||||||
}
|
const tenantId = hostTenantId ?? queryTenantId
|
||||||
|
|
||||||
// Anonyme Requests: Tenant aus Domain oder Query-Parameter ermitteln
|
return hasUser ? true : tenantId ? { tenant: { equals: tenantId } } : false
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue