diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..96758d9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,199 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +BlogWoman.de frontend - a Next.js 16 application consuming Payload CMS as a headless backend. This is a multi-tenant system where **Tenant ID 9 (slug: blogwoman)** is the target tenant. + +**Tech Stack:** Next.js 16, React 19, TypeScript, Tailwind CSS 4 +**Backend API:** https://cms.c2sgmbh.de/api (Production) +**Package Manager:** pnpm + +## Commands + +```bash +pnpm dev # Development server (localhost:3000) +pnpm build # Production build +pnpm start # Start production server +pnpm lint # ESLint +``` + +## Environment Variables + +Create `.env.local`: + +```env +NEXT_PUBLIC_PAYLOAD_URL=https://cms.c2sgmbh.de +NEXT_PUBLIC_API_URL=https://cms.c2sgmbh.de/api +NEXT_PUBLIC_TENANT_ID=9 +NEXT_PUBLIC_TENANT_SLUG=blogwoman +NEXT_PUBLIC_SITE_URL=https://blogwoman.de +NEXT_PUBLIC_UMAMI_HOST=https://analytics.c2sgmbh.de +NEXT_PUBLIC_UMAMI_WEBSITE_ID= +``` + +## Architecture + +### Directory Structure + +``` +src/ +├── app/ # Next.js App Router +│ ├── layout.tsx # Root layout (Header/Footer/Analytics) +│ ├── page.tsx # Home page +│ ├── [slug]/page.tsx # Dynamic pages from Payload +│ ├── blog/ # Blog routes +│ ├── serien/ # BlogWoman YouTube series pages +│ └── favoriten/ # BlogWoman affiliate products +├── components/ +│ ├── layout/ # Header, Footer, Navigation, MobileMenu +│ ├── blocks/ # CMS block components (see Block System below) +│ ├── ui/ # Reusable UI components +│ └── analytics/ # UmamiScript +├── lib/ +│ ├── api.ts # Payload API client functions +│ ├── types.ts # TypeScript types +│ └── structuredData.ts # JSON-LD helpers for SEO +└── hooks/ + └── useAnalytics.ts # Umami event tracking +``` + +### API Pattern - CRITICAL + +**Every API call MUST include tenant filtering:** + +```typescript +const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL +const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID + +// CORRECT - always filter by tenant +fetch(`${PAYLOAD_URL}/api/pages?where[tenant][equals]=${TENANT_ID}&where[slug][equals]=about`) + +// WRONG - will return 403 or empty results +fetch(`${PAYLOAD_URL}/api/pages?where[slug][equals]=about`) +``` + +### Block System + +Pages from Payload contain a `layout` array of blocks. Each block has a `blockType` field: + +| Block Type | Purpose | +|------------|---------| +| `hero-block` | Hero banner with image | +| `hero-slider-block` | Multi-slide hero carousel | +| `text-block` | Rich text content | +| `image-text-block` | Image + text side-by-side | +| `card-grid-block` | Card grid layout | +| `cta-block` | Call-to-action section | +| `posts-list-block` | Blog/news listing | +| `testimonials-block` | Customer testimonials | +| `faq-block` | FAQ accordion (with JSON-LD) | +| `newsletter-block` | Newsletter signup form | +| `contact-form-block` | Contact form | +| `video-block` | Video embed | +| `favorites-block` | Affiliate products (BlogWoman) | +| `series-block` | YouTube series overview (BlogWoman) | +| `series-detail-block` | Series hero (BlogWoman) | +| `video-embed-block` | Privacy-mode video embed | + +**Block Renderer pattern:** +```typescript +// src/components/blocks/index.tsx +export function BlockRenderer({ blocks }) { + return blocks.map(block => { + const Component = blockComponents[block.blockType] + return Component ? : null + }) +} +``` + +### BlogWoman-Specific Collections + +Beyond standard CMS collections (pages, posts, testimonials), BlogWoman uses: + +- **favorites** - Affiliate products with categories (fashion, beauty, travel, tech, home) and badges (investment-piece, daily-driver, grfi-approved, new, bestseller) +- **series** - YouTube series with branding (logo, cover image, brand color) + +### API Endpoints Reference + +| Endpoint | Purpose | +|----------|---------| +| `GET /api/pages?where[tenant][equals]=9` | Fetch pages | +| `GET /api/posts?where[tenant][equals]=9` | Fetch blog posts | +| `GET /api/navigations?where[tenant][equals]=9&where[type][equals]=header` | Get navigation | +| `GET /api/site-settings?where[tenant][equals]=9` | Site configuration | +| `GET /api/favorites?where[tenant][equals]=9` | Affiliate products | +| `GET /api/series?where[tenant][equals]=9` | YouTube series | +| `POST /api/newsletter/subscribe` | Newsletter signup (body: email, tenantId, source) | +| `POST /api/form-submissions` | Contact form submission | + +**API Documentation:** https://cms.c2sgmbh.de/api/docs (Swagger UI) + +## Design System (Styleguide) + +**Philosophy:** "Editorial Warmth" - Premium but approachable, like a lifestyle magazine. + +### Colors (60/30/10 Rule) + +| Name | Hex | Use | +|------|-----|-----| +| Ivory | `#F7F3EC` | Backgrounds (60%) | +| Sand | `#C6A47E` | Cards, modules (30%) | +| Espresso | `#2B2520` | Text, headlines | +| Brass | `#B08D57` | Primary buttons, highlights (10%) | +| Bordeaux | `#6B1F2B` | Accent for P&L series | +| Rosé | `#D4A5A5` | SPARK series accent | +| Gold | `#C9A227` | Premium badges | +| Soft White | `#FBF8F3` | Cards, surfaces | +| Warm Gray | `#DDD4C7` | Borders | + +**Forbidden:** Neon colors, pure black (#000), pure white (#FFF), cold blue. + +### Typography + +- **Headlines:** Playfair Display (600 weight) +- **Body/UI:** Inter (400, 500, 600) +- **NO CAPSLOCK HEADLINES** - use uppercase only for small labels with letter-spacing + +### Series Pills (BlogWoman) + +YouTube series use color-coded pills: +- GRFI: Sand background +- Investment: Brass background +- P&L: Bordeaux background +- SPARK: Rosé background +- Inner Circle: Gold background + +## SEO Requirements + +1. **Meta tags** from page.meta field +2. **JSON-LD** for Organization, FAQPage, Article schemas +3. **Open Graph** tags for social sharing +4. Sitemap available at: https://cms.c2sgmbh.de/sitemap.xml + +## Analytics (Umami) + +Umami is cookieless and DSGVO-compliant - runs without consent banner. + +```typescript +// Track custom events +import { useAnalytics } from '@/hooks/useAnalytics' +const { trackEvent, trackNewsletterSubscribe, trackAffiliateClick } = useAnalytics() +``` + +## Documentation References + +| Document | Path | +|----------|------| +| Styleguide | `/docs/guides/styleguide.md` | +| Universal Features | `/docs/architecture/UNIVERSAL_FEATURES.md` | +| API Guide | `/docs/api/API_ANLEITUNG.md` | +| SEO | `/docs/guides/SEO_ERWEITERUNG.md` | +| Analytics | `/docs/architecture/Analytics.md` | +| Frontend Guide | `/docs/guides/FRONTEND.md` | +| Development Prompt | `/prompts/2026-01-20_blogwoman-frontend-entwicklung.md` | + +## Import Alias + +`@/*` maps to `./src/*` (configured in tsconfig.json) diff --git a/package.json b/package.json index 7002fad..2546b32 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,11 @@ "lint": "eslint" }, "dependencies": { + "clsx": "^2.1.1", "next": "16.0.10", "react": "19.2.1", - "react-dom": "19.2.1" + "react-dom": "19.2.1", + "tailwind-merge": "^3.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66dcab8..b39897f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + clsx: + specifier: ^2.1.1 + version: 2.1.1 next: specifier: 16.0.10 version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -17,6 +20,9 @@ importers: react-dom: specifier: 19.2.1 version: 19.2.1(react@19.2.1) + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -799,6 +805,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1785,6 +1795,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tailwind-merge@3.4.0: + resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} @@ -2649,6 +2662,8 @@ snapshots: client-only@0.0.1: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3830,6 +3845,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tailwind-merge@3.4.0: {} + tailwindcss@4.1.18: {} tapable@2.3.0: {} diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx new file mode 100644 index 0000000..62b0518 --- /dev/null +++ b/src/app/[slug]/page.tsx @@ -0,0 +1,88 @@ +import { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getPage, getPages, getSeoSettings } from '@/lib/api' +import { BlockRenderer } from '@/components/blocks' +import { generateBreadcrumbSchema } from '@/lib/structuredData' + +interface PageProps { + params: Promise<{ slug: string }> +} + +export async function generateStaticParams() { + try { + const pages = await getPages() + return pages.docs + .filter((page) => page.slug !== 'home') + .map((page) => ({ + slug: page.slug, + })) + } catch { + // Return empty array if API is unavailable during build + return [] + } +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params + const [page, seoSettings] = await Promise.all([ + getPage(slug), + getSeoSettings(), + ]) + + if (!page) { + return {} + } + + const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || '' + const titleBase = page.meta?.title || page.title + const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase + const description = + page.meta?.description || seoSettings?.metaDefaults?.defaultDescription + const image = + page.meta?.image?.url || seoSettings?.metaDefaults?.defaultImage?.url + + return { + title, + description, + openGraph: { + title, + description: description || undefined, + images: image ? [{ url: image }] : undefined, + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title, + description: description || undefined, + images: image ? [image] : undefined, + }, + robots: { + index: !page.meta?.noIndex, + follow: !page.meta?.noFollow, + }, + } +} + +export default async function DynamicPage({ params }: PageProps) { + const { slug } = await params + const page = await getPage(slug) + + if (!page) { + notFound() + } + + const breadcrumbSchema = generateBreadcrumbSchema([ + { name: 'Startseite', url: '/' }, + { name: page.title, url: `/${slug}` }, + ]) + + return ( + <> +