diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md
new file mode 100644
index 000000000..c9e0af341
--- /dev/null
+++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md
@@ -0,0 +1,82 @@
+---
+name: gitnexus-cli
+description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
+---
+
+# GitNexus CLI Commands
+
+All commands work via `npx` — no global install required.
+
+## Commands
+
+### analyze — Build or refresh the index
+
+```bash
+npx gitnexus analyze
+```
+
+Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
+
+| Flag | Effect |
+| -------------- | ---------------------------------------------------------------- |
+| `--force` | Force full re-index even if up to date |
+| `--embeddings` | Enable embedding generation for semantic search (off by default) |
+
+**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
+
+### status — Check index freshness
+
+```bash
+npx gitnexus status
+```
+
+Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
+
+### clean — Delete the index
+
+```bash
+npx gitnexus clean
+```
+
+Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
+
+| Flag | Effect |
+| --------- | ------------------------------------------------- |
+| `--force` | Skip confirmation prompt |
+| `--all` | Clean all indexed repos, not just the current one |
+
+### wiki — Generate documentation from the graph
+
+```bash
+npx gitnexus wiki
+```
+
+Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
+
+| Flag | Effect |
+| ------------------- | ----------------------------------------- |
+| `--force` | Force full regeneration |
+| `--model ` | LLM model (default: minimax/minimax-m2.5) |
+| `--base-url ` | LLM API base URL |
+| `--api-key ` | LLM API key |
+| `--concurrency ` | Parallel LLM calls (default: 3) |
+| `--gist` | Publish wiki as a public GitHub Gist |
+
+### list — Show all indexed repos
+
+```bash
+npx gitnexus list
+```
+
+Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
+
+## After Indexing
+
+1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
+2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
+
+## Troubleshooting
+
+- **"Not inside a git repository"**: Run from a directory inside a git repo
+- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
+- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
new file mode 100644
index 000000000..9510b97ac
--- /dev/null
+++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
@@ -0,0 +1,89 @@
+---
+name: gitnexus-debugging
+description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
+---
+
+# Debugging with GitNexus
+
+## When to Use
+
+- "Why is this function failing?"
+- "Trace where this error comes from"
+- "Who calls this method?"
+- "This endpoint returns 500"
+- Investigating bugs, errors, or unexpected behavior
+
+## Workflow
+
+```
+1. gitnexus_query({query: ""}) → Find related execution flows
+2. gitnexus_context({name: ""}) → See callers/callees/processes
+3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
+4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
+```
+
+> If "Index is stale" → run `npx gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] Understand the symptom (error message, unexpected behavior)
+- [ ] gitnexus_query for error text or related code
+- [ ] Identify the suspect function from returned processes
+- [ ] gitnexus_context to see callers and callees
+- [ ] Trace execution flow via process resource if applicable
+- [ ] gitnexus_cypher for custom call chain traces if needed
+- [ ] Read source files to confirm root cause
+```
+
+## Debugging Patterns
+
+| Symptom | GitNexus Approach |
+| -------------------- | ---------------------------------------------------------- |
+| Error message | `gitnexus_query` for error text → `context` on throw sites |
+| Wrong return value | `context` on the function → trace callees for data flow |
+| Intermittent failure | `context` → look for external calls, async deps |
+| Performance issue | `context` → find symbols with many callers (hot paths) |
+| Recent regression | `detect_changes` to see what your changes affect |
+
+## Tools
+
+**gitnexus_query** — find code related to error:
+
+```
+gitnexus_query({query: "payment validation error"})
+→ Processes: CheckoutFlow, ErrorHandling
+→ Symbols: validatePayment, handlePaymentError, PaymentException
+```
+
+**gitnexus_context** — full context for a suspect:
+
+```
+gitnexus_context({name: "validatePayment"})
+→ Incoming calls: processCheckout, webhookHandler
+→ Outgoing calls: verifyCard, fetchRates (external API!)
+→ Processes: CheckoutFlow (step 3/7)
+```
+
+**gitnexus_cypher** — custom call chain traces:
+
+```cypher
+MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
+RETURN [n IN nodes(path) | n.name] AS chain
+```
+
+## Example: "Payment endpoint returns 500 intermittently"
+
+```
+1. gitnexus_query({query: "payment error handling"})
+ → Processes: CheckoutFlow, ErrorHandling
+ → Symbols: validatePayment, handlePaymentError
+
+2. gitnexus_context({name: "validatePayment"})
+ → Outgoing calls: verifyCard, fetchRates (external API!)
+
+3. READ gitnexus://repo/my-app/process/CheckoutFlow
+ → Step 3: validatePayment → calls fetchRates (external)
+
+4. Root cause: fetchRates calls external API without proper timeout
+```
diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
new file mode 100644
index 000000000..927a4e4b6
--- /dev/null
+++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
@@ -0,0 +1,78 @@
+---
+name: gitnexus-exploring
+description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
+---
+
+# Exploring Codebases with GitNexus
+
+## When to Use
+
+- "How does authentication work?"
+- "What's the project structure?"
+- "Show me the main components"
+- "Where is the database logic?"
+- Understanding code you haven't seen before
+
+## Workflow
+
+```
+1. READ gitnexus://repos → Discover indexed repos
+2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
+3. gitnexus_query({query: ""}) → Find related execution flows
+4. gitnexus_context({name: ""}) → Deep dive on specific symbol
+5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
+```
+
+> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] READ gitnexus://repo/{name}/context
+- [ ] gitnexus_query for the concept you want to understand
+- [ ] Review returned processes (execution flows)
+- [ ] gitnexus_context on key symbols for callers/callees
+- [ ] READ process resource for full execution traces
+- [ ] Read source files for implementation details
+```
+
+## Resources
+
+| Resource | What you get |
+| --------------------------------------- | ------------------------------------------------------- |
+| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
+| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
+| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
+| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
+
+## Tools
+
+**gitnexus_query** — find execution flows related to a concept:
+
+```
+gitnexus_query({query: "payment processing"})
+→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
+→ Symbols grouped by flow with file locations
+```
+
+**gitnexus_context** — 360-degree view of a symbol:
+
+```
+gitnexus_context({name: "validateUser"})
+→ Incoming calls: loginHandler, apiMiddleware
+→ Outgoing calls: checkToken, getUserById
+→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
+```
+
+## Example: "How does payment processing work?"
+
+```
+1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
+2. gitnexus_query({query: "payment processing"})
+ → CheckoutFlow: processPayment → validateCard → chargeStripe
+ → RefundFlow: initiateRefund → calculateRefund → processRefund
+3. gitnexus_context({name: "processPayment"})
+ → Incoming: checkoutHandler, webhookHandler
+ → Outgoing: validateCard, chargeStripe, saveTransaction
+4. Read src/payments/processor.ts for implementation details
+```
diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md
new file mode 100644
index 000000000..937ac73d1
--- /dev/null
+++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: gitnexus-guide
+description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
+---
+
+# GitNexus Guide
+
+Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
+
+## Always Start Here
+
+For any task involving code understanding, debugging, impact analysis, or refactoring:
+
+1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
+2. **Match your task to a skill below** and **read that skill file**
+3. **Follow the skill's workflow and checklist**
+
+> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
+
+## Skills
+
+| Task | Skill to read |
+| -------------------------------------------- | ------------------- |
+| Understand architecture / "How does X work?" | `gitnexus-exploring` |
+| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
+| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
+| Rename / extract / split / refactor | `gitnexus-refactoring` |
+| Tools, resources, schema reference | `gitnexus-guide` (this file) |
+| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
+
+## Tools Reference
+
+| Tool | What it gives you |
+| ---------------- | ------------------------------------------------------------------------ |
+| `query` | Process-grouped code intelligence — execution flows related to a concept |
+| `context` | 360-degree symbol view — categorized refs, processes it participates in |
+| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
+| `detect_changes` | Git-diff impact — what do your current changes affect |
+| `rename` | Multi-file coordinated rename with confidence-tagged edits |
+| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
+| `list_repos` | Discover indexed repos |
+
+## Resources Reference
+
+Lightweight reads (~100-500 tokens) for navigation:
+
+| Resource | Content |
+| ---------------------------------------------- | ----------------------------------------- |
+| `gitnexus://repo/{name}/context` | Stats, staleness check |
+| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
+| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
+| `gitnexus://repo/{name}/processes` | All execution flows |
+| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
+| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
+
+## Graph Schema
+
+**Nodes:** File, Function, Class, Interface, Method, Community, Process
+**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
+
+```cypher
+MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
+RETURN caller.name, caller.filePath
+```
diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
new file mode 100644
index 000000000..e19af280c
--- /dev/null
+++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
@@ -0,0 +1,97 @@
+---
+name: gitnexus-impact-analysis
+description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
+---
+
+# Impact Analysis with GitNexus
+
+## When to Use
+
+- "Is it safe to change this function?"
+- "What will break if I modify X?"
+- "Show me the blast radius"
+- "Who uses this code?"
+- Before making non-trivial code changes
+- Before committing — to understand what your changes affect
+
+## Workflow
+
+```
+1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
+2. READ gitnexus://repo/{name}/processes → Check affected execution flows
+3. gitnexus_detect_changes() → Map current git changes to affected flows
+4. Assess risk and report to user
+```
+
+> If "Index is stale" → run `npx gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
+- [ ] Review d=1 items first (these WILL BREAK)
+- [ ] Check high-confidence (>0.8) dependencies
+- [ ] READ processes to check affected execution flows
+- [ ] gitnexus_detect_changes() for pre-commit check
+- [ ] Assess risk level and report to user
+```
+
+## Understanding Output
+
+| Depth | Risk Level | Meaning |
+| ----- | ---------------- | ------------------------ |
+| d=1 | **WILL BREAK** | Direct callers/importers |
+| d=2 | LIKELY AFFECTED | Indirect dependencies |
+| d=3 | MAY NEED TESTING | Transitive effects |
+
+## Risk Assessment
+
+| Affected | Risk |
+| ------------------------------ | -------- |
+| <5 symbols, few processes | LOW |
+| 5-15 symbols, 2-5 processes | MEDIUM |
+| >15 symbols or many processes | HIGH |
+| Critical path (auth, payments) | CRITICAL |
+
+## Tools
+
+**gitnexus_impact** — the primary tool for symbol blast radius:
+
+```
+gitnexus_impact({
+ target: "validateUser",
+ direction: "upstream",
+ minConfidence: 0.8,
+ maxDepth: 3
+})
+
+→ d=1 (WILL BREAK):
+ - loginHandler (src/auth/login.ts:42) [CALLS, 100%]
+ - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
+
+→ d=2 (LIKELY AFFECTED):
+ - authRouter (src/routes/auth.ts:22) [CALLS, 95%]
+```
+
+**gitnexus_detect_changes** — git-diff based impact analysis:
+
+```
+gitnexus_detect_changes({scope: "staged"})
+
+→ Changed: 5 symbols in 3 files
+→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
+→ Risk: MEDIUM
+```
+
+## Example: "What breaks if I change validateUser?"
+
+```
+1. gitnexus_impact({target: "validateUser", direction: "upstream"})
+ → d=1: loginHandler, apiMiddleware (WILL BREAK)
+ → d=2: authRouter, sessionManager (LIKELY AFFECTED)
+
+2. READ gitnexus://repo/my-app/processes
+ → LoginFlow and TokenRefresh touch validateUser
+
+3. Risk: 2 direct callers, 2 processes = MEDIUM
+```
diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
new file mode 100644
index 000000000..f48cc01bd
--- /dev/null
+++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
@@ -0,0 +1,121 @@
+---
+name: gitnexus-refactoring
+description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
+---
+
+# Refactoring with GitNexus
+
+## When to Use
+
+- "Rename this function safely"
+- "Extract this into a module"
+- "Split this service"
+- "Move this to a new file"
+- Any task involving renaming, extracting, splitting, or restructuring code
+
+## Workflow
+
+```
+1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
+2. gitnexus_query({query: "X"}) → Find execution flows involving X
+3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
+4. Plan update order: interfaces → implementations → callers → tests
+```
+
+> If "Index is stale" → run `npx gitnexus analyze` in terminal.
+
+## Checklists
+
+### Rename Symbol
+
+```
+- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
+- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
+- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
+- [ ] gitnexus_detect_changes() — verify only expected files changed
+- [ ] Run tests for affected processes
+```
+
+### Extract Module
+
+```
+- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
+- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
+- [ ] Define new module interface
+- [ ] Extract code, update imports
+- [ ] gitnexus_detect_changes() — verify affected scope
+- [ ] Run tests for affected processes
+```
+
+### Split Function/Service
+
+```
+- [ ] gitnexus_context({name: target}) — understand all callees
+- [ ] Group callees by responsibility
+- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
+- [ ] Create new functions/services
+- [ ] Update callers
+- [ ] gitnexus_detect_changes() — verify affected scope
+- [ ] Run tests for affected processes
+```
+
+## Tools
+
+**gitnexus_rename** — automated multi-file rename:
+
+```
+gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
+→ 12 edits across 8 files
+→ 10 graph edits (high confidence), 2 ast_search edits (review)
+→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
+```
+
+**gitnexus_impact** — map all dependents first:
+
+```
+gitnexus_impact({target: "validateUser", direction: "upstream"})
+→ d=1: loginHandler, apiMiddleware, testUtils
+→ Affected Processes: LoginFlow, TokenRefresh
+```
+
+**gitnexus_detect_changes** — verify your changes after refactoring:
+
+```
+gitnexus_detect_changes({scope: "all"})
+→ Changed: 8 files, 12 symbols
+→ Affected processes: LoginFlow, TokenRefresh
+→ Risk: MEDIUM
+```
+
+**gitnexus_cypher** — custom reference queries:
+
+```cypher
+MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
+RETURN caller.name, caller.filePath ORDER BY caller.filePath
+```
+
+## Risk Rules
+
+| Risk Factor | Mitigation |
+| ------------------- | ----------------------------------------- |
+| Many callers (>5) | Use gitnexus_rename for automated updates |
+| Cross-area refs | Use detect_changes after to verify scope |
+| String/dynamic refs | gitnexus_query to find them |
+| External/public API | Version and deprecate properly |
+
+## Example: Rename `validateUser` to `authenticateUser`
+
+```
+1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
+ → 12 edits: 10 graph (safe), 2 ast_search (review)
+ → Files: validator.ts, login.ts, middleware.ts, config.json...
+
+2. Review ast_search edits (config.json: dynamic reference!)
+
+3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
+ → Applied 12 edits across 8 files
+
+4. gitnexus_detect_changes({scope: "all"})
+ → Affected: LoginFlow, TokenRefresh
+ → Risk: MEDIUM — run tests for these flows
+```
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..d26dfabb5
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+node_modules
+.next
+.turbo
+.git
+*.md
+.env*
+.DS_Store
+apps/e2e
+apps/dev-tool
+.gitnexus
+.gsd
+.claude
diff --git a/.env.production.example b/.env.production.example
new file mode 100644
index 000000000..f801bc32b
--- /dev/null
+++ b/.env.production.example
@@ -0,0 +1,48 @@
+# =====================================================
+# MyEasyCMS v2 — Environment Variables
+# Copy to .env and fill in your values
+# =====================================================
+
+# --- Supabase ---
+POSTGRES_PASSWORD=change-me-to-a-strong-password
+JWT_SECRET=change-me-to-at-least-32-characters-long-secret
+
+# Generate these with: npx supabase gen keys
+SUPABASE_ANON_KEY=your-anon-key-here
+SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
+
+# --- App ---
+SITE_URL=https://myeasycms.de
+APP_PORT=3000
+
+# --- Kong ---
+KONG_HTTP_PORT=8000
+KONG_HTTPS_PORT=8443
+API_EXTERNAL_URL=https://api.myeasycms.de
+
+# --- Email (SMTP) ---
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_USER=noreply@myeasycms.de
+SMTP_PASS=your-smtp-password
+SMTP_ADMIN_EMAIL=admin@myeasycms.de
+
+# --- Auth ---
+ENABLE_EMAIL_AUTOCONFIRM=false
+DISABLE_SIGNUP=false
+JWT_EXPIRY=3600
+ADDITIONAL_REDIRECT_URLS=
+
+# --- Webhooks ---
+DB_WEBHOOK_SECRET=your-webhook-secret
+
+# --- Feature Flags ---
+# All default to true, set to false to disable
+# NEXT_PUBLIC_ENABLE_MODULE_BUILDER=true
+# NEXT_PUBLIC_ENABLE_MEMBER_MANAGEMENT=true
+# NEXT_PUBLIC_ENABLE_COURSE_MANAGEMENT=true
+# NEXT_PUBLIC_ENABLE_BOOKING_MANAGEMENT=false
+# NEXT_PUBLIC_ENABLE_SEPA_PAYMENTS=true
+# NEXT_PUBLIC_ENABLE_DOCUMENT_GENERATION=true
+# NEXT_PUBLIC_ENABLE_NEWSLETTER=true
+# NEXT_PUBLIC_ENABLE_SITE_BUILDER=true
diff --git a/.gitignore b/.gitignore
index 3c3c8d420..161be2abb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,4 +50,5 @@ yarn-error.log*
node-compile-cache/
# prds
-.prds/
\ No newline at end of file
+.prds/
+.gitnexus
diff --git a/AGENTS.md b/AGENTS.md
index d1dc564cc..26ca9df84 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,3 +68,105 @@ After implementation, always run:
2. `pnpm lint:fix`
3. `pnpm format:fix`
4. Run code quality reviewer agent
+
+
+# GitNexus — Code Intelligence
+
+This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+
+> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
+
+## Always Do
+
+- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
+- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
+- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
+- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
+- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
+
+## When Debugging
+
+1. `gitnexus_query({query: ""})` — find execution flows related to the issue
+2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation
+3. `READ gitnexus://repo/myeasycms-v2/process/{processName}` — trace the full execution flow step by step
+4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
+
+## When Refactoring
+
+- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
+- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
+- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
+
+## Never Do
+
+- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
+- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
+- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
+- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
+
+## Tools Quick Reference
+
+| Tool | When to use | Command |
+|------|-------------|---------|
+| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
+| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
+| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
+| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
+| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
+| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
+
+## Impact Risk Levels
+
+| Depth | Meaning | Action |
+|-------|---------|--------|
+| d=1 | WILL BREAK — direct callers/importers | MUST update these |
+| d=2 | LIKELY AFFECTED — indirect deps | Should test |
+| d=3 | MAY NEED TESTING — transitive | Test if critical path |
+
+## Resources
+
+| Resource | Use for |
+|----------|---------|
+| `gitnexus://repo/myeasycms-v2/context` | Codebase overview, check index freshness |
+| `gitnexus://repo/myeasycms-v2/clusters` | All functional areas |
+| `gitnexus://repo/myeasycms-v2/processes` | All execution flows |
+| `gitnexus://repo/myeasycms-v2/process/{name}` | Step-by-step execution trace |
+
+## Self-Check Before Finishing
+
+Before completing any code modification task, verify:
+1. `gitnexus_impact` was run for all modified symbols
+2. No HIGH/CRITICAL risk warnings were ignored
+3. `gitnexus_detect_changes()` confirms changes match expected scope
+4. All d=1 (WILL BREAK) dependents were updated
+
+## Keeping the Index Fresh
+
+After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
+
+```bash
+npx gitnexus analyze
+```
+
+If the index previously included embeddings, preserve them by adding `--embeddings`:
+
+```bash
+npx gitnexus analyze --embeddings
+```
+
+To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
+
+> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
+
+## CLI
+
+| Task | Read this skill file |
+|------|---------------------|
+| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
+| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
+| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
+| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
+| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
+| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
index eef4bd20c..f0862be7c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1 +1,103 @@
-@AGENTS.md
\ No newline at end of file
+@AGENTS.md
+
+
+# GitNexus — Code Intelligence
+
+This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+
+> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
+
+## Always Do
+
+- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
+- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
+- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
+- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
+- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
+
+## When Debugging
+
+1. `gitnexus_query({query: ""})` — find execution flows related to the issue
+2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation
+3. `READ gitnexus://repo/myeasycms-v2/process/{processName}` — trace the full execution flow step by step
+4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
+
+## When Refactoring
+
+- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
+- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
+- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
+
+## Never Do
+
+- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
+- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
+- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
+- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
+
+## Tools Quick Reference
+
+| Tool | When to use | Command |
+|------|-------------|---------|
+| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
+| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
+| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
+| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
+| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
+| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
+
+## Impact Risk Levels
+
+| Depth | Meaning | Action |
+|-------|---------|--------|
+| d=1 | WILL BREAK — direct callers/importers | MUST update these |
+| d=2 | LIKELY AFFECTED — indirect deps | Should test |
+| d=3 | MAY NEED TESTING — transitive | Test if critical path |
+
+## Resources
+
+| Resource | Use for |
+|----------|---------|
+| `gitnexus://repo/myeasycms-v2/context` | Codebase overview, check index freshness |
+| `gitnexus://repo/myeasycms-v2/clusters` | All functional areas |
+| `gitnexus://repo/myeasycms-v2/processes` | All execution flows |
+| `gitnexus://repo/myeasycms-v2/process/{name}` | Step-by-step execution trace |
+
+## Self-Check Before Finishing
+
+Before completing any code modification task, verify:
+1. `gitnexus_impact` was run for all modified symbols
+2. No HIGH/CRITICAL risk warnings were ignored
+3. `gitnexus_detect_changes()` confirms changes match expected scope
+4. All d=1 (WILL BREAK) dependents were updated
+
+## Keeping the Index Fresh
+
+After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
+
+```bash
+npx gitnexus analyze
+```
+
+If the index previously included embeddings, preserve them by adding `--embeddings`:
+
+```bash
+npx gitnexus analyze --embeddings
+```
+
+To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
+
+> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
+
+## CLI
+
+| Task | Read this skill file |
+|------|---------------------|
+| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
+| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
+| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
+| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
+| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
+| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
+
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..7ff1863a1
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,50 @@
+# =====================================================
+# MyEasyCMS v2 — Production Dockerfile
+# Multi-stage build for Next.js with pnpm
+# =====================================================
+
+FROM node:22-alpine AS base
+RUN corepack enable && corepack prepare pnpm@latest --activate
+WORKDIR /app
+
+# --- Dependencies stage ---
+FROM base AS deps
+COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
+COPY apps/web/package.json ./apps/web/package.json
+COPY packages/ ./packages/
+COPY tooling/ ./tooling/
+RUN pnpm install --frozen-lockfile --prod=false
+
+# --- Build stage ---
+FROM base AS builder
+COPY --from=deps /app/node_modules ./node_modules
+COPY --from=deps /app/packages/ ./packages/
+COPY . .
+
+# Set build-time env vars
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV NEXT_PUBLIC_SITE_URL=https://myeasycms.de
+
+RUN pnpm --filter web build
+
+# --- Production stage ---
+FROM node:22-alpine AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+# Copy built app
+COPY --from=builder /app/apps/web/.next/standalone ./
+COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
+COPY --from=builder /app/apps/web/public ./apps/web/public
+
+USER nextjs
+EXPOSE 3000
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "apps/web/server.js"]
diff --git a/apps/web/app/[locale]/(marketing)/page.tsx b/apps/web/app/[locale]/(marketing)/page.tsx
index ab320ca08..7d7f36de9 100644
--- a/apps/web/app/[locale]/(marketing)/page.tsx
+++ b/apps/web/app/[locale]/(marketing)/page.tsx
@@ -1,19 +1,34 @@
import Image from 'next/image';
import Link from 'next/link';
-import { ArrowRightIcon, LayoutDashboard } from 'lucide-react';
+import {
+ ArrowRightIcon,
+ BookOpenIcon,
+ CalendarIcon,
+ FileTextIcon,
+ GraduationCapIcon,
+ LayoutDashboardIcon,
+ MailIcon,
+ ShieldCheckIcon,
+ UsersIcon,
+ WalletIcon,
+ BedDoubleIcon,
+ GlobeIcon,
+ ZapIcon,
+ HeadsetIcon,
+ LockIcon,
+ SmartphoneIcon,
+ CheckIcon,
+} from 'lucide-react';
import { PricingTable } from '@kit/billing-gateway/marketing';
import {
CtaButton,
EcosystemShowcase,
- FeatureCard,
- FeatureGrid,
FeatureShowcase,
FeatureShowcaseIconContainer,
Hero,
Pill,
- PillActionButton,
SecondaryHero,
} from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans';
@@ -23,31 +38,25 @@ import pathsConfig from '~/config/paths.config';
function Home() {
return (
-
+
+ {/* Hero Section */}
- The SaaS Starter Kit for ambitious developers
-
-
-
- }
- />
+
+
+
+
}
title={
- Ship a SaaS faster than ever.
+
}
subtitle={
- Makerkit gives you a production-ready boilerplate to build your
- SaaS faster than ever before with the next-gen SaaS Starter Kit.
- Get started in minutes.
+
}
cta={ }
@@ -55,95 +64,227 @@ function Home() {
}
/>
+ {/* Trust Indicators */}
+
+
+ {/* Core Modules Feature Grid */}
- The ultimate SaaS Starter Kit
+
.{' '}
- Unleash your creativity and build your SaaS faster than ever
- with Makerkit.
+
>
}
icon={
-
- All-in-one solution
+
+
+
+
}
>
-
-
-
-
-
-
+
-
-
-
-
-
-
-
+
+
+
+ {/* Dashboard Showcase */}
}
+ description={ }
>
+ {/* Additional Features Row */}
+
+
+
+
+
+
+ .{' '}
+
+
+
+ >
+ }
+ icon={
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ {/* Why Choose Us Section */}
+
+
}
+ description={
}
+ textPosition="right"
+ >
+
+
+
+
+
+
+
+
+
+ {/* How It Works */}
+
+
+ {/* Pricing Section */}
No credit card required.}
- heading="Fair pricing for all types of businesses"
- subheading="Get started on our free plan and upgrade when you are ready."
+ pill={
+ }>
+
+
+ }
+ heading={ }
+ subheading={ }
/>
@@ -167,6 +312,37 @@ function Home() {
+
+ {/* Final CTA */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
@@ -201,3 +377,73 @@ function MainCallToActionButton() {
);
}
+
+function IconFeatureCard(props: {
+ icon: React.ComponentType<{ className?: string }>;
+ titleKey: string;
+ descKey: string;
+}) {
+ return (
+
+ );
+}
+
+function TrustItem(props: {
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+function WhyItem(props: {
+ icon: React.ComponentType<{ className?: string }>;
+ titleKey: string;
+ descKey: string;
+}) {
+ return (
+
+ );
+}
+
+function StepCard(props: { step: string; titleKey: string; descKey: string }) {
+ return (
+
+
{props.step}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx b/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx
new file mode 100644
index 000000000..1756eb8d4
--- /dev/null
+++ b/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx
@@ -0,0 +1,31 @@
+import { createClient } from '@supabase/supabase-js';
+import { notFound } from 'next/navigation';
+import { SiteRenderer } from '@kit/site-builder/components';
+
+interface Props { params: Promise<{ slug: string; page: string[] }> }
+
+export default async function ClubSubPage({ params }: Props) {
+ const { slug, page: pagePath } = await params;
+ const pageSlug = pagePath.join('/');
+
+ const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
+ );
+
+ const { data: account } = await supabase.from('accounts').select('id').eq('slug', slug).single();
+ if (!account) notFound();
+
+ const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
+ if (!settings) notFound();
+
+ const { data: sitePageData } = await supabase.from('site_pages').select('*')
+ .eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle();
+ if (!sitePageData) notFound();
+
+ return (
+
+ } />
+
+ );
+}
diff --git a/apps/web/app/[locale]/club/[slug]/newsletter/subscribe/page.tsx b/apps/web/app/[locale]/club/[slug]/newsletter/subscribe/page.tsx
new file mode 100644
index 000000000..312880318
--- /dev/null
+++ b/apps/web/app/[locale]/club/[slug]/newsletter/subscribe/page.tsx
@@ -0,0 +1,41 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Input } from '@kit/ui/input';
+import { Label } from '@kit/ui/label';
+import { Button } from '@kit/ui/button';
+import { Mail } from 'lucide-react';
+
+interface Props { params: Promise<{ slug: string }> }
+
+export default async function NewsletterSubscribePage({ params }: Props) {
+ const { slug } = await params;
+
+ return (
+
+
+
+
+
+
+ Newsletter abonnieren
+ Bleiben Sie über Neuigkeiten informiert.
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/club/[slug]/newsletter/unsubscribe/page.tsx b/apps/web/app/[locale]/club/[slug]/newsletter/unsubscribe/page.tsx
new file mode 100644
index 000000000..deb842f65
--- /dev/null
+++ b/apps/web/app/[locale]/club/[slug]/newsletter/unsubscribe/page.tsx
@@ -0,0 +1,37 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Button } from '@kit/ui/button';
+import { MailX } from 'lucide-react';
+import Link from 'next/link';
+
+interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }> }
+
+export default async function NewsletterUnsubscribePage({ params, searchParams }: Props) {
+ const { slug } = await params;
+ const { token } = await searchParams;
+
+ return (
+
+
+
+
+
+
+ Newsletter abbestellen
+
+
+ {token ? (
+ <>
+ Möchten Sie den Newsletter wirklich abbestellen?
+ Abbestellen bestätigen
+ >
+ ) : (
+ Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der Newsletter-E-Mail.
+ )}
+
+ ← Zurück zur Website
+
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/club/[slug]/page.tsx b/apps/web/app/[locale]/club/[slug]/page.tsx
new file mode 100644
index 000000000..d5c3eb0e7
--- /dev/null
+++ b/apps/web/app/[locale]/club/[slug]/page.tsx
@@ -0,0 +1,34 @@
+import { createClient } from '@supabase/supabase-js';
+import { notFound } from 'next/navigation';
+import { SiteRenderer } from '@kit/site-builder/components';
+
+interface Props { params: Promise<{ slug: string }> }
+
+export default async function ClubHomePage({ params }: Props) {
+ const { slug } = await params;
+
+ // Use anon client for public access
+ const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
+ );
+
+ // Resolve slug → account
+ const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
+ if (!account) notFound();
+
+ // Check site is public
+ const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
+ if (!settings) notFound();
+
+ // Get homepage
+ const { data: page } = await supabase.from('site_pages').select('*')
+ .eq('account_id', account.id).eq('is_homepage', true).eq('is_published', true).maybeSingle();
+ if (!page) notFound();
+
+ return (
+
+ } />
+
+ );
+}
diff --git a/apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx b/apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx
new file mode 100644
index 000000000..d9553a275
--- /dev/null
+++ b/apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx
@@ -0,0 +1,100 @@
+import { createClient } from '@supabase/supabase-js';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Button } from '@kit/ui/button';
+import { Badge } from '@kit/ui/badge';
+import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react';
+import Link from 'next/link';
+
+interface Props {
+ params: Promise<{ slug: string }>;
+}
+
+export default async function PortalDocumentsPage({ params }: Props) {
+ const { slug } = await params;
+
+ const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
+ );
+
+ const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
+ if (!account) return Organisation nicht gefunden
;
+
+ // Demo documents (in production: query invoices + cms_files for this member)
+ const documents = [
+ { id: '1', title: 'Mitgliedsbeitrag 2026', type: 'Rechnung', date: '2026-01-15', status: 'paid' },
+ { id: '2', title: 'Mitgliedsbeitrag 2025', type: 'Rechnung', date: '2025-01-10', status: 'paid' },
+ { id: '3', title: 'Beitrittserklärung', type: 'Dokument', date: '2020-01-15', status: 'signed' },
+ ];
+
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case 'paid': return Bezahlt ;
+ case 'open': return Offen ;
+ case 'signed': return Unterschrieben ;
+ default: return {status} ;
+ }
+ };
+
+ const getIcon = (type: string) => {
+ switch (type) {
+ case 'Rechnung': return ;
+ case 'Dokument': return ;
+ default: return ;
+ }
+ };
+
+ return (
+
+
+
+
+
+
Meine Dokumente
+
+
+
← Zurück zum Portal
+
+
+
+
+
+
+
+ Verfügbare Dokumente
+ {String(account.name)} — Dokumente und Rechnungen
+
+
+ {documents.length === 0 ? (
+
+
+
Keine Dokumente vorhanden
+
+ ) : (
+
+ {documents.map((doc) => (
+
+
+ {getIcon(doc.type)}
+
+
{doc.title}
+
{doc.type} — {new Date(doc.date).toLocaleDateString('de-DE')}
+
+
+
+ {getStatusBadge(doc.status)}
+
+
+ PDF
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx b/apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx
new file mode 100644
index 000000000..4343d5812
--- /dev/null
+++ b/apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx
@@ -0,0 +1,125 @@
+import { createClient } from '@supabase/supabase-js';
+import { notFound } from 'next/navigation';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Label } from '@kit/ui/label';
+import { UserPlus, Shield, CheckCircle } from 'lucide-react';
+import Link from 'next/link';
+
+interface Props {
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ token?: string }>;
+}
+
+export default async function PortalInvitePage({ params, searchParams }: Props) {
+ const { slug } = await params;
+ const { token } = await searchParams;
+
+ if (!token) notFound();
+
+ const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
+ );
+
+ // Resolve account
+ const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
+ if (!account) notFound();
+
+ // Look up invitation
+ const { data: invitation } = await supabase.from('member_portal_invitations')
+ .select('id, email, status, expires_at, member_id')
+ .eq('invite_token', token)
+ .maybeSingle();
+
+ if (!invitation || invitation.status !== 'pending') {
+ return (
+
+
+
+
+ Einladung ungültig
+
+ Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist ungültig.
+ Bitte wenden Sie sich an Ihren Vereinsadministrator.
+
+
+ ← Zur Website
+
+
+
+
+ );
+ }
+
+ const expired = new Date(invitation.expires_at) < new Date();
+ if (expired) {
+ return (
+
+
+
+
+ Einladung abgelaufen
+
+ Diese Einladung ist am {new Date(invitation.expires_at).toLocaleDateString('de-DE')} abgelaufen.
+ Bitte fordern Sie eine neue Einladung an.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Einladung zum Mitgliederbereich
+ {String(account.name)}
+
+
+
+
+ Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu erstellen.
+ Damit können Sie Ihr Profil einsehen, Dokumente herunterladen und Ihre Datenschutz-Einstellungen verwalten.
+
+
+
+
+
+
+ Bereits ein Konto? Anmelden
+
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/club/[slug]/portal/page.tsx b/apps/web/app/[locale]/club/[slug]/portal/page.tsx
new file mode 100644
index 000000000..cedce27ec
--- /dev/null
+++ b/apps/web/app/[locale]/club/[slug]/portal/page.tsx
@@ -0,0 +1,100 @@
+import { createClient } from '@supabase/supabase-js';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Button } from '@kit/ui/button';
+import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react';
+import Link from 'next/link';
+
+import { PortalLoginForm } from '@kit/site-builder/components';
+
+interface Props {
+ params: Promise<{ slug: string }>;
+}
+
+export default async function MemberPortalPage({ params }: Props) {
+ const { slug } = await params;
+
+ const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
+ );
+
+ const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
+ if (!account) return Organisation nicht gefunden
;
+
+ // Check if user is already logged in
+ const { data: { user } } = await supabase.auth.getUser();
+
+ if (user) {
+ // Check if this user is a member of this club
+ const { data: member } = await supabase.from('members')
+ .select('id, first_name, last_name, status')
+ .eq('account_id', account.id)
+ .eq('user_id', user.id)
+ .maybeSingle();
+
+ if (member) {
+ // Logged in member — show portal dashboard
+ return (
+
+
+
+ Willkommen, {String(member.first_name)}!
+
+
+
+
+
+ Mein Profil
+ Kontaktdaten und Datenschutz
+
+
+
+
+
+
+
+ Dokumente
+ Rechnungen und Bescheinigungen
+
+
+
+
+
+
+ Mitgliedsausweis
+ Digital anzeigen
+
+
+
+
+
+ );
+ }
+ }
+
+ // Not logged in or not a member — show login form
+ return (
+
+ );
+}
diff --git a/apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx b/apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx
new file mode 100644
index 000000000..0ba3b4ee2
--- /dev/null
+++ b/apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx
@@ -0,0 +1,131 @@
+import { createClient } from '@supabase/supabase-js';
+import { redirect } from 'next/navigation';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Label } from '@kit/ui/label';
+import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react';
+import Link from 'next/link';
+
+interface Props {
+ params: Promise<{ slug: string }>;
+}
+
+export default async function PortalProfilePage({ params }: Props) {
+ const { slug } = await params;
+
+ const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
+ );
+
+ const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
+ if (!account) return Organisation nicht gefunden
;
+
+ // Get current user
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) redirect(`/club/${slug}/portal`);
+
+ // Find member linked to this user
+ const { data: member } = await supabase.from('members')
+ .select('*')
+ .eq('account_id', account.id)
+ .eq('user_id', user.id)
+ .maybeSingle();
+
+ if (!member) {
+ return (
+
+
+
+
+ Kein Mitglied
+
+ Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem Verein verknüpft.
+ Bitte wenden Sie sich an Ihren Vereinsadministrator.
+
+
+ ← Zurück
+
+
+
+
+ );
+ }
+
+ const m = member;
+
+ return (
+
+
+
+
+
+
Mein Profil
+
+
← Zurück zum Portal
+
+
+
+
+
+
+
+
+
+
+
+
{String(m.first_name)} {String(m.last_name)}
+
+ Nr. {String(m.member_number ?? '—')} — Mitglied seit {m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : '—'}
+
+
+
+
+
+
+
+ Kontaktdaten
+
+ Vorname
+ Nachname
+ E-Mail
+ Telefon
+ Mobil
+
+
+
+
+ Adresse
+
+ Straße
+ Hausnummer
+ PLZ
+ Ort
+
+
+
+
+ Datenschutz-Einwilligungen
+
+ {[
+ { key: 'gdpr_newsletter', label: 'Newsletter per E-Mail', value: m.gdpr_newsletter },
+ { key: 'gdpr_internet', label: 'Veröffentlichung auf der Homepage', value: m.gdpr_internet },
+ { key: 'gdpr_print', label: 'Veröffentlichung in der Vereinszeitung', value: m.gdpr_print },
+ { key: 'gdpr_birthday_info', label: 'Geburtstagsinfo an Mitglieder', value: m.gdpr_birthday_info },
+ ].map(({ key, label, value }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+
+ Änderungen speichern
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
index 890e225f6..822274561 100644
--- a/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
@@ -1,235 +1,28 @@
-import Link from 'next/link';
-
-import { ArrowLeft, BedDouble } from 'lucide-react';
-
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-import { Button } from '@kit/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@kit/ui/card';
-
import { createBookingManagementApi } from '@kit/booking-management/api';
-
+import { CreateBookingForm } from '@kit/booking-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-interface PageProps {
- params: Promise<{ account: string }>;
-}
+interface Props { params: Promise<{ account: string }> }
-export default async function NewBookingPage({ params }: PageProps) {
+export default async function NewBookingPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
-
- const { data: acct } = await client
- .from('accounts')
- .select('id')
- .eq('slug', account)
- .single();
-
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return Konto nicht gefunden
;
const api = createBookingManagementApi(client);
-
- const [rooms, guests] = await Promise.all([
- api.listRooms(acct.id),
- api.listGuests(acct.id),
- ]);
+ const rooms = await api.listRooms(acct.id);
return (
-
-
- {/* Header */}
-
-
-
-
-
-
-
-
Neue Buchung
-
- Buchung für ein Zimmer erstellen
-
-
-
-
-
-
+
+ ) => ({
+ id: String(r.id), roomNumber: String(r.room_number), name: String(r.name ?? ''), pricePerNight: Number(r.price_per_night ?? 0)
+ }))}
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/courses/new/page.tsx b/apps/web/app/[locale]/home/[account]/courses/new/page.tsx
index 5be7d2355..fea0cc3dc 100644
--- a/apps/web/app/[locale]/home/[account]/courses/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/new/page.tsx
@@ -1,191 +1,18 @@
-import Link from 'next/link';
-
-import { ArrowLeft } from 'lucide-react';
-
-import { Button } from '@kit/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@kit/ui/card';
-import { Input } from '@kit/ui/input';
-import { Label } from '@kit/ui/label';
-import { Textarea } from '@kit/ui/textarea';
-
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { CreateCourseForm } from '@kit/course-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-interface PageProps {
- params: Promise<{ account: string }>;
-}
+interface Props { params: Promise<{ account: string }> }
-export default async function NewCoursePage({ params }: PageProps) {
+export default async function NewCoursePage({ params }: Props) {
const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
return (
-
-
- {/* Header */}
-
-
-
-
- Zurück
-
-
-
-
Neuer Kurs
-
Kurs anlegen
-
-
-
-
- {/* Grunddaten */}
-
-
- Grunddaten
-
- Allgemeine Informationen zum Kurs
-
-
-
-
- Kursnummer
-
-
-
-
- Kursname
-
-
-
-
- Beschreibung
-
-
-
-
-
- {/* Zeitplan */}
-
-
- Zeitplan
- Beginn und Ende des Kurses
-
-
-
- Beginn
-
-
-
-
- Ende
-
-
-
-
-
- {/* Kapazität */}
-
-
- Kapazität
-
- Teilnehmer und Gebühren
-
-
-
-
- Max. Teilnehmer
-
-
-
-
- Min. Teilnehmer
-
-
-
-
- Gebühr (€)
-
-
-
-
-
- {/* Zuordnung */}
-
-
- Zuordnung
- Status des Kurses
-
-
-
- Status
-
- Geplant
- Offen
- Laufend
- Abgeschlossen
- Abgesagt
-
-
-
-
-
- {/* Actions */}
-
-
-
- Abbrechen
-
-
- Kurs erstellen
-
-
-
+
+
);
}
diff --git a/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx
index f8265c117..2ebe734b8 100644
--- a/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx
@@ -1,10 +1,4 @@
-import {
- GraduationCap,
- Users,
- Calendar,
- TrendingUp,
- BarChart3,
-} from 'lucide-react';
+import { GraduationCap, Users, Calendar, TrendingUp, BarChart3 } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -13,6 +7,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card';
+import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
interface PageProps {
params: Promise<{ account: string }>;
@@ -22,77 +17,53 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
- const { data: acct } = await client
- .from('accounts')
- .select('id')
- .eq('slug', account)
- .single();
-
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return Konto nicht gefunden
;
const api = createCourseManagementApi(client);
const stats = await api.getStatistics(acct.id);
+ const statusChartData = [
+ { name: 'Aktiv', value: stats.openCourses },
+ { name: 'Abgeschlossen', value: stats.completedCourses },
+ { name: 'Gesamt', value: stats.totalCourses },
+ ];
+
return (
-
-
Statistiken
-
Übersicht über das Kursangebot
-
-
- {/* Stat Cards */}
- }
- />
- }
- />
- }
- />
- }
- />
+ } />
+ } />
+ } />
+ } />
- {/* Chart Placeholder */}
-
-
-
-
- Kursauslastung
-
-
-
-
- Diagramm wird hier angezeigt
-
-
-
+
+
+
+
+
+ Kursauslastung
+
+
+
+
+
+
-
-
-
-
- Anmeldungen pro Monat
-
-
-
-
- Diagramm wird hier angezeigt
-
-
-
+
+
+
+
+ Verteilung
+
+
+
+
+
+
+
);
diff --git a/apps/web/app/[locale]/home/[account]/events/new/page.tsx b/apps/web/app/[locale]/home/[account]/events/new/page.tsx
index a1bd756da..88ef8f21e 100644
--- a/apps/web/app/[locale]/home/[account]/events/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/events/new/page.tsx
@@ -1,301 +1,18 @@
-import Link from 'next/link';
-
-import {
- ArrowLeft,
- CalendarDays,
- Clock,
- MapPin,
- Phone,
- Users,
-} from 'lucide-react';
-
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-import { Button } from '@kit/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@kit/ui/card';
-
+import { CreateEventForm } from '@kit/event-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-interface PageProps {
- params: Promise<{ account: string }>;
-}
+interface Props { params: Promise<{ account: string }> }
-export default async function NewEventPage({ params }: PageProps) {
+export default async function NewEventPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
-
- const { data: acct } = await client
- .from('accounts')
- .select('id')
- .eq('slug', account)
- .single();
-
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return Konto nicht gefunden
;
return (
-
-
+
+
);
}
diff --git a/apps/web/app/[locale]/home/[account]/finance/invoices/new/page.tsx b/apps/web/app/[locale]/home/[account]/finance/invoices/new/page.tsx
index cb6c4fd54..4456e8d23 100644
--- a/apps/web/app/[locale]/home/[account]/finance/invoices/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/finance/invoices/new/page.tsx
@@ -1,212 +1,18 @@
-import Link from 'next/link';
-
-import { ArrowLeft } from 'lucide-react';
-
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-import { Button } from '@kit/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from '@kit/ui/card';
-import { Input } from '@kit/ui/input';
-import { Label } from '@kit/ui/label';
-import { Textarea } from '@kit/ui/textarea';
-
+import { CreateInvoiceForm } from '@kit/finance/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-interface PageProps {
- params: Promise<{ account: string }>;
-}
+interface Props { params: Promise<{ account: string }> }
-export default async function NewInvoicePage({ params }: PageProps) {
+export default async function NewInvoicePage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
-
- const { data: acct } = await client
- .from('accounts')
- .select('id')
- .eq('slug', account)
- .single();
-
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return Konto nicht gefunden
;
return (
-
-
- {/* Back link */}
-
-
-
- Zurück zu Rechnungen
-
-
-
-
-
- Neue Rechnung
-
- Erstellen Sie eine neue Rechnung mit Positionen.
-
-
-
-
-
- {/* Basic Info */}
-
-
- {/* Recipient Address */}
-
- Empfängeradresse
-
-
-
- {/* Dates + Tax */}
-
-
- {/* Line Items */}
-
-
Positionen
-
-
- {/* Totals */}
-
-
- Zwischensumme
- 0,00 €
-
-
- MwSt. (19%)
- 0,00 €
-
-
- Gesamt
- 0,00 €
-
-
-
-
-
-
-
-
- Abbrechen
-
- Rechnung erstellen
-
-
-
+
+
);
}
diff --git a/apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx b/apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx
index 9ea98838f..5dd9a318d 100644
--- a/apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx
@@ -1,27 +1,13 @@
-import Link from 'next/link';
-
-import { ArrowLeft } from 'lucide-react';
-
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-import { Button } from '@kit/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from '@kit/ui/card';
-import { Input } from '@kit/ui/input';
-import { Label } from '@kit/ui/label';
+import { CreateSepaBatchForm } from '@kit/finance/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-interface PageProps {
+interface Props {
params: Promise<{ account: string }>;
}
-export default async function NewSepaPage({ params }: PageProps) {
+export default async function NewSepaBatchPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
@@ -34,84 +20,8 @@ export default async function NewSepaPage({ params }: PageProps) {
if (!acct) return Konto nicht gefunden
;
return (
-
-
- {/* Back link */}
-
-
-
- Zurück zu SEPA-Lastschriften
-
-
-
-
-
- Neuer SEPA-Einzug
-
- Erstellen Sie einen neuen Lastschrifteinzug oder eine Überweisung.
-
-
-
-
-
- {/* Typ */}
-
- Typ
-
- Lastschrift
- Überweisung
-
-
-
- {/* Beschreibung */}
-
- Beschreibung
-
-
-
- {/* Ausführungsdatum */}
-
- Ausführungsdatum
-
-
-
- {/* PAIN Format Info */}
-
-
- Hinweis: Nach dem Erstellen können Sie
- einzelne Positionen hinzufügen und anschließend die
- SEPA-XML-Datei generieren.
-
-
-
-
-
-
-
- Abbrechen
-
- Einzug erstellen
-
-
-
+
+
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx
new file mode 100644
index 000000000..c42fa41e8
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx
@@ -0,0 +1,25 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { createMemberManagementApi } from '@kit/member-management/api';
+import { EditMemberForm } from '@kit/member-management/components';
+import { CmsPageShell } from '~/components/cms-page-shell';
+
+interface Props {
+ params: Promise<{ account: string; memberId: string }>;
+}
+
+export default async function EditMemberPage({ params }: Props) {
+ const { account, memberId } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ const api = createMemberManagementApi(client);
+ const member = await api.getMember(memberId);
+ if (!member) return Mitglied nicht gefunden
;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
index 9aa19972f..cf3fd9773 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
@@ -1,173 +1,25 @@
-import Link from 'next/link';
-
-import { User, Mail, Phone, MapPin, CreditCard, Pencil, Ban } from 'lucide-react';
-
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-import { Badge } from '@kit/ui/badge';
-import { Button } from '@kit/ui/button';
-import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
-
import { createMemberManagementApi } from '@kit/member-management/api';
-
+import { MemberDetailView } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-interface PageProps {
+interface Props {
params: Promise<{ account: string; memberId: string }>;
}
-const STATUS_LABEL: Record = {
- active: 'Aktiv',
- inactive: 'Inaktiv',
- pending: 'Ausstehend',
- cancelled: 'Gekündigt',
-};
-
-const STATUS_VARIANT: Record = {
- active: 'default',
- inactive: 'secondary',
- pending: 'outline',
- cancelled: 'destructive',
-};
-
-function DetailRow({ label, value }: { label: string; value: string }) {
- return (
-
- {label}
- {value || '—'}
-
- );
-}
-
-export default async function MemberDetailPage({ params }: PageProps) {
+export default async function MemberDetailPage({ params }: Props) {
const { account, memberId } = await params;
const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
const api = createMemberManagementApi(client);
-
const member = await api.getMember(memberId);
-
if (!member) return Mitglied nicht gefunden
;
- const m = member as Record;
-
return (
-
-
- {/* Header */}
-
-
-
- {String(m.first_name)} {String(m.last_name)}
-
-
-
- {STATUS_LABEL[String(m.status)] ?? String(m.status)}
-
- {m.member_number ? (
-
- Nr. {String(m.member_number)}
-
- ) : null}
-
-
-
-
-
- Bearbeiten
-
-
-
- Kündigen
-
-
-
-
-
- {/* Persönliche Daten */}
-
-
-
-
- Persönliche Daten
-
-
-
-
-
-
-
-
-
-
- {/* Kontakt */}
-
-
-
-
- Kontakt
-
-
-
-
-
-
-
-
-
- {/* Adresse */}
-
-
-
-
- Adresse
-
-
-
-
-
-
-
-
- {/* Mitgliedschaft */}
-
-
-
-
- Mitgliedschaft
-
-
-
-
-
-
-
-
-
-
- {/* SEPA */}
-
-
-
-
- SEPA-Bankverbindung
-
-
-
-
-
-
-
-
-
-
+
+
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx
index 92e6ed3b9..204b363f9 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx
@@ -1,117 +1,24 @@
-import { UserCheck, UserX, FileText } from 'lucide-react';
-
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-import { Badge } from '@kit/ui/badge';
-import { Button } from '@kit/ui/button';
-import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
-
import { createMemberManagementApi } from '@kit/member-management/api';
-
+import { ApplicationWorkflow } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-import { EmptyState } from '~/components/empty-state';
-interface PageProps {
+interface Props {
params: Promise<{ account: string }>;
}
-const STATUS_VARIANT: Record = {
- pending: 'secondary',
- approved: 'default',
- rejected: 'destructive',
-};
-
-const STATUS_LABEL: Record = {
- pending: 'Ausstehend',
- approved: 'Genehmigt',
- rejected: 'Abgelehnt',
-};
-
-export default async function ApplicationsPage({ params }: PageProps) {
+export default async function ApplicationsPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
-
- const { data: acct } = await client
- .from('accounts')
- .select('id')
- .eq('slug', account)
- .single();
-
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return Konto nicht gefunden
;
const api = createMemberManagementApi(client);
const applications = await api.listApplications(acct.id);
return (
-
-
-
-
Mitgliedsanträge
-
Eingehende Anträge prüfen und bearbeiten
-
-
- {applications.length === 0 ? (
-
}
- title="Keine Anträge"
- description="Es liegen derzeit keine Mitgliedsanträge vor."
- />
- ) : (
-
-
- Alle Anträge ({applications.length})
-
-
-
-
-
-
- Name
- E-Mail
- Datum
- Status
- Aktionen
-
-
-
- {applications.map((app: Record) => (
-
-
- {String(app.last_name ?? '')}, {String(app.first_name ?? '')}
-
- {String(app.email ?? '—')}
-
- {app.created_at
- ? new Date(String(app.created_at)).toLocaleDateString('de-DE')
- : '—'}
-
-
-
- {STATUS_LABEL[String(app.status)] ?? String(app.status)}
-
-
-
- {String(app.status) === 'pending' && (
-
-
-
- Genehmigen
-
-
-
- Ablehnen
-
-
- )}
-
-
- ))}
-
-
-
-
-
- )}
-
+
+
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx
new file mode 100644
index 000000000..be6a8b1f8
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx
@@ -0,0 +1,81 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { createMemberManagementApi } from '@kit/member-management/api';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Button } from '@kit/ui/button';
+import { Badge } from '@kit/ui/badge';
+import { CreditCard, Download } from 'lucide-react';
+import { CmsPageShell } from '~/components/cms-page-shell';
+import { EmptyState } from '~/components/empty-state';
+
+interface Props {
+ params: Promise<{ account: string }>;
+}
+
+export default async function MemberCardsPage({ params }: Props) {
+ const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ const api = createMemberManagementApi(client);
+ const result = await api.listMembers(acct.id, { status: 'active', pageSize: 100 });
+ const members = result.data;
+
+ return (
+
+
+
+
{members.length} aktive Mitglieder
+
+
+ Alle Ausweise generieren (PDF)
+
+
+
+ {members.length === 0 ? (
+
}
+ title="Keine aktiven Mitglieder"
+ description="Erstellen Sie zuerst Mitglieder, um Ausweise zu generieren."
+ actionLabel="Mitglieder verwalten"
+ actionHref={`/home/${account}/members-cms`}
+ />
+ ) : (
+
+ {members.map((m: Record
) => (
+
+
+
+
+
{String(m.last_name)}, {String(m.first_name)}
+
Nr. {String(m.member_number ?? '—')}
+
+
Aktiv
+
+
+
+
+ Ausweis
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ PDF-Generierung
+
+
+
+ Die PDF-Generierung erfordert die Installation von @react-pdf/renderer.
+ Nach der Installation können Mitgliedsausweise einzeln oder als Stapel erstellt werden.
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx
new file mode 100644
index 000000000..913baec8d
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx
@@ -0,0 +1,54 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { createMemberManagementApi } from '@kit/member-management/api';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Badge } from '@kit/ui/badge';
+import { Button } from '@kit/ui/button';
+import { CmsPageShell } from '~/components/cms-page-shell';
+import { EmptyState } from '~/components/empty-state';
+import { Users } from 'lucide-react';
+
+interface Props {
+ params: Promise<{ account: string }>;
+}
+
+export default async function DepartmentsPage({ params }: Props) {
+ const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ const api = createMemberManagementApi(client);
+ const departments = await api.listDepartments(acct.id);
+
+ return (
+
+ {departments.length === 0 ? (
+ }
+ title="Keine Abteilungen vorhanden"
+ description="Erstellen Sie Ihre erste Abteilung."
+ actionLabel="Neue Abteilung"
+ />
+ ) : (
+
+
+
+
+ Name
+ Beschreibung
+
+
+
+ {departments.map((dept: Record) => (
+
+ {String(dept.name)}
+ {String(dept.description ?? '—')}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/dues/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/dues/page.tsx
index 2b9eb5f98..ad11cb834 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/dues/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/dues/page.tsx
@@ -1,95 +1,24 @@
-import { Euro, Plus } from 'lucide-react';
-
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-import { Badge } from '@kit/ui/badge';
-import { Button } from '@kit/ui/button';
-import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
-
import { createMemberManagementApi } from '@kit/member-management/api';
-
+import { DuesCategoryManager } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-import { EmptyState } from '~/components/empty-state';
-interface PageProps {
+interface Props {
params: Promise<{ account: string }>;
}
-export default async function DuesPage({ params }: PageProps) {
+export default async function DuesPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
-
- const { data: acct } = await client
- .from('accounts')
- .select('id')
- .eq('slug', account)
- .single();
-
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return Konto nicht gefunden
;
const api = createMemberManagementApi(client);
const categories = await api.listDuesCategories(acct.id);
return (
-
-
-
-
-
Beitragskategorien
-
Beiträge und Gebühren verwalten
-
-
-
- Neue Kategorie
-
-
-
- {categories.length === 0 ? (
-
}
- title="Keine Beitragskategorien"
- description="Legen Sie Ihre erste Beitragskategorie an."
- actionLabel="Neue Kategorie"
- />
- ) : (
-
-
- Alle Kategorien ({categories.length})
-
-
-
-
-
-
- Name
- Beschreibung
- Betrag (€)
- Intervall
- Standard
-
-
-
- {categories.map((cat: Record) => (
-
- {String(cat.name)}
-
- {String(cat.description ?? '—')}
-
-
- {cat.amount != null ? `${Number(cat.amount).toFixed(2)}` : '—'}
-
- {String(cat.interval ?? '—')}
-
- {cat.is_default ? '✓' : '✗'}
-
-
- ))}
-
-
-
-
-
- )}
-
+
+
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/import/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/import/page.tsx
new file mode 100644
index 000000000..34fcd9c3f
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/members-cms/import/page.tsx
@@ -0,0 +1,20 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { MemberImportWizard } from '@kit/member-management/components';
+import { CmsPageShell } from '~/components/cms-page-shell';
+
+interface Props {
+ params: Promise<{ account: string }>;
+}
+
+export default async function MemberImportPage({ params }: Props) {
+ const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
index e46a59500..de6d0c137 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
@@ -1,183 +1,28 @@
-import { UserPlus } from 'lucide-react';
-
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-import { Button } from '@kit/ui/button';
-import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
-import { Input } from '@kit/ui/input';
-import { Label } from '@kit/ui/label';
-
+import { createMemberManagementApi } from '@kit/member-management/api';
+import { CreateMemberForm } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-interface PageProps {
- params: Promise<{ account: string }>;
-}
+interface Props { params: Promise<{ account: string }> }
-export default async function NewMemberPage({ params }: PageProps) {
+export default async function NewMemberPage({ params }: Props) {
const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ const api = createMemberManagementApi(client);
+ const duesCategories = await api.listDuesCategories(acct.id);
return (
-
-
+
+ ) => ({
+ id: String(c.id), name: String(c.name), amount: Number(c.amount ?? 0)
+ }))}
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx
index 7347777aa..30a1f7dfa 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx
@@ -1,66 +1,42 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-
import { createMemberManagementApi } from '@kit/member-management/api';
+import { MembersDataTable } from '@kit/member-management/components';
+import { CmsPageShell } from '~/components/cms-page-shell';
-interface MembersPageProps {
+interface Props {
params: Promise<{ account: string }>;
searchParams: Promise>;
}
-export default async function MembersPage({ params, searchParams }: MembersPageProps) {
+export default async function MembersPage({ params, searchParams }: Props) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
const api = createMemberManagementApi(client);
-
- const { data: accountData } = await client
- .from('accounts')
- .select('id')
- .eq('slug', account)
- .single();
-
- if (!accountData) return Konto nicht gefunden
;
-
const page = Number(search.page) || 1;
- const result = await api.listMembers(accountData.id, {
+ const result = await api.listMembers(acct.id, {
search: search.q as string,
status: search.status as string,
page,
+ pageSize: 25,
});
+ const duesCategories = await api.listDuesCategories(acct.id);
return (
-
-
-
-
Mitglieder
-
{result.total} Mitglieder
-
-
-
-
-
-
-
- Nr.
- Name
- E-Mail
- Ort
- Status
-
-
-
- {result.data.map((member: Record) => (
-
- {String(member.member_number ?? '—')}
- {String(member.last_name)}, {String(member.first_name)}
- {String(member.email ?? '—')}
- {String(member.postal_code ?? '')} {String(member.city ?? '')}
- {String(member.status)}
-
- ))}
-
-
-
-
+
+ ) => ({
+ id: String(c.id), name: String(c.name),
+ }))}
+ />
+
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx
index 9ccac65db..108e7af13 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx
@@ -7,6 +7,7 @@ import { createMemberManagementApi } from '@kit/member-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card';
+import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
interface PageProps {
params: Promise<{ account: string }>;
@@ -27,65 +28,48 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
const api = createMemberManagementApi(client);
const stats = await api.getMemberStatistics(acct.id);
+ const statusChartData = [
+ { name: 'Aktiv', value: stats.active ?? 0 },
+ { name: 'Inaktiv', value: stats.inactive ?? 0 },
+ { name: 'Ausstehend', value: stats.pending ?? 0 },
+ { name: 'Ausgetreten', value: stats.resigned ?? 0 },
+ ];
+
return (
-
-
Mitglieder-Statistiken
-
Übersicht über Ihre Mitglieder
-
-
- }
- />
- }
- />
- }
- />
- }
- />
+ } />
+ } />
+ } />
+ } />
- {/* Chart Placeholders */}
-
-
-
-
- Mitgliederentwicklung
-
-
-
-
- Diagramm wird hier angezeigt
-
-
-
+
+
+
+
+
+ Mitglieder nach Status
+
+
+
+
+
+
-
-
-
-
- Eintritte / Austritte pro Monat
-
-
-
-
- Diagramm wird hier angezeigt
-
-
-
+
+
+
+
+ Verteilung
+
+
+
+
+
+
+
);
diff --git a/apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx
index b13950f23..95d173f37 100644
--- a/apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx
@@ -1,125 +1,18 @@
-import Link from 'next/link';
-
-import { ArrowLeft } from 'lucide-react';
-
import { getSupabaseServerClient } from '@kit/supabase/server-client';
-import { Button } from '@kit/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from '@kit/ui/card';
-import { Input } from '@kit/ui/input';
-import { Label } from '@kit/ui/label';
-import { Textarea } from '@kit/ui/textarea';
-
+import { CreateNewsletterForm } from '@kit/newsletter/components';
import { CmsPageShell } from '~/components/cms-page-shell';
-interface PageProps {
- params: Promise<{ account: string }>;
-}
+interface Props { params: Promise<{ account: string }> }
-export default async function NewNewsletterPage({ params }: PageProps) {
+export default async function NewNewsletterPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
-
- const { data: acct } = await client
- .from('accounts')
- .select('id')
- .eq('slug', account)
- .single();
-
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return Konto nicht gefunden
;
return (
-
-
- {/* Back link */}
-
-
-
- Zurück zu Newsletter
-
-
-
-
-
- Neuer Newsletter
-
- Erstellen Sie eine neue Newsletter-Kampagne.
-
-
-
-
-
- {/* Betreff */}
-
- Betreff
-
-
-
- {/* Body HTML */}
-
-
Inhalt (HTML)
-
-
- Verwenden Sie {'{{first_name}}'}, {'{{name}}'} und{' '}
- {'{{email}}'} als Platzhalter für die Personalisierung.
-
-
-
- {/* Empfänger Info */}
-
-
- Empfänger-Auswahl: Nach dem Erstellen können
- Sie die Empfänger aus Ihrer Mitgliederliste auswählen. Es
- werden nur Mitglieder mit hinterlegter E-Mail-Adresse
- berücksichtigt.
-
-
-
- {/* Vorlage Auswahl */}
-
-
- Vorlage (optional)
-
-
- Keine Vorlage
-
-
-
-
-
-
-
- Abbrechen
-
- Newsletter erstellen
-
-
-
+
+
);
}
diff --git a/apps/web/app/[locale]/home/[account]/site-builder/[pageId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/[pageId]/edit/page.tsx
new file mode 100644
index 000000000..c98d475bc
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/site-builder/[pageId]/edit/page.tsx
@@ -0,0 +1,18 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { createSiteBuilderApi } from '@kit/site-builder/api';
+import { SiteEditor } from '@kit/site-builder/components';
+
+interface Props { params: Promise<{ account: string; pageId: string }> }
+
+export default async function EditPageRoute({ params }: Props) {
+ const { account, pageId } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ const api = createSiteBuilderApi(client);
+ const page = await api.getPage(pageId);
+ if (!page) return Seite nicht gefunden
;
+
+ return } />;
+}
diff --git a/apps/web/app/[locale]/home/[account]/site-builder/new/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/new/page.tsx
new file mode 100644
index 000000000..f0e461218
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/site-builder/new/page.tsx
@@ -0,0 +1,20 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { CreatePageForm } from '@kit/site-builder/components';
+import { CmsPageShell } from '~/components/cms-page-shell';
+
+interface Props {
+ params: Promise<{ account: string }>;
+}
+
+export default async function NewSitePage({ params }: Props) {
+ const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/site-builder/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/page.tsx
new file mode 100644
index 000000000..f66000f60
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/site-builder/page.tsx
@@ -0,0 +1,95 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { createSiteBuilderApi } from '@kit/site-builder/api';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Badge } from '@kit/ui/badge';
+import { Button } from '@kit/ui/button';
+import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react';
+import Link from 'next/link';
+import { CmsPageShell } from '~/components/cms-page-shell';
+import { EmptyState } from '~/components/empty-state';
+
+interface Props { params: Promise<{ account: string }> }
+
+export default async function SiteBuilderDashboard({ params }: Props) {
+ const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ const api = createSiteBuilderApi(client);
+ const pages = await api.listPages(acct.id);
+ const settings = await api.getSiteSettings(acct.id);
+ const posts = await api.listPosts(acct.id);
+
+ const publishedCount = pages.filter((p: any) => p.is_published).length;
+
+ return (
+
+
+
+
+
+
Einstellungen
+
+
+
Beiträge ({posts.length})
+
+ {settings?.is_public && (
+
+ Website ansehen
+
+ )}
+
+
+
Neue Seite
+
+
+
+
+
Seiten
{pages.length}
+
Veröffentlicht
{publishedCount}
+
Status
{settings?.is_public ? '🟢 Online' : '🔴 Offline'}
+
+
+ {pages.length === 0 ? (
+
}
+ title="Noch keine Seiten"
+ description="Erstellen Sie Ihre erste Seite mit dem visuellen Editor."
+ actionLabel="Erste Seite erstellen"
+ actionHref={`/home/${account}/site-builder/new`}
+ />
+ ) : (
+
+
+
+ Titel
+ URL
+ Status
+ Startseite
+ Aktualisiert
+ Aktionen
+
+
+ {pages.map((page: Record) => (
+
+ {String(page.title)}
+ /{String(page.slug)}
+ {page.is_published ? 'Veröffentlicht' : 'Entwurf'}
+ {page.is_homepage ? '⭐' : '—'}
+ {page.updated_at ? new Date(String(page.updated_at)).toLocaleDateString('de-DE') : '—'}
+
+
+ Bearbeiten
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/site-builder/posts/new/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/posts/new/page.tsx
new file mode 100644
index 000000000..dd3a026b5
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/site-builder/posts/new/page.tsx
@@ -0,0 +1,18 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { CreatePostForm } from '@kit/site-builder/components';
+import { CmsPageShell } from '~/components/cms-page-shell';
+
+interface Props { params: Promise<{ account: string }> }
+
+export default async function NewPostPage({ params }: Props) {
+ const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/site-builder/posts/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/posts/page.tsx
new file mode 100644
index 000000000..ee139418e
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/site-builder/posts/page.tsx
@@ -0,0 +1,53 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { createSiteBuilderApi } from '@kit/site-builder/api';
+import { Card, CardContent } from '@kit/ui/card';
+import { Badge } from '@kit/ui/badge';
+import { Button } from '@kit/ui/button';
+import { Plus } from 'lucide-react';
+import Link from 'next/link';
+import { CmsPageShell } from '~/components/cms-page-shell';
+import { EmptyState } from '~/components/empty-state';
+
+interface Props { params: Promise<{ account: string }> }
+
+export default async function PostsManagerPage({ params }: Props) {
+ const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ const api = createSiteBuilderApi(client);
+ const posts = await api.listPosts(acct.id);
+
+ return (
+
+
+
+ {posts.length === 0 ? (
+
+ ) : (
+
+
+
+ Titel
+ Status
+ Erstellt
+
+
+ {posts.map((post: Record) => (
+
+ {String(post.title)}
+ {String(post.status)}
+ {post.created_at ? new Date(String(post.created_at)).toLocaleDateString('de-DE') : '—'}
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/site-builder/settings/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/settings/page.tsx
new file mode 100644
index 000000000..c2a8da79d
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/site-builder/settings/page.tsx
@@ -0,0 +1,22 @@
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { createSiteBuilderApi } from '@kit/site-builder/api';
+import { SiteSettingsForm } from '@kit/site-builder/components';
+import { CmsPageShell } from '~/components/cms-page-shell';
+
+interface Props { params: Promise<{ account: string }> }
+
+export default async function SiteSettingsPage({ params }: Props) {
+ const { account } = await params;
+ const client = getSupabaseServerClient();
+ const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
+ if (!acct) return Konto nicht gefunden
;
+
+ const api = createSiteBuilderApi(client);
+ const settings = await api.getSiteSettings(acct.id);
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/api/club/accept-invite/route.ts b/apps/web/app/api/club/accept-invite/route.ts
new file mode 100644
index 000000000..5b0b06aff
--- /dev/null
+++ b/apps/web/app/api/club/accept-invite/route.ts
@@ -0,0 +1,75 @@
+import { createClient } from '@supabase/supabase-js';
+import { NextResponse } from 'next/server';
+
+export async function POST(request: Request) {
+ try {
+ const formData = await request.formData();
+ const token = formData.get('token') as string;
+ const slug = formData.get('slug') as string;
+ const password = formData.get('password') as string;
+
+ if (!token || !password || password.length < 8) {
+ return NextResponse.json({ error: 'Ungültige Eingabe' }, { status: 400 });
+ }
+
+ // Use service role to create user + link member
+ const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SECRET_KEY!,
+ );
+
+ // 1. Get invitation
+ const { data: invitation, error: invError } = await supabase
+ .from('member_portal_invitations')
+ .select('id, email, member_id, account_id, status, expires_at')
+ .eq('invite_token', token)
+ .single();
+
+ if (invError || !invitation || invitation.status !== 'pending') {
+ return NextResponse.redirect(new URL(`/club/${slug}/portal/invite?token=${token}&error=invalid`, request.url));
+ }
+
+ if (new Date(invitation.expires_at) < new Date()) {
+ return NextResponse.redirect(new URL(`/club/${slug}/portal/invite?token=${token}&error=expired`, request.url));
+ }
+
+ // 2. Create auth user
+ const { data: authData, error: authError } = await supabase.auth.admin.createUser({
+ email: invitation.email,
+ password,
+ email_confirm: true,
+ user_metadata: { invited_via: 'member_portal' },
+ });
+
+ if (authError) {
+ // User might already exist — try to find them
+ const { data: existingUsers } = await supabase.auth.admin.listUsers();
+ const existing = existingUsers?.users?.find(u => u.email === invitation.email);
+
+ if (existing) {
+ // Link existing user to member
+ await supabase.from('members').update({ user_id: existing.id }).eq('id', invitation.member_id);
+ await supabase.from('member_portal_invitations').update({ status: 'accepted', accepted_at: new Date().toISOString() }).eq('id', invitation.id);
+ return NextResponse.redirect(new URL(`/club/${slug}/portal`, request.url));
+ }
+
+ console.error('[accept-invite] Auth error:', authError.message);
+ return NextResponse.redirect(new URL(`/club/${slug}/portal/invite?token=${token}&error=auth`, request.url));
+ }
+
+ // 3. Link member to user
+ await supabase.from('members').update({ user_id: authData.user.id }).eq('id', invitation.member_id);
+
+ // 4. Mark invitation as accepted
+ await supabase.from('member_portal_invitations').update({
+ status: 'accepted',
+ accepted_at: new Date().toISOString(),
+ }).eq('id', invitation.id);
+
+ // 5. Redirect to portal login
+ return NextResponse.redirect(new URL(`/club/${slug}/portal`, request.url));
+ } catch (err) {
+ console.error('[accept-invite] Error:', err);
+ return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/club/contact/route.ts b/apps/web/app/api/club/contact/route.ts
new file mode 100644
index 000000000..c8f64bb2a
--- /dev/null
+++ b/apps/web/app/api/club/contact/route.ts
@@ -0,0 +1,40 @@
+import { NextResponse } from 'next/server';
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const { recipientEmail, name, email, subject, message } = body;
+
+ if (!email || !message) {
+ return NextResponse.json({ error: 'E-Mail und Nachricht sind erforderlich' }, { status: 400 });
+ }
+
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ return NextResponse.json({ error: 'Ungültige E-Mail-Adresse' }, { status: 400 });
+ }
+
+ // In production: use @kit/mailers to send the email
+ // For now: log and return success
+ console.log('[contact] Form submission:', {
+ to: recipientEmail || 'admin',
+ from: email,
+ name,
+ subject: subject || 'Kontaktanfrage',
+ message,
+ });
+
+ // TODO: Wire to @kit/mailers
+ // const mailer = await getMailer();
+ // await mailer.sendMail({
+ // to: recipientEmail,
+ // from: email,
+ // subject: subject || 'Kontaktanfrage von der Website',
+ // text: `Name: ${name}\nE-Mail: ${email}\n\n${message}`,
+ // });
+
+ return NextResponse.json({ success: true, message: 'Nachricht gesendet' });
+ } catch (err) {
+ console.error('[contact] Error:', err);
+ return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/api/club/newsletter/route.ts b/apps/web/app/api/club/newsletter/route.ts
new file mode 100644
index 000000000..e228c8ff2
--- /dev/null
+++ b/apps/web/app/api/club/newsletter/route.ts
@@ -0,0 +1,42 @@
+import { createClient } from '@supabase/supabase-js';
+import { NextResponse } from 'next/server';
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const { accountId, email, name } = body;
+
+ if (!accountId || !email) {
+ return NextResponse.json({ error: 'accountId und email sind erforderlich' }, { status: 400 });
+ }
+
+ // Validate email format
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ return NextResponse.json({ error: 'Ungültige E-Mail-Adresse' }, { status: 400 });
+ }
+
+ const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
+ );
+
+ const token = crypto.randomUUID();
+ const { error } = await supabase.from('newsletter_subscriptions').upsert({
+ account_id: accountId,
+ email,
+ name: name || null,
+ confirmation_token: token,
+ is_active: true,
+ }, { onConflict: 'account_id,email' });
+
+ if (error) {
+ console.error('[newsletter] Subscription error:', error.message);
+ return NextResponse.json({ error: 'Anmeldung fehlgeschlagen' }, { status: 500 });
+ }
+
+ return NextResponse.json({ success: true, message: 'Erfolgreich angemeldet' });
+ } catch (err) {
+ console.error('[newsletter] Error:', err);
+ return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
+ }
+}
diff --git a/apps/web/components/stats-charts.tsx b/apps/web/components/stats-charts.tsx
new file mode 100644
index 000000000..508300a84
--- /dev/null
+++ b/apps/web/components/stats-charts.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import {
+ BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
+ PieChart, Pie, Cell, Legend,
+} from 'recharts';
+
+const COLORS = ['#0d9488', '#14b8a6', '#2dd4bf', '#5eead4', '#99f6e4', '#ccfbf1'];
+
+interface BarChartData {
+ name: string;
+ value: number;
+}
+
+interface PieChartData {
+ name: string;
+ value: number;
+}
+
+export function StatsBarChart({ data, title }: { data: BarChartData[]; title?: string }) {
+ if (data.length === 0 || data.every(d => d.value === 0)) {
+ return (
+
+ Noch keine Daten vorhanden
+
+ );
+ }
+
+ return (
+
+ {title &&
{title}
}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function StatsPieChart({ data, title }: { data: PieChartData[]; title?: string }) {
+ const filtered = data.filter(d => d.value > 0);
+ if (filtered.length === 0) {
+ return (
+
+ Noch keine Daten vorhanden
+
+ );
+ }
+
+ return (
+
+ {title &&
{title}
}
+
+
+ `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}>
+ {filtered.map((_, i) => (
+ |
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/config/feature-flags.config.ts b/apps/web/config/feature-flags.config.ts
index 17b15c081..e7128773d 100644
--- a/apps/web/config/feature-flags.config.ts
+++ b/apps/web/config/feature-flags.config.ts
@@ -50,6 +50,7 @@ const FeatureFlagsSchema = z.object({
enableDocumentGeneration: z.boolean().default(true),
enableNewsletter: z.boolean().default(true),
enableGdprCompliance: z.boolean().default(true),
+ enableSiteBuilder: z.boolean().default(true),
});
const featuresFlagConfig = FeatureFlagsSchema.parse({
@@ -132,6 +133,10 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({
process.env.NEXT_PUBLIC_ENABLE_GDPR_COMPLIANCE,
true,
),
+ enableSiteBuilder: getBoolean(
+ process.env.NEXT_PUBLIC_ENABLE_SITE_BUILDER,
+ true,
+ ),
} satisfies z.output);
export default featuresFlagConfig;
diff --git a/apps/web/config/paths.config.ts b/apps/web/config/paths.config.ts
index 67509b7f9..71f4daedb 100644
--- a/apps/web/config/paths.config.ts
+++ b/apps/web/config/paths.config.ts
@@ -30,6 +30,7 @@ const PathsSchema = z.object({
accountFinance: z.string().min(1),
accountDocuments: z.string().min(1),
accountNewsletter: z.string().min(1),
+ accountSiteBuilder: z.string().min(1),
}),
});
@@ -63,6 +64,7 @@ const pathsConfig = PathsSchema.parse({
accountFinance: `/home/[account]/finance`,
accountDocuments: `/home/[account]/documents`,
accountNewsletter: `/home/[account]/newsletter`,
+ accountSiteBuilder: `/home/[account]/site-builder`,
},
} satisfies z.output);
diff --git a/apps/web/config/team-account-navigation.config.tsx b/apps/web/config/team-account-navigation.config.tsx
index 1e274d991..293ac18bb 100644
--- a/apps/web/config/team-account-navigation.config.tsx
+++ b/apps/web/config/team-account-navigation.config.tsx
@@ -1,7 +1,7 @@
import {
CreditCard, LayoutDashboard, Settings, Users, Database,
UserCheck, GraduationCap, Hotel, Calendar, Wallet,
- FileText, Mail,
+ FileText, Mail, Globe,
} from 'lucide-react';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
@@ -75,6 +75,11 @@ const getRoutes = (account: string) => [
Icon: ,
}
: undefined,
+ {
+ label: 'common.routes.siteBuilder',
+ path: createPath(`/home/[account]/site-builder`, account),
+ Icon: ,
+ },
].filter(Boolean),
},
{
diff --git a/apps/web/i18n/messages/de/common.json b/apps/web/i18n/messages/de/common.json
index ed3136e96..c2a96071b 100644
--- a/apps/web/i18n/messages/de/common.json
+++ b/apps/web/i18n/messages/de/common.json
@@ -74,7 +74,8 @@
"finance": "Finanzen",
"documents": "Dokumente",
"newsletter": "Newsletter",
- "events": "Veranstaltungen"
+ "events": "Veranstaltungen",
+ "siteBuilder": "Website"
},
"roles": {
"owner": {
diff --git a/apps/web/i18n/messages/de/marketing.json b/apps/web/i18n/messages/de/marketing.json
index d9a685be7..a97597998 100644
--- a/apps/web/i18n/messages/de/marketing.json
+++ b/apps/web/i18n/messages/de/marketing.json
@@ -41,6 +41,78 @@
"contactError": "Fehler beim Senden Ihrer Nachricht",
"contactSuccessDescription": "Wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich",
"contactErrorDescription": "Beim Senden ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut",
- "footerDescription": "Hier können Sie eine Beschreibung Ihres Unternehmens oder Produkts einfügen",
- "copyright": "© Copyright {year} {product}. Alle Rechte vorbehalten."
+ "footerDescription": "Die All-in-One-Verwaltungsplattform für Vereine, Clubs und Organisationen. Entwickelt von Com.BISS GmbH.",
+ "copyright": "© Copyright {year} {product}. Alle Rechte vorbehalten.",
+
+ "heroPill": "Die nächste Generation der Vereinsverwaltung",
+ "heroTitle": "Verwalten Sie Ihre Organisation. Einfach und effizient.",
+ "heroSubtitle": "MyEasyCMS ist die All-in-One-Plattform für Vereine, Clubs und Organisationen. Verwalten Sie Mitglieder, Kurse, Veranstaltungen, Finanzen und mehr — alles an einem Ort.",
+
+ "trustedBy": "Vertraut von Vereinen und Clubs in ganz Deutschland",
+ "trustAssociations": "Vereine",
+ "trustSchools": "Bildungseinrichtungen",
+ "trustClubs": "Sport- & Angelvereine",
+ "trustOrganizations": "Gemeinnützige Organisationen",
+
+ "featuresHeading": "Alles, was Ihre Organisation braucht",
+ "featuresSubheading": "Von der Mitgliederverwaltung bis zur Finanzbuchhaltung — alle Werkzeuge in einer modernen, benutzerfreundlichen Plattform.",
+ "featuresLabel": "Kernmodule",
+
+ "featureMembersTitle": "Mitgliederverwaltung",
+ "featureMembersDesc": "Verwalten Sie alle Mitglieder mit Abteilungen, Beitragsverfolgung, Mitgliedsausweisen, Anträgen und detaillierten Statistiken.",
+ "featureCoursesTitle": "Kursverwaltung",
+ "featureCoursesDesc": "Organisieren Sie Kurse mit Terminplanung, Dozentenzuweisung, Anwesenheitsverfolgung, Kategorien und Standorten.",
+ "featureBookingsTitle": "Raumbuchungen",
+ "featureBookingsDesc": "Buchen Sie Räume und Ressourcen mit einem visuellen Kalender, verwalten Sie Gäste und prüfen Sie die Verfügbarkeit.",
+ "featureEventsTitle": "Veranstaltungsverwaltung",
+ "featureEventsDesc": "Planen und verwalten Sie Veranstaltungen mit Anmeldungen, Ferienpässen und Teilnehmerverfolgung.",
+ "featureFinanceTitle": "Finanzen & Abrechnung",
+ "featureFinanceDesc": "Erstellen Sie Rechnungen, verwalten Sie Zahlungen und SEPA-Lastschrifteinzüge — behalten Sie Ihre Finanzen mühelos im Griff.",
+ "featureNewsletterTitle": "Newsletter",
+ "featureNewsletterDesc": "Erstellen und versenden Sie professionelle Newsletter mit Vorlagen. Halten Sie Ihre Mitglieder informiert.",
+
+ "showcaseHeading": "Ein leistungsstarkes Dashboard auf einen Blick",
+ "showcaseDescription": "Erhalten Sie einen vollständigen Überblick über Ihre Organisation mit unserem intuitiven Dashboard. Greifen Sie auf alles zu — Mitglieder, Kurse, Veranstaltungen und Finanzen — von einer zentralen Stelle aus.",
+
+ "additionalFeaturesHeading": "Und es gibt noch mehr",
+ "additionalFeaturesSubheading": "Zusätzliche Werkzeuge, die jeden Aspekt der täglichen Arbeit Ihrer Organisation vereinfachen.",
+ "additionalFeaturesLabel": "Weitere Funktionen",
+
+ "featureDocumentsTitle": "Dokumentenverwaltung",
+ "featureDocumentsDesc": "Erstellen Sie Dokumente aus Vorlagen, verwalten Sie Dateien und halten Sie alle wichtigen Unterlagen organisiert.",
+ "featureSiteBuilderTitle": "Website-Baukasten",
+ "featureSiteBuilderDesc": "Erstellen und verwalten Sie die Website Ihrer Organisation ohne Programmierkenntnisse. Aktualisieren Sie Inhalte ganz einfach.",
+ "featureModulesTitle": "Individuelle Module",
+ "featureModulesDesc": "Erweitern Sie die Plattform mit maßgeschneiderten Modulen für Ihre spezifischen Anforderungen. Importieren Sie Daten und passen Sie Einstellungen an.",
+
+ "whyChooseHeading": "Warum Organisationen MyEasyCMS wählen",
+ "whyChooseDescription": "Entwickelt mit über 20 Jahren Erfahrung im Dienste von Vereinen, Clubs und gemeinnützigen Organisationen in ganz Deutschland.",
+ "whyResponsiveTitle": "Mobilfreundlich",
+ "whyResponsiveDesc": "Greifen Sie von jedem Gerät auf Ihre Daten zu. Unser responsives Design funktioniert perfekt auf Desktop, Tablet und Smartphone.",
+ "whySecureTitle": "Sicher & Zuverlässig",
+ "whySecureDesc": "Ihre Daten sind mit erstklassiger Sicherheit geschützt. Regelmäßige Backups stellen sicher, dass nichts verloren geht.",
+ "whySupportTitle": "Persönlicher Support",
+ "whySupportDesc": "Erhalten Sie direkten, persönlichen Support von unserem Team. Wir sprechen Ihre Sprache und verstehen Ihre Bedürfnisse.",
+ "whyGdprTitle": "DSGVO-konform",
+ "whyGdprDesc": "Vollständig konform mit der europäischen Datenschutz-Grundverordnung. Die Daten Ihrer Mitglieder werden sorgfältig behandelt.",
+
+ "howItWorksHeading": "In drei einfachen Schritten loslegen",
+ "howItWorksSubheading": "Die Einrichtung Ihrer Organisation auf MyEasyCMS dauert nur wenige Minuten.",
+ "howStep1Title": "Konto erstellen",
+ "howStep1Desc": "Registrieren Sie sich kostenlos und richten Sie Ihr Organisationsprofil ein. Keine Kreditkarte erforderlich.",
+ "howStep2Title": "Module konfigurieren",
+ "howStep2Desc": "Aktivieren Sie die benötigten Module — Mitglieder, Kurse, Veranstaltungen, Finanzen — und passen Sie diese an Ihren Workflow an.",
+ "howStep3Title": "Team einladen",
+ "howStep3Desc": "Fügen Sie Teammitglieder mit verschiedenen Rollen und Berechtigungen hinzu. Verwalten Sie Ihre Organisation gemeinsam.",
+
+ "pricingPillLabel": "Kostenlos starten",
+ "pricingPillText": "Keine Kreditkarte erforderlich.",
+ "pricingHeading": "Faire Preise für alle Arten von Organisationen",
+ "pricingSubheading": "Starten Sie mit unserem kostenlosen Tarif und upgraden Sie, wenn Sie bereit sind.",
+
+ "ctaHeading": "Bereit, die Verwaltung Ihrer Organisation zu vereinfachen?",
+ "ctaDescription": "Schließen Sie sich hunderten von Vereinen, Clubs und Organisationen an, die MyEasyCMS bereits nutzen.",
+ "ctaButtonPrimary": "Jetzt kostenlos starten",
+ "ctaButtonSecondary": "Kontakt aufnehmen",
+ "ctaNote": "Keine Kreditkarte erforderlich. Kostenloser Tarif verfügbar."
}
diff --git a/apps/web/i18n/messages/en/common.json b/apps/web/i18n/messages/en/common.json
index 5627f97bb..aa7a4fd06 100644
--- a/apps/web/i18n/messages/en/common.json
+++ b/apps/web/i18n/messages/en/common.json
@@ -72,6 +72,7 @@
"courses": "Courses",
"bookings": "Bookings",
"events": "Events",
+ "siteBuilder": "Website",
"finance": "Finance",
"documents": "Documents",
"newsletter": "Newsletter"
diff --git a/apps/web/i18n/messages/en/marketing.json b/apps/web/i18n/messages/en/marketing.json
index 7354ff1c3..b15baa60b 100644
--- a/apps/web/i18n/messages/en/marketing.json
+++ b/apps/web/i18n/messages/en/marketing.json
@@ -41,6 +41,78 @@
"contactError": "An error occurred while sending your message",
"contactSuccessDescription": "We have received your message and will get back to you as soon as possible",
"contactErrorDescription": "An error occurred while sending your message. Please try again later",
- "footerDescription": "Here you can add a description about your company or product",
- "copyright": "© Copyright {year} {product}. All Rights Reserved."
+ "footerDescription": "The all-in-one management platform for associations, clubs, and organizations. Built by Com.BISS GmbH.",
+ "copyright": "© Copyright {year} {product}. All Rights Reserved.",
+
+ "heroPill": "The next generation of association management",
+ "heroTitle": "Manage your organization. Simply and efficiently.",
+ "heroSubtitle": "MyEasyCMS is the all-in-one platform for associations, clubs, and organizations. Manage members, courses, events, finances, and more — all from one place.",
+
+ "trustedBy": "Trusted by associations and clubs across Germany",
+ "trustAssociations": "Associations",
+ "trustSchools": "Educational Institutions",
+ "trustClubs": "Sports & Fishing Clubs",
+ "trustOrganizations": "Non-Profit Organizations",
+
+ "featuresHeading": "Everything your organization needs",
+ "featuresSubheading": "From member management to finance — all the tools you need in one modern, easy-to-use platform.",
+ "featuresLabel": "Core Modules",
+
+ "featureMembersTitle": "Member Management",
+ "featureMembersDesc": "Manage all your members with departments, dues tracking, membership cards, applications, and detailed statistics.",
+ "featureCoursesTitle": "Course Management",
+ "featureCoursesDesc": "Organize courses with scheduling, instructor assignment, attendance tracking, categories, and locations.",
+ "featureBookingsTitle": "Room Bookings",
+ "featureBookingsDesc": "Book rooms and resources with a visual calendar, manage guests, and track availability at a glance.",
+ "featureEventsTitle": "Event Management",
+ "featureEventsDesc": "Plan and manage events with registrations, holiday passes, and participant tracking.",
+ "featureFinanceTitle": "Finance & Billing",
+ "featureFinanceDesc": "Handle invoices, payments, and SEPA direct debit collections — keep your finances organized effortlessly.",
+ "featureNewsletterTitle": "Newsletter",
+ "featureNewsletterDesc": "Create and send professional newsletters with templates. Keep your members informed and engaged.",
+
+ "showcaseHeading": "A powerful dashboard at your fingertips",
+ "showcaseDescription": "Get a complete overview of your organization with our intuitive dashboard. Access everything you need — members, courses, events, and finances — from one central hub.",
+
+ "additionalFeaturesHeading": "And there's more",
+ "additionalFeaturesSubheading": "Additional tools to streamline every aspect of your organization's daily work.",
+ "additionalFeaturesLabel": "More Features",
+
+ "featureDocumentsTitle": "Document Management",
+ "featureDocumentsDesc": "Generate documents from templates, manage files, and keep all your important documents organized.",
+ "featureSiteBuilderTitle": "Website Builder",
+ "featureSiteBuilderDesc": "Create and manage your organization's website without any programming knowledge. Update content with ease.",
+ "featureModulesTitle": "Custom Modules",
+ "featureModulesDesc": "Extend the platform with custom modules tailored to your specific needs. Import data and configure settings.",
+
+ "whyChooseHeading": "Why organizations choose MyEasyCMS",
+ "whyChooseDescription": "Built with over 20 years of experience serving associations, clubs, and non-profit organizations across Germany.",
+ "whyResponsiveTitle": "Mobile-Friendly",
+ "whyResponsiveDesc": "Access your data from any device. Our responsive design works perfectly on desktop, tablet, and smartphone.",
+ "whySecureTitle": "Secure & Reliable",
+ "whySecureDesc": "Your data is protected with enterprise-grade security. Regular backups ensure nothing is ever lost.",
+ "whySupportTitle": "Personal Support",
+ "whySupportDesc": "Get direct, personal support from our team. We speak your language and understand your needs.",
+ "whyGdprTitle": "GDPR Compliant",
+ "whyGdprDesc": "Fully compliant with European data protection regulations. Your members' data is handled with care.",
+
+ "howItWorksHeading": "Get started in three easy steps",
+ "howItWorksSubheading": "Setting up your organization on MyEasyCMS takes just minutes.",
+ "howStep1Title": "Create your account",
+ "howStep1Desc": "Sign up for free and set up your organization profile. No credit card required to get started.",
+ "howStep2Title": "Configure your modules",
+ "howStep2Desc": "Activate the modules you need — members, courses, events, finance — and customize them to fit your workflow.",
+ "howStep3Title": "Invite your team",
+ "howStep3Desc": "Add team members with different roles and permissions. Start managing your organization collaboratively.",
+
+ "pricingPillLabel": "Start for free",
+ "pricingPillText": "No credit card required.",
+ "pricingHeading": "Fair pricing for all types of organizations",
+ "pricingSubheading": "Get started on our free plan and upgrade when you are ready.",
+
+ "ctaHeading": "Ready to simplify your organization's management?",
+ "ctaDescription": "Join hundreds of associations, clubs, and organizations who already use MyEasyCMS to streamline their work.",
+ "ctaButtonPrimary": "Get Started for Free",
+ "ctaButtonSecondary": "Contact Us",
+ "ctaNote": "No credit card required. Free plan available."
}
diff --git a/apps/web/lib/database.types.ts b/apps/web/lib/database.types.ts
index b80464da9..66b5fe309 100644
--- a/apps/web/lib/database.types.ts
+++ b/apps/web/lib/database.types.ts
@@ -496,6 +496,73 @@ export type Database = {
},
]
}
+ cms_posts: {
+ Row: {
+ account_id: string
+ author_id: string | null
+ content: string | null
+ cover_image: string | null
+ created_at: string
+ excerpt: string | null
+ id: string
+ published_at: string | null
+ slug: string
+ status: string
+ title: string
+ updated_at: string
+ }
+ Insert: {
+ account_id: string
+ author_id?: string | null
+ content?: string | null
+ cover_image?: string | null
+ created_at?: string
+ excerpt?: string | null
+ id?: string
+ published_at?: string | null
+ slug: string
+ status?: string
+ title: string
+ updated_at?: string
+ }
+ Update: {
+ account_id?: string
+ author_id?: string | null
+ content?: string | null
+ cover_image?: string | null
+ created_at?: string
+ excerpt?: string | null
+ id?: string
+ published_at?: string | null
+ slug?: string
+ status?: string
+ title?: string
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "cms_posts_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "cms_posts_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "cms_posts_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
config: {
Row: {
billing_provider: Database["public"]["Enums"]["billing_provider"]
@@ -963,6 +1030,8 @@ export type Database = {
id: string
interval: string
is_default: boolean
+ is_exit: boolean
+ is_youth: boolean
name: string
sort_order: number
}
@@ -974,6 +1043,8 @@ export type Database = {
id?: string
interval?: string
is_default?: boolean
+ is_exit?: boolean
+ is_youth?: boolean
name: string
sort_order?: number
}
@@ -985,6 +1056,8 @@ export type Database = {
id?: string
interval?: string
is_default?: boolean
+ is_exit?: boolean
+ is_youth?: boolean
name?: string
sort_order?: number
}
@@ -1671,126 +1744,499 @@ export type Database = {
},
]
}
+ member_department_assignments: {
+ Row: {
+ department_id: string
+ member_id: string
+ }
+ Insert: {
+ department_id: string
+ member_id: string
+ }
+ Update: {
+ department_id?: string
+ member_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_department_assignments_department_id_fkey"
+ columns: ["department_id"]
+ isOneToOne: false
+ referencedRelation: "member_departments"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_department_assignments_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ member_departments: {
+ Row: {
+ account_id: string
+ created_at: string
+ description: string | null
+ id: string
+ name: string
+ sort_order: number
+ }
+ Insert: {
+ account_id: string
+ created_at?: string
+ description?: string | null
+ id?: string
+ name: string
+ sort_order?: number
+ }
+ Update: {
+ account_id?: string
+ created_at?: string
+ description?: string | null
+ id?: string
+ name?: string
+ sort_order?: number
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_departments_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_departments_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_departments_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ member_honors: {
+ Row: {
+ account_id: string
+ created_at: string
+ description: string | null
+ honor_date: string | null
+ honor_name: string
+ id: string
+ member_id: string
+ }
+ Insert: {
+ account_id: string
+ created_at?: string
+ description?: string | null
+ honor_date?: string | null
+ honor_name: string
+ id?: string
+ member_id: string
+ }
+ Update: {
+ account_id?: string
+ created_at?: string
+ description?: string | null
+ honor_date?: string | null
+ honor_name?: string
+ id?: string
+ member_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_honors_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_honors_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_honors_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_honors_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ member_portal_invitations: {
+ Row: {
+ accepted_at: string | null
+ account_id: string
+ created_at: string
+ email: string
+ expires_at: string
+ id: string
+ invite_token: string
+ invited_by: string | null
+ member_id: string
+ status: string
+ }
+ Insert: {
+ accepted_at?: string | null
+ account_id: string
+ created_at?: string
+ email: string
+ expires_at?: string
+ id?: string
+ invite_token?: string
+ invited_by?: string | null
+ member_id: string
+ status?: string
+ }
+ Update: {
+ accepted_at?: string | null
+ account_id?: string
+ created_at?: string
+ email?: string
+ expires_at?: string
+ id?: string
+ invite_token?: string
+ invited_by?: string | null
+ member_id?: string
+ status?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_portal_invitations_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_portal_invitations_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_portal_invitations_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_portal_invitations_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ member_roles: {
+ Row: {
+ account_id: string
+ created_at: string
+ from_date: string | null
+ id: string
+ is_active: boolean
+ member_id: string
+ role_name: string
+ until_date: string | null
+ }
+ Insert: {
+ account_id: string
+ created_at?: string
+ from_date?: string | null
+ id?: string
+ is_active?: boolean
+ member_id: string
+ role_name: string
+ until_date?: string | null
+ }
+ Update: {
+ account_id?: string
+ created_at?: string
+ from_date?: string | null
+ id?: string
+ is_active?: boolean
+ member_id?: string
+ role_name?: string
+ until_date?: string | null
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_roles_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_roles_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_roles_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_roles_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
members: {
Row: {
account_holder: string | null
account_id: string
+ additional_fees: number | null
+ address_invalid: boolean
bic: string | null
+ birth_country: string | null
+ birthplace: string | null
city: string | null
country: string | null
created_at: string
created_by: string | null
custom_data: Json
+ data_reconciliation_needed: boolean
date_of_birth: string | null
dues_category_id: string | null
+ dues_paid: boolean
+ dues_year: number | null
email: string | null
+ email_confirmed: boolean
entry_date: string
+ exemption_amount: number | null
+ exemption_reason: string | null
+ exemption_type: string | null
exit_date: string | null
exit_reason: string | null
+ fax: string | null
first_name: string
+ gdpr_birthday_info: boolean
gdpr_consent: boolean
gdpr_consent_date: string | null
gdpr_data_source: string | null
+ gdpr_internet: boolean
+ gdpr_newsletter: boolean
+ gdpr_print: boolean
gender: string | null
+ guardian_email: string | null
+ guardian_name: string | null
+ guardian_phone: string | null
house_number: string | null
iban: string | null
id: string
+ is_archived: boolean
+ is_founding_member: boolean
+ is_honorary: boolean
+ is_probationary: boolean
+ is_retiree: boolean
+ is_transferred: boolean
+ is_youth: boolean
last_name: string
member_number: string | null
mobile: string | null
notes: string | null
+ online_access_blocked: boolean
+ online_access_key: string | null
phone: string | null
+ phone2: string | null
postal_code: string | null
+ salutation: string | null
+ sepa_bank_name: string | null
sepa_mandate_date: string | null
sepa_mandate_id: string | null
+ sepa_mandate_reference: string | null
+ sepa_mandate_sequence: string | null
sepa_mandate_status:
| Database["public"]["Enums"]["sepa_mandate_status"]
| null
status: Database["public"]["Enums"]["membership_status"]
street: string | null
+ street2: string | null
title: string | null
updated_at: string
updated_by: string | null
+ user_id: string | null
}
Insert: {
account_holder?: string | null
account_id: string
+ additional_fees?: number | null
+ address_invalid?: boolean
bic?: string | null
+ birth_country?: string | null
+ birthplace?: string | null
city?: string | null
country?: string | null
created_at?: string
created_by?: string | null
custom_data?: Json
+ data_reconciliation_needed?: boolean
date_of_birth?: string | null
dues_category_id?: string | null
+ dues_paid?: boolean
+ dues_year?: number | null
email?: string | null
+ email_confirmed?: boolean
entry_date?: string
+ exemption_amount?: number | null
+ exemption_reason?: string | null
+ exemption_type?: string | null
exit_date?: string | null
exit_reason?: string | null
+ fax?: string | null
first_name: string
+ gdpr_birthday_info?: boolean
gdpr_consent?: boolean
gdpr_consent_date?: string | null
gdpr_data_source?: string | null
+ gdpr_internet?: boolean
+ gdpr_newsletter?: boolean
+ gdpr_print?: boolean
gender?: string | null
+ guardian_email?: string | null
+ guardian_name?: string | null
+ guardian_phone?: string | null
house_number?: string | null
iban?: string | null
id?: string
+ is_archived?: boolean
+ is_founding_member?: boolean
+ is_honorary?: boolean
+ is_probationary?: boolean
+ is_retiree?: boolean
+ is_transferred?: boolean
+ is_youth?: boolean
last_name: string
member_number?: string | null
mobile?: string | null
notes?: string | null
+ online_access_blocked?: boolean
+ online_access_key?: string | null
phone?: string | null
+ phone2?: string | null
postal_code?: string | null
+ salutation?: string | null
+ sepa_bank_name?: string | null
sepa_mandate_date?: string | null
sepa_mandate_id?: string | null
+ sepa_mandate_reference?: string | null
+ sepa_mandate_sequence?: string | null
sepa_mandate_status?:
| Database["public"]["Enums"]["sepa_mandate_status"]
| null
status?: Database["public"]["Enums"]["membership_status"]
street?: string | null
+ street2?: string | null
title?: string | null
updated_at?: string
updated_by?: string | null
+ user_id?: string | null
}
Update: {
account_holder?: string | null
account_id?: string
+ additional_fees?: number | null
+ address_invalid?: boolean
bic?: string | null
+ birth_country?: string | null
+ birthplace?: string | null
city?: string | null
country?: string | null
created_at?: string
created_by?: string | null
custom_data?: Json
+ data_reconciliation_needed?: boolean
date_of_birth?: string | null
dues_category_id?: string | null
+ dues_paid?: boolean
+ dues_year?: number | null
email?: string | null
+ email_confirmed?: boolean
entry_date?: string
+ exemption_amount?: number | null
+ exemption_reason?: string | null
+ exemption_type?: string | null
exit_date?: string | null
exit_reason?: string | null
+ fax?: string | null
first_name?: string
+ gdpr_birthday_info?: boolean
gdpr_consent?: boolean
gdpr_consent_date?: string | null
gdpr_data_source?: string | null
+ gdpr_internet?: boolean
+ gdpr_newsletter?: boolean
+ gdpr_print?: boolean
gender?: string | null
+ guardian_email?: string | null
+ guardian_name?: string | null
+ guardian_phone?: string | null
house_number?: string | null
iban?: string | null
id?: string
+ is_archived?: boolean
+ is_founding_member?: boolean
+ is_honorary?: boolean
+ is_probationary?: boolean
+ is_retiree?: boolean
+ is_transferred?: boolean
+ is_youth?: boolean
last_name?: string
member_number?: string | null
mobile?: string | null
notes?: string | null
+ online_access_blocked?: boolean
+ online_access_key?: string | null
phone?: string | null
+ phone2?: string | null
postal_code?: string | null
+ salutation?: string | null
+ sepa_bank_name?: string | null
sepa_mandate_date?: string | null
sepa_mandate_id?: string | null
+ sepa_mandate_reference?: string | null
+ sepa_mandate_sequence?: string | null
sepa_mandate_status?:
| Database["public"]["Enums"]["sepa_mandate_status"]
| null
status?: Database["public"]["Enums"]["membership_status"]
street?: string | null
+ street2?: string | null
title?: string | null
updated_at?: string
updated_by?: string | null
+ user_id?: string | null
}
Relationships: [
{
@@ -2412,6 +2858,64 @@ export type Database = {
},
]
}
+ newsletter_subscriptions: {
+ Row: {
+ account_id: string
+ confirmation_token: string | null
+ confirmed_at: string | null
+ email: string
+ id: string
+ is_active: boolean
+ name: string | null
+ subscribed_at: string
+ unsubscribed_at: string | null
+ }
+ Insert: {
+ account_id: string
+ confirmation_token?: string | null
+ confirmed_at?: string | null
+ email: string
+ id?: string
+ is_active?: boolean
+ name?: string | null
+ subscribed_at?: string
+ unsubscribed_at?: string | null
+ }
+ Update: {
+ account_id?: string
+ confirmation_token?: string | null
+ confirmed_at?: string | null
+ email?: string
+ id?: string
+ is_active?: boolean
+ name?: string | null
+ subscribed_at?: string
+ unsubscribed_at?: string | null
+ }
+ Relationships: [
+ {
+ foreignKeyName: "newsletter_subscriptions_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "newsletter_subscriptions_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "newsletter_subscriptions_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
newsletter_templates: {
Row: {
account_id: string
@@ -3015,6 +3519,259 @@ export type Database = {
},
]
}
+ sepa_mandates: {
+ Row: {
+ account_holder: string
+ account_id: string
+ bic: string | null
+ created_at: string
+ has_error: boolean
+ iban: string
+ id: string
+ is_primary: boolean
+ last_used_at: string | null
+ mandate_date: string
+ mandate_reference: string
+ member_id: string
+ notes: string | null
+ sequence: string
+ status: Database["public"]["Enums"]["sepa_mandate_status"]
+ updated_at: string
+ }
+ Insert: {
+ account_holder: string
+ account_id: string
+ bic?: string | null
+ created_at?: string
+ has_error?: boolean
+ iban: string
+ id?: string
+ is_primary?: boolean
+ last_used_at?: string | null
+ mandate_date: string
+ mandate_reference: string
+ member_id: string
+ notes?: string | null
+ sequence?: string
+ status?: Database["public"]["Enums"]["sepa_mandate_status"]
+ updated_at?: string
+ }
+ Update: {
+ account_holder?: string
+ account_id?: string
+ bic?: string | null
+ created_at?: string
+ has_error?: boolean
+ iban?: string
+ id?: string
+ is_primary?: boolean
+ last_used_at?: string | null
+ mandate_date?: string
+ mandate_reference?: string
+ member_id?: string
+ notes?: string | null
+ sequence?: string
+ status?: Database["public"]["Enums"]["sepa_mandate_status"]
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "sepa_mandates_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "sepa_mandates_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "sepa_mandates_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "sepa_mandates_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ site_pages: {
+ Row: {
+ account_id: string
+ created_at: string
+ created_by: string | null
+ id: string
+ is_homepage: boolean
+ is_members_only: boolean
+ is_published: boolean
+ meta_description: string | null
+ meta_image: string | null
+ published_at: string | null
+ puck_data: Json
+ slug: string
+ sort_order: number
+ title: string
+ updated_at: string
+ updated_by: string | null
+ }
+ Insert: {
+ account_id: string
+ created_at?: string
+ created_by?: string | null
+ id?: string
+ is_homepage?: boolean
+ is_members_only?: boolean
+ is_published?: boolean
+ meta_description?: string | null
+ meta_image?: string | null
+ published_at?: string | null
+ puck_data?: Json
+ slug: string
+ sort_order?: number
+ title: string
+ updated_at?: string
+ updated_by?: string | null
+ }
+ Update: {
+ account_id?: string
+ created_at?: string
+ created_by?: string | null
+ id?: string
+ is_homepage?: boolean
+ is_members_only?: boolean
+ is_published?: boolean
+ meta_description?: string | null
+ meta_image?: string | null
+ published_at?: string | null
+ puck_data?: Json
+ slug?: string
+ sort_order?: number
+ title?: string
+ updated_at?: string
+ updated_by?: string | null
+ }
+ Relationships: [
+ {
+ foreignKeyName: "site_pages_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "site_pages_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "site_pages_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ site_settings: {
+ Row: {
+ account_id: string
+ contact_address: string | null
+ contact_email: string | null
+ contact_phone: string | null
+ created_at: string
+ custom_css: string | null
+ custom_domain: string | null
+ datenschutz: string | null
+ font_family: string | null
+ footer_text: string | null
+ impressum: string | null
+ is_public: boolean
+ navigation: Json
+ primary_color: string | null
+ secondary_color: string | null
+ site_logo: string | null
+ site_name: string | null
+ social_links: Json | null
+ updated_at: string
+ }
+ Insert: {
+ account_id: string
+ contact_address?: string | null
+ contact_email?: string | null
+ contact_phone?: string | null
+ created_at?: string
+ custom_css?: string | null
+ custom_domain?: string | null
+ datenschutz?: string | null
+ font_family?: string | null
+ footer_text?: string | null
+ impressum?: string | null
+ is_public?: boolean
+ navigation?: Json
+ primary_color?: string | null
+ secondary_color?: string | null
+ site_logo?: string | null
+ site_name?: string | null
+ social_links?: Json | null
+ updated_at?: string
+ }
+ Update: {
+ account_id?: string
+ contact_address?: string | null
+ contact_email?: string | null
+ contact_phone?: string | null
+ created_at?: string
+ custom_css?: string | null
+ custom_domain?: string | null
+ datenschutz?: string | null
+ font_family?: string | null
+ footer_text?: string | null
+ impressum?: string | null
+ is_public?: boolean
+ navigation?: Json
+ primary_color?: string | null
+ secondary_color?: string | null
+ site_logo?: string | null
+ site_name?: string | null
+ social_links?: Json | null
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "site_settings_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: true
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "site_settings_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: true
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "site_settings_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: true
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
subscription_items: {
Row: {
created_at: string
@@ -3194,6 +3951,22 @@ export type Database = {
Args: { target_team_account_id: string; target_user_id: string }
Returns: boolean
}
+ check_duplicate_member: {
+ Args: {
+ p_account_id: string
+ p_date_of_birth?: string
+ p_first_name: string
+ p_last_name: string
+ }
+ Returns: {
+ date_of_birth: string
+ first_name: string
+ id: string
+ last_name: string
+ member_number: string
+ status: Database["public"]["Enums"]["membership_status"]
+ }[]
+ }
create_invitation: {
Args: { account_id: string; email: string; role: string }
Returns: {
@@ -3327,6 +4100,10 @@ export type Database = {
Args: { account_id: string; user_id: string }
Returns: boolean
}
+ link_member_to_user: {
+ Args: { p_invite_token: string; p_user_id: string }
+ Returns: string
+ }
module_query: {
Args: {
p_filters?: Json
diff --git a/apps/web/package.json b/apps/web/package.json
index 11eef78da..aa85ddcfa 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -84,6 +84,7 @@
"zod": "catalog:"
},
"devDependencies": {
+ "@kit/site-builder": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@next/bundle-analyzer": "catalog:",
"@tailwindcss/postcss": "catalog:",
diff --git a/apps/web/supabase/migrations/20260409000001_member_management_parity.sql b/apps/web/supabase/migrations/20260409000001_member_management_parity.sql
new file mode 100644
index 000000000..69ce2d6eb
--- /dev/null
+++ b/apps/web/supabase/migrations/20260409000001_member_management_parity.sql
@@ -0,0 +1,204 @@
+/*
+ * -------------------------------------------------------
+ * Member Management Parity Migration
+ * Adds missing columns, departments, roles, honors,
+ * SEPA mandates table, dues extensions, duplicate detection
+ * -------------------------------------------------------
+ */
+
+-- =====================================================
+-- A1. Add missing columns to members table
+-- =====================================================
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS salutation text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS street2 text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS phone2 text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS fax text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS birthplace text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS birth_country text DEFAULT 'DE';
+
+-- Membership lifecycle flags
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_honorary boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_founding_member boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_youth boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_retiree boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_probationary boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_transferred boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false;
+
+-- Youth/Guardian
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS guardian_name text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS guardian_phone text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS guardian_email text;
+
+-- Dues tracking
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS dues_year integer;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS dues_paid boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS additional_fees numeric(10,2) DEFAULT 0;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS exemption_type text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS exemption_reason text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS exemption_amount numeric(10,2);
+
+-- SEPA extras
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS sepa_mandate_reference text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS sepa_mandate_sequence text DEFAULT 'RCUR';
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS sepa_bank_name text;
+
+-- GDPR granular
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_newsletter boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_internet boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_print boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_birthday_info boolean NOT NULL DEFAULT false;
+
+-- Online portal
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS online_access_key text;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS online_access_blocked boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS email_confirmed boolean NOT NULL DEFAULT false;
+
+-- Address quality
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS address_invalid boolean NOT NULL DEFAULT false;
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS data_reconciliation_needed boolean NOT NULL DEFAULT false;
+
+-- =====================================================
+-- A2. member_departments + assignments
+-- =====================================================
+CREATE TABLE IF NOT EXISTS public.member_departments (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
+ name text NOT NULL,
+ description text,
+ sort_order integer NOT NULL DEFAULT 0,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_member_departments_account ON public.member_departments(account_id);
+ALTER TABLE public.member_departments ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.member_departments FROM authenticated, service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_departments TO authenticated;
+GRANT ALL ON public.member_departments TO service_role;
+CREATE POLICY member_departments_select ON public.member_departments FOR SELECT TO authenticated
+ USING (public.has_role_on_account(account_id));
+CREATE POLICY member_departments_mutate ON public.member_departments FOR ALL TO authenticated
+ USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
+
+CREATE TABLE IF NOT EXISTS public.member_department_assignments (
+ member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+ department_id uuid NOT NULL REFERENCES public.member_departments(id) ON DELETE CASCADE,
+ PRIMARY KEY (member_id, department_id)
+);
+ALTER TABLE public.member_department_assignments ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.member_department_assignments FROM authenticated, service_role;
+GRANT SELECT, INSERT, DELETE ON public.member_department_assignments TO authenticated;
+GRANT ALL ON public.member_department_assignments TO service_role;
+CREATE POLICY mda_select ON public.member_department_assignments FOR SELECT TO authenticated
+ USING (EXISTS (SELECT 1 FROM public.members m WHERE m.id = member_department_assignments.member_id AND public.has_role_on_account(m.account_id)));
+CREATE POLICY mda_mutate ON public.member_department_assignments FOR ALL TO authenticated
+ USING (EXISTS (SELECT 1 FROM public.members m WHERE m.id = member_department_assignments.member_id AND public.has_permission(auth.uid(), m.account_id, 'members.write'::public.app_permissions)));
+
+-- =====================================================
+-- A3. member_roles (board positions / Funktionen)
+-- =====================================================
+CREATE TABLE IF NOT EXISTS public.member_roles (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+ account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
+ role_name text NOT NULL,
+ from_date date,
+ until_date date,
+ is_active boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_member_roles_member ON public.member_roles(member_id);
+ALTER TABLE public.member_roles ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.member_roles FROM authenticated, service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_roles TO authenticated;
+GRANT ALL ON public.member_roles TO service_role;
+CREATE POLICY member_roles_select ON public.member_roles FOR SELECT TO authenticated
+ USING (public.has_role_on_account(account_id));
+CREATE POLICY member_roles_mutate ON public.member_roles FOR ALL TO authenticated
+ USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
+
+-- =====================================================
+-- A4. member_honors (Ehrungen)
+-- =====================================================
+CREATE TABLE IF NOT EXISTS public.member_honors (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+ account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
+ honor_name text NOT NULL,
+ honor_date date,
+ description text,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_member_honors_member ON public.member_honors(member_id);
+ALTER TABLE public.member_honors ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.member_honors FROM authenticated, service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_honors TO authenticated;
+GRANT ALL ON public.member_honors TO service_role;
+CREATE POLICY member_honors_select ON public.member_honors FOR SELECT TO authenticated
+ USING (public.has_role_on_account(account_id));
+CREATE POLICY member_honors_mutate ON public.member_honors FOR ALL TO authenticated
+ USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
+
+-- =====================================================
+-- A5. sepa_mandates (proper sub-table)
+-- =====================================================
+CREATE TABLE IF NOT EXISTS public.sepa_mandates (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+ account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
+ mandate_reference text NOT NULL,
+ iban text NOT NULL,
+ bic text,
+ account_holder text NOT NULL,
+ mandate_date date NOT NULL,
+ status public.sepa_mandate_status NOT NULL DEFAULT 'active',
+ sequence text NOT NULL DEFAULT 'RCUR' CHECK (sequence IN ('FRST','RCUR','FNAL','OOFF')),
+ is_primary boolean NOT NULL DEFAULT true,
+ has_error boolean NOT NULL DEFAULT false,
+ last_used_at timestamptz,
+ notes text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_sepa_mandates_member ON public.sepa_mandates(member_id);
+CREATE INDEX IF NOT EXISTS ix_sepa_mandates_account ON public.sepa_mandates(account_id);
+ALTER TABLE public.sepa_mandates ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.sepa_mandates FROM authenticated, service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON public.sepa_mandates TO authenticated;
+GRANT ALL ON public.sepa_mandates TO service_role;
+CREATE POLICY sepa_mandates_select ON public.sepa_mandates FOR SELECT TO authenticated
+ USING (public.has_role_on_account(account_id));
+CREATE POLICY sepa_mandates_mutate ON public.sepa_mandates FOR ALL TO authenticated
+ USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
+
+CREATE TRIGGER trg_sepa_mandates_updated_at
+ BEFORE UPDATE ON public.sepa_mandates
+ FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
+
+-- =====================================================
+-- A6. Extend dues_categories
+-- =====================================================
+ALTER TABLE public.dues_categories ADD COLUMN IF NOT EXISTS is_youth boolean NOT NULL DEFAULT false;
+ALTER TABLE public.dues_categories ADD COLUMN IF NOT EXISTS is_exit boolean NOT NULL DEFAULT false;
+
+-- =====================================================
+-- A7. Duplicate detection function
+-- =====================================================
+CREATE OR REPLACE FUNCTION public.check_duplicate_member(
+ p_account_id uuid,
+ p_first_name text,
+ p_last_name text,
+ p_date_of_birth date DEFAULT NULL
+)
+RETURNS TABLE(id uuid, member_number text, first_name text, last_name text, date_of_birth date, status public.membership_status)
+LANGUAGE sql STABLE SECURITY DEFINER
+SET search_path = ''
+AS $$
+ SELECT m.id, m.member_number, m.first_name, m.last_name, m.date_of_birth, m.status
+ FROM public.members m
+ WHERE m.account_id = p_account_id
+ AND lower(m.first_name) = lower(p_first_name)
+ AND lower(m.last_name) = lower(p_last_name)
+ AND (p_date_of_birth IS NULL OR m.date_of_birth = p_date_of_birth);
+$$;
+
+GRANT EXECUTE ON FUNCTION public.check_duplicate_member(uuid, text, text, date) TO authenticated, service_role;
diff --git a/apps/web/supabase/migrations/20260410000001_site_builder.sql b/apps/web/supabase/migrations/20260410000001_site_builder.sql
new file mode 100644
index 000000000..4cffca817
--- /dev/null
+++ b/apps/web/supabase/migrations/20260410000001_site_builder.sql
@@ -0,0 +1,176 @@
+/*
+ * Site Builder Migration
+ * Tables: site_pages, site_settings, cms_posts, newsletter_subscriptions
+ * Public read via anon + RLS
+ */
+
+-- =====================================================
+-- 1. site_pages — Puck JSON per page
+-- =====================================================
+CREATE TABLE IF NOT EXISTS public.site_pages (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
+ slug text NOT NULL,
+ title text NOT NULL,
+ puck_data jsonb NOT NULL DEFAULT '{}'::jsonb,
+ is_published boolean NOT NULL DEFAULT false,
+ is_homepage boolean NOT NULL DEFAULT false,
+ sort_order integer NOT NULL DEFAULT 0,
+ meta_description text,
+ meta_image text,
+ created_by uuid REFERENCES auth.users(id),
+ updated_by uuid REFERENCES auth.users(id),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ published_at timestamptz,
+ UNIQUE(account_id, slug)
+);
+
+CREATE INDEX idx_site_pages_account ON public.site_pages(account_id);
+CREATE INDEX idx_site_pages_published ON public.site_pages(account_id, is_published);
+
+ALTER TABLE public.site_pages ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.site_pages FROM authenticated, service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON public.site_pages TO authenticated;
+GRANT SELECT ON public.site_pages TO anon;
+GRANT ALL ON public.site_pages TO service_role;
+
+CREATE POLICY site_pages_public_read ON public.site_pages
+ FOR SELECT TO anon USING (is_published = true);
+
+CREATE POLICY site_pages_auth_read ON public.site_pages
+ FOR SELECT TO authenticated USING (
+ is_published = true OR public.has_role_on_account(account_id)
+ );
+
+CREATE POLICY site_pages_admin_write ON public.site_pages
+ FOR ALL TO authenticated USING (
+ public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
+ );
+
+CREATE TRIGGER trg_site_pages_updated
+ BEFORE UPDATE ON public.site_pages
+ FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
+
+-- =====================================================
+-- 2. site_settings — Per-club branding
+-- =====================================================
+CREATE TABLE IF NOT EXISTS public.site_settings (
+ account_id uuid PRIMARY KEY REFERENCES public.accounts(id) ON DELETE CASCADE,
+ site_name text,
+ site_logo text,
+ primary_color text DEFAULT '#2563eb',
+ secondary_color text DEFAULT '#64748b',
+ font_family text DEFAULT 'Inter',
+ custom_css text,
+ navigation jsonb NOT NULL DEFAULT '[]'::jsonb,
+ footer_text text,
+ contact_email text,
+ contact_phone text,
+ contact_address text,
+ social_links jsonb DEFAULT '{}'::jsonb,
+ impressum text,
+ datenschutz text,
+ custom_domain text,
+ is_public boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+ALTER TABLE public.site_settings ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.site_settings FROM authenticated, service_role;
+GRANT SELECT, INSERT, UPDATE ON public.site_settings TO authenticated;
+GRANT SELECT ON public.site_settings TO anon;
+GRANT ALL ON public.site_settings TO service_role;
+
+CREATE POLICY site_settings_public_read ON public.site_settings
+ FOR SELECT TO anon USING (is_public = true);
+
+CREATE POLICY site_settings_auth_read ON public.site_settings
+ FOR SELECT TO authenticated USING (
+ is_public = true OR public.has_role_on_account(account_id)
+ );
+
+CREATE POLICY site_settings_admin_write ON public.site_settings
+ FOR ALL TO authenticated USING (
+ public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
+ );
+
+CREATE TRIGGER trg_site_settings_updated
+ BEFORE UPDATE ON public.site_settings
+ FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
+
+-- =====================================================
+-- 3. cms_posts — News/blog per club
+-- =====================================================
+CREATE TABLE IF NOT EXISTS public.cms_posts (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
+ title text NOT NULL,
+ slug text NOT NULL,
+ content text,
+ excerpt text,
+ cover_image text,
+ author_id uuid REFERENCES auth.users(id),
+ status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived')),
+ published_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE(account_id, slug)
+);
+
+CREATE INDEX idx_cms_posts_account ON public.cms_posts(account_id);
+CREATE INDEX idx_cms_posts_published ON public.cms_posts(account_id, status);
+
+ALTER TABLE public.cms_posts ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.cms_posts FROM authenticated, service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON public.cms_posts TO authenticated;
+GRANT SELECT ON public.cms_posts TO anon;
+GRANT ALL ON public.cms_posts TO service_role;
+
+CREATE POLICY cms_posts_public_read ON public.cms_posts
+ FOR SELECT TO anon USING (status = 'published');
+
+CREATE POLICY cms_posts_auth_read ON public.cms_posts
+ FOR SELECT TO authenticated USING (
+ status = 'published' OR public.has_role_on_account(account_id)
+ );
+
+CREATE POLICY cms_posts_admin_write ON public.cms_posts
+ FOR ALL TO authenticated USING (
+ public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
+ );
+
+CREATE TRIGGER trg_cms_posts_updated
+ BEFORE UPDATE ON public.cms_posts
+ FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
+
+-- =====================================================
+-- 4. newsletter_subscriptions — Public signup
+-- =====================================================
+CREATE TABLE IF NOT EXISTS public.newsletter_subscriptions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
+ email text NOT NULL,
+ name text,
+ subscribed_at timestamptz NOT NULL DEFAULT now(),
+ unsubscribed_at timestamptz,
+ is_active boolean NOT NULL DEFAULT true,
+ confirmation_token text,
+ confirmed_at timestamptz,
+ UNIQUE(account_id, email)
+);
+
+CREATE INDEX idx_newsletter_subs_account ON public.newsletter_subscriptions(account_id);
+
+ALTER TABLE public.newsletter_subscriptions ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.newsletter_subscriptions FROM authenticated, service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON public.newsletter_subscriptions TO authenticated;
+GRANT INSERT ON public.newsletter_subscriptions TO anon;
+GRANT ALL ON public.newsletter_subscriptions TO service_role;
+
+CREATE POLICY newsletter_sub_public_insert ON public.newsletter_subscriptions
+ FOR INSERT TO anon WITH CHECK (true);
+
+CREATE POLICY newsletter_sub_admin ON public.newsletter_subscriptions
+ FOR ALL TO authenticated USING (public.has_role_on_account(account_id));
diff --git a/apps/web/supabase/migrations/20260410000002_anon_grants.sql b/apps/web/supabase/migrations/20260410000002_anon_grants.sql
new file mode 100644
index 000000000..0ed3773c8
--- /dev/null
+++ b/apps/web/supabase/migrations/20260410000002_anon_grants.sql
@@ -0,0 +1,5 @@
+-- Fix: Grant anon USAGE on public schema for public page reads
+GRANT USAGE ON SCHEMA public TO anon;
+
+-- Ensure anon can read accounts table (needed to resolve club slugs)
+GRANT SELECT ON public.accounts TO anon;
diff --git a/apps/web/supabase/migrations/20260411000001_member_portal_auth.sql b/apps/web/supabase/migrations/20260411000001_member_portal_auth.sql
new file mode 100644
index 000000000..958998529
--- /dev/null
+++ b/apps/web/supabase/migrations/20260411000001_member_portal_auth.sql
@@ -0,0 +1,93 @@
+/*
+ * Member Portal Auth + Invitations
+ * Links members to auth.users, adds invitation system
+ */
+
+-- Add user_id to members (links to Supabase Auth)
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS ix_members_user ON public.members(user_id);
+
+-- Member portal invitations
+CREATE TABLE IF NOT EXISTS public.member_portal_invitations (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
+ member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+ email text NOT NULL,
+ invite_token text NOT NULL DEFAULT gen_random_uuid()::text,
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','expired','revoked')),
+ invited_by uuid REFERENCES auth.users(id),
+ accepted_at timestamptz,
+ expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS ix_portal_invitations_token ON public.member_portal_invitations(invite_token);
+CREATE INDEX IF NOT EXISTS ix_portal_invitations_member ON public.member_portal_invitations(member_id);
+
+ALTER TABLE public.member_portal_invitations ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.member_portal_invitations FROM authenticated, service_role;
+GRANT SELECT, INSERT, UPDATE ON public.member_portal_invitations TO authenticated;
+GRANT ALL ON public.member_portal_invitations TO service_role;
+
+-- Admins can manage invitations for their account
+CREATE POLICY portal_invitations_admin ON public.member_portal_invitations
+ FOR ALL TO authenticated
+ USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
+
+-- Anon can read invitation by token (for the accept flow)
+GRANT SELECT ON public.member_portal_invitations TO anon;
+CREATE POLICY portal_invitations_anon_read ON public.member_portal_invitations
+ FOR SELECT TO anon
+ USING (status = 'pending' AND expires_at > now());
+
+-- RLS: Members can read their own portal data
+-- Allow authenticated users to read their own member record via user_id
+CREATE POLICY members_portal_self_read ON public.members
+ FOR SELECT TO authenticated
+ USING (user_id = auth.uid());
+
+-- Allow members to update their own contact/gdpr fields
+CREATE POLICY members_portal_self_update ON public.members
+ FOR UPDATE TO authenticated
+ USING (user_id = auth.uid());
+
+-- Add is_members_only flag to site_pages for member-only content
+ALTER TABLE public.site_pages ADD COLUMN IF NOT EXISTS is_members_only boolean NOT NULL DEFAULT false;
+
+-- Function: Link member to auth user after signup
+CREATE OR REPLACE FUNCTION public.link_member_to_user(
+ p_invite_token text,
+ p_user_id uuid
+) RETURNS uuid
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_member_id uuid;
+ v_account_id uuid;
+BEGIN
+ -- Find and validate invitation
+ SELECT member_id, account_id INTO v_member_id, v_account_id
+ FROM public.member_portal_invitations
+ WHERE invite_token = p_invite_token
+ AND status = 'pending'
+ AND expires_at > now();
+
+ IF v_member_id IS NULL THEN
+ RAISE EXCEPTION 'Invalid or expired invitation';
+ END IF;
+
+ -- Link member to user
+ UPDATE public.members SET user_id = p_user_id WHERE id = v_member_id;
+
+ -- Mark invitation as accepted
+ UPDATE public.member_portal_invitations
+ SET status = 'accepted', accepted_at = now()
+ WHERE invite_token = p_invite_token;
+
+ RETURN v_member_id;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.link_member_to_user(text, uuid) TO authenticated, service_role;
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..68bef5333
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,147 @@
+version: '3.8'
+
+# MyEasyCMS v2 — Docker Compose for Dokploy deployment
+# Supabase (self-hosted) + Next.js app
+
+services:
+ # =====================================================
+ # Supabase Stack
+ # =====================================================
+
+ supabase-db:
+ image: supabase/postgres:15.8.1.060
+ restart: unless-stopped
+ volumes:
+ - supabase-db-data:/var/lib/postgresql/data
+ - ./apps/web/supabase/migrations:/docker-entrypoint-initdb.d/migrations
+ environment:
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-your-super-secret-password}
+ POSTGRES_DB: postgres
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ supabase-auth:
+ image: supabase/gotrue:v2.172.1
+ restart: unless-stopped
+ depends_on:
+ supabase-db:
+ condition: service_healthy
+ environment:
+ GOTRUE_API_HOST: 0.0.0.0
+ GOTRUE_API_PORT: 9999
+ API_EXTERNAL_URL: ${API_EXTERNAL_URL:-http://localhost:8000}
+ GOTRUE_DB_DRIVER: postgres
+ GOTRUE_DB_DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-your-super-secret-password}@supabase-db:5432/postgres?search_path=auth
+ GOTRUE_SITE_URL: ${SITE_URL:-https://myeasycms.de}
+ GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS:-}
+ GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false}
+ GOTRUE_JWT_ADMIN_ROLES: service_role
+ GOTRUE_JWT_AUD: authenticated
+ GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
+ GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
+ GOTRUE_JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-token-with-at-least-32-characters}
+ GOTRUE_EXTERNAL_EMAIL_ENABLED: true
+ GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM:-false}
+ GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
+ GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
+ GOTRUE_SMTP_USER: ${SMTP_USER:-}
+ GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
+ GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-admin@myeasycms.de}
+ GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify
+ GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
+
+ supabase-rest:
+ image: postgrest/postgrest:v12.2.8
+ restart: unless-stopped
+ depends_on:
+ supabase-db:
+ condition: service_healthy
+ environment:
+ PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD:-your-super-secret-password}@supabase-db:5432/postgres
+ PGRST_DB_SCHEMAS: public,storage,graphql_public
+ PGRST_DB_ANON_ROLE: anon
+ PGRST_JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-token-with-at-least-32-characters}
+ PGRST_DB_USE_LEGACY_GUCS: "false"
+
+ supabase-storage:
+ image: supabase/storage-api:v1.22.7
+ restart: unless-stopped
+ depends_on:
+ supabase-db:
+ condition: service_healthy
+ volumes:
+ - supabase-storage-data:/var/lib/storage
+ environment:
+ ANON_KEY: ${SUPABASE_ANON_KEY}
+ SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
+ POSTGREST_URL: http://supabase-rest:3000
+ PGRST_JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-token-with-at-least-32-characters}
+ DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:-your-super-secret-password}@supabase-db:5432/postgres
+ FILE_SIZE_LIMIT: 52428800
+ STORAGE_BACKEND: file
+ FILE_STORAGE_BACKEND_PATH: /var/lib/storage
+ TENANT_ID: stub
+ REGION: local
+ GLOBAL_S3_BUCKET: stub
+
+ supabase-kong:
+ image: kong:2.8.1
+ restart: unless-stopped
+ depends_on:
+ - supabase-auth
+ - supabase-rest
+ - supabase-storage
+ ports:
+ - "${KONG_HTTP_PORT:-8000}:8000"
+ - "${KONG_HTTPS_PORT:-8443}:8443"
+ environment:
+ KONG_DATABASE: "off"
+ KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
+ KONG_DNS_ORDER: LAST,A,CNAME
+ KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
+ KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
+ KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
+ SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
+ SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
+ volumes:
+ - ./docker/kong.yml:/var/lib/kong/kong.yml:ro
+
+ # =====================================================
+ # Next.js App
+ # =====================================================
+
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ depends_on:
+ - supabase-kong
+ ports:
+ - "${APP_PORT:-3000}:3000"
+ environment:
+ NODE_ENV: production
+ NEXT_PUBLIC_SITE_URL: ${SITE_URL:-https://myeasycms.de}
+ NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000
+ NEXT_PUBLIC_SUPABASE_PUBLIC_KEY: ${SUPABASE_ANON_KEY}
+ SUPABASE_SECRET_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
+ SUPABASE_DB_WEBHOOK_SECRET: ${DB_WEBHOOK_SECRET:-webhooksecret}
+ NEXT_PUBLIC_PRODUCT_NAME: MyEasyCMS
+ NEXT_PUBLIC_DEFAULT_LOCALE: de
+ NEXT_PUBLIC_ENABLE_THEME_TOGGLE: "true"
+ NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS: "true"
+ NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION: "true"
+ NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: "false"
+ NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: "false"
+ NEXT_PUBLIC_ENABLE_NOTIFICATIONS: "true"
+
+volumes:
+ supabase-db-data:
+ supabase-storage-data:
diff --git a/docker/kong.yml b/docker/kong.yml
new file mode 100644
index 000000000..9b6cde544
--- /dev/null
+++ b/docker/kong.yml
@@ -0,0 +1,58 @@
+_format_version: "1.1"
+
+consumers:
+ - username: anon
+ keyauth_credentials:
+ - key: ${SUPABASE_ANON_KEY}
+ - username: service_role
+ keyauth_credentials:
+ - key: ${SUPABASE_SERVICE_KEY}
+
+acls:
+ - consumer: anon
+ group: anon
+ - consumer: service_role
+ group: admin
+
+services:
+ # Auth
+ - name: auth-v1
+ url: http://supabase-auth:9999/
+ routes:
+ - name: auth-v1-routes
+ strip_path: true
+ paths:
+ - /auth/v1/
+ plugins:
+ - name: cors
+
+ # REST (PostgREST)
+ - name: rest-v1
+ url: http://supabase-rest:3000/
+ routes:
+ - name: rest-v1-routes
+ strip_path: true
+ paths:
+ - /rest/v1/
+ plugins:
+ - name: cors
+ - name: key-auth
+ config:
+ hide_credentials: false
+ - name: acl
+ config:
+ hide_groups_header: true
+ allow:
+ - anon
+ - admin
+
+ # Storage
+ - name: storage-v1
+ url: http://supabase-storage:5000/
+ routes:
+ - name: storage-v1-routes
+ strip_path: true
+ paths:
+ - /storage/v1/
+ plugins:
+ - name: cors
diff --git a/package.json b/package.json
index e3961f637..4107d69ff 100644
--- a/package.json
+++ b/package.json
@@ -36,11 +36,14 @@
},
"devDependencies": {
"@manypkg/cli": "catalog:",
+ "@measured/puck": "catalog:",
+ "@react-pdf/renderer": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/react": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@turbo/gen": "catalog:",
"@types/node": "catalog:",
+ "@types/papaparse": "catalog:",
"cross-env": "catalog:",
"exceljs": "catalog:",
"iban": "catalog:",
diff --git a/packages/features/booking-management/package.json b/packages/features/booking-management/package.json
index d6770ac87..cf80075ee 100644
--- a/packages/features/booking-management/package.json
+++ b/packages/features/booking-management/package.json
@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
- "./components": "./src/components/index.ts"
+ "./components": "./src/components/index.ts",
+ "./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
+ "@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
+ "react-hook-form": "catalog:",
"zod": "catalog:"
}
}
diff --git a/packages/features/booking-management/src/components/create-booking-form.tsx b/packages/features/booking-management/src/components/create-booking-form.tsx
new file mode 100644
index 000000000..9bfc2fe09
--- /dev/null
+++ b/packages/features/booking-management/src/components/create-booking-form.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { CreateBookingSchema } from '../schema/booking.schema';
+import { createBooking } from '../server/actions/booking-actions';
+
+interface Props {
+ accountId: string;
+ account: string;
+ rooms: Array<{ id: string; roomNumber: string; name?: string; pricePerNight: number }>;
+}
+
+export function CreateBookingForm({ accountId, account, rooms }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(CreateBookingSchema),
+ defaultValues: {
+ accountId,
+ roomId: '',
+ checkIn: '',
+ checkOut: '',
+ adults: 1,
+ children: 0,
+ status: 'confirmed' as const,
+ totalPrice: 0,
+ notes: '',
+ },
+ });
+
+ const { execute, isPending } = useAction(createBooking, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Buchung erfolgreich erstellt');
+ router.push(`/home/${account}/bookings-cms`);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Erstellen der Buchung');
+ },
+ });
+
+ return (
+
+ execute(data))} className="space-y-6">
+
+ Zimmer & Zeitraum
+
+ (
+ Zimmer *
+
+ — Zimmer wählen —
+ {rooms.map(r => (
+
+ {r.roomNumber}{r.name ? ` – ${r.name}` : ''} ({r.pricePerNight} €/Nacht)
+
+ ))}
+
+
+ )} />
+ (
+ Check-in *
+ )} />
+ (
+ Check-out *
+ )} />
+
+
+
+
+ Gäste
+
+ (
+ Erwachsene *
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+ (
+ Kinder
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+
+
+
+
+ Preis & Notizen
+
+ (
+ Gesamtpreis (€)
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+ (
+ Status
+
+ Ausstehend
+ Bestätigt
+ Eingecheckt
+ Ausgecheckt
+ Storniert
+ Nicht erschienen
+
+
+ )} />
+
+ (
+ Notizen
+
+
+ )} />
+
+
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird erstellt...' : 'Buchung erstellen'}
+
+
+
+ );
+}
diff --git a/packages/features/booking-management/src/components/index.ts b/packages/features/booking-management/src/components/index.ts
index cb0ff5c3b..0f6f4c936 100644
--- a/packages/features/booking-management/src/components/index.ts
+++ b/packages/features/booking-management/src/components/index.ts
@@ -1 +1 @@
-export {};
+export { CreateBookingForm } from './create-booking-form';
diff --git a/packages/features/booking-management/src/server/actions/booking-actions.ts b/packages/features/booking-management/src/server/actions/booking-actions.ts
new file mode 100644
index 000000000..1eda00fac
--- /dev/null
+++ b/packages/features/booking-management/src/server/actions/booking-actions.ts
@@ -0,0 +1,69 @@
+'use server';
+
+import { z } from 'zod';
+import { authActionClient } from '@kit/next/safe-action';
+import { getLogger } from '@kit/shared/logger';
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import {
+ CreateBookingSchema,
+ CreateGuestSchema,
+ CreateRoomSchema,
+} from '../../schema/booking.schema';
+import { createBookingManagementApi } from '../api';
+
+export const createBooking = authActionClient
+ .inputSchema(CreateBookingSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createBookingManagementApi(client);
+
+ logger.info({ name: 'booking.create' }, 'Creating booking...');
+ const result = await api.createBooking(input);
+ logger.info({ name: 'booking.create' }, 'Booking created');
+ return { success: true, data: result };
+ });
+
+export const updateBookingStatus = authActionClient
+ .inputSchema(
+ z.object({
+ bookingId: z.string().uuid(),
+ status: z.string(),
+ }),
+ )
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createBookingManagementApi(client);
+
+ logger.info({ name: 'booking.updateStatus' }, 'Updating booking status...');
+ const result = await api.updateBookingStatus(input.bookingId, input.status);
+ logger.info({ name: 'booking.updateStatus' }, 'Booking status updated');
+ return { success: true, data: result };
+ });
+
+export const createRoom = authActionClient
+ .inputSchema(CreateRoomSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createBookingManagementApi(client);
+
+ logger.info({ name: 'booking.createRoom' }, 'Creating room...');
+ const result = await api.createRoom(input);
+ logger.info({ name: 'booking.createRoom' }, 'Room created');
+ return { success: true, data: result };
+ });
+
+export const createGuest = authActionClient
+ .inputSchema(CreateGuestSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createBookingManagementApi(client);
+
+ logger.info({ name: 'booking.createGuest' }, 'Creating guest...');
+ const result = await api.createGuest(input);
+ logger.info({ name: 'booking.createGuest' }, 'Guest created');
+ return { success: true, data: result };
+ });
diff --git a/packages/features/booking-management/src/server/api.ts b/packages/features/booking-management/src/server/api.ts
index d299032d2..66a3feefc 100644
--- a/packages/features/booking-management/src/server/api.ts
+++ b/packages/features/booking-management/src/server/api.ts
@@ -84,5 +84,15 @@ export function createBookingManagementApi(client: SupabaseClient) {
if (error) throw error;
return data;
},
+
+ async createRoom(input: { accountId: string; roomNumber: string; name?: string; roomType?: string; capacity?: number; floor?: number; pricePerNight: number; description?: string }) {
+ const { data, error } = await client.from('rooms').insert({
+ account_id: input.accountId, room_number: input.roomNumber, name: input.name,
+ room_type: input.roomType ?? 'standard', capacity: input.capacity ?? 2,
+ floor: input.floor, price_per_night: input.pricePerNight, description: input.description,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
};
}
diff --git a/packages/features/course-management/package.json b/packages/features/course-management/package.json
index 2c8a19437..167250d8e 100644
--- a/packages/features/course-management/package.json
+++ b/packages/features/course-management/package.json
@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
- "./components": "./src/components/index.ts"
+ "./components": "./src/components/index.ts",
+ "./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
+ "@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
+ "react-hook-form": "catalog:",
"zod": "catalog:"
}
}
diff --git a/packages/features/course-management/src/components/create-course-form.tsx b/packages/features/course-management/src/components/create-course-form.tsx
new file mode 100644
index 000000000..75ae980b9
--- /dev/null
+++ b/packages/features/course-management/src/components/create-course-form.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { CreateCourseSchema } from '../schema/course.schema';
+import { createCourse } from '../server/actions/course-actions';
+
+interface Props {
+ accountId: string;
+ account: string;
+}
+
+export function CreateCourseForm({ accountId, account }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(CreateCourseSchema),
+ defaultValues: {
+ accountId,
+ courseNumber: '',
+ name: '',
+ description: '',
+ startDate: '',
+ endDate: '',
+ fee: 0,
+ reducedFee: 0,
+ capacity: 20,
+ minParticipants: 5,
+ status: 'planned' as const,
+ registrationDeadline: '',
+ notes: '',
+ },
+ });
+
+ const { execute, isPending } = useAction(createCourse, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Kurs erfolgreich erstellt');
+ router.push(`/home/${account}/courses-cms`);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Erstellen des Kurses');
+ },
+ });
+
+ return (
+
+ execute(data))} className="space-y-6">
+
+ Grunddaten
+
+ (
+ Kursnummer
+ )} />
+ (
+ Kursname *
+ )} />
+
+ (
+ Beschreibung
+
+
+ )} />
+
+
+
+
+
+ Zeitplan
+
+ (
+ Startdatum
+ )} />
+ (
+ Enddatum
+ )} />
+ (
+ Anmeldeschluss
+ )} />
+
+
+
+
+ Kapazität
+
+ (
+ Max. Teilnehmer
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+ (
+ Min. Teilnehmer
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+ (
+ Gebühr (€)
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+ (
+ Ermäßigte Gebühr (€)
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+
+
+
+
+ Status
+
+ (
+ Kursstatus
+
+ Geplant
+ Offen
+ Laufend
+ Abgeschlossen
+ Abgesagt
+
+
+ )} />
+
+ (
+ Notizen
+
+
+ )} />
+
+
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird erstellt...' : 'Kurs erstellen'}
+
+
+
+ );
+}
diff --git a/packages/features/course-management/src/components/index.ts b/packages/features/course-management/src/components/index.ts
index cb0ff5c3b..a409a92ce 100644
--- a/packages/features/course-management/src/components/index.ts
+++ b/packages/features/course-management/src/components/index.ts
@@ -1 +1 @@
-export {};
+export { CreateCourseForm } from './create-course-form';
diff --git a/packages/features/course-management/src/server/actions/course-actions.ts b/packages/features/course-management/src/server/actions/course-actions.ts
new file mode 100644
index 000000000..8186b9048
--- /dev/null
+++ b/packages/features/course-management/src/server/actions/course-actions.ts
@@ -0,0 +1,129 @@
+'use server';
+
+import { z } from 'zod';
+import { authActionClient } from '@kit/next/safe-action';
+import { getLogger } from '@kit/shared/logger';
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import {
+ CreateCourseSchema,
+ EnrollParticipantSchema,
+ CreateSessionSchema,
+ CreateCategorySchema,
+ CreateInstructorSchema,
+ CreateLocationSchema,
+} from '../../schema/course.schema';
+import { createCourseManagementApi } from '../api';
+
+export const createCourse = authActionClient
+ .inputSchema(CreateCourseSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createCourseManagementApi(client);
+
+ logger.info({ name: 'course.create' }, 'Creating course...');
+ const result = await api.createCourse(input);
+ logger.info({ name: 'course.create' }, 'Course created');
+ return { success: true, data: result };
+ });
+
+export const enrollParticipant = authActionClient
+ .inputSchema(EnrollParticipantSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createCourseManagementApi(client);
+
+ logger.info({ name: 'course.enrollParticipant' }, 'Enrolling participant...');
+ const result = await api.enrollParticipant(input);
+ logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
+ return { success: true, data: result };
+ });
+
+export const cancelEnrollment = authActionClient
+ .inputSchema(
+ z.object({
+ participantId: z.string().uuid(),
+ }),
+ )
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createCourseManagementApi(client);
+
+ logger.info({ name: 'course.cancelEnrollment' }, 'Cancelling enrollment...');
+ const result = await api.cancelEnrollment(input.participantId);
+ logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled');
+ return { success: true, data: result };
+ });
+
+export const markAttendance = authActionClient
+ .inputSchema(
+ z.object({
+ sessionId: z.string().uuid(),
+ participantId: z.string().uuid(),
+ present: z.boolean(),
+ }),
+ )
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createCourseManagementApi(client);
+
+ logger.info({ name: 'course.markAttendance' }, 'Marking attendance...');
+ const result = await api.markAttendance(input.sessionId, input.participantId, input.present);
+ logger.info({ name: 'course.markAttendance' }, 'Attendance marked');
+ return { success: true, data: result };
+ });
+
+export const createCategory = authActionClient
+ .inputSchema(CreateCategorySchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createCourseManagementApi(client);
+
+ logger.info({ name: 'course.createCategory' }, 'Creating category...');
+ const result = await api.createCategory(input);
+ logger.info({ name: 'course.createCategory' }, 'Category created');
+ return { success: true, data: result };
+ });
+
+export const createInstructor = authActionClient
+ .inputSchema(CreateInstructorSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createCourseManagementApi(client);
+
+ logger.info({ name: 'course.createInstructor' }, 'Creating instructor...');
+ const result = await api.createInstructor(input);
+ logger.info({ name: 'course.createInstructor' }, 'Instructor created');
+ return { success: true, data: result };
+ });
+
+export const createLocation = authActionClient
+ .inputSchema(CreateLocationSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createCourseManagementApi(client);
+
+ logger.info({ name: 'course.createLocation' }, 'Creating location...');
+ const result = await api.createLocation(input);
+ logger.info({ name: 'course.createLocation' }, 'Location created');
+ return { success: true, data: result };
+ });
+
+export const createSession = authActionClient
+ .inputSchema(CreateSessionSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createCourseManagementApi(client);
+
+ logger.info({ name: 'course.createSession' }, 'Creating session...');
+ const result = await api.createSession(input);
+ logger.info({ name: 'course.createSession' }, 'Session created');
+ return { success: true, data: result };
+ });
diff --git a/packages/features/course-management/src/server/api.ts b/packages/features/course-management/src/server/api.ts
index 514bfa55d..13adac1d3 100644
--- a/packages/features/course-management/src/server/api.ts
+++ b/packages/features/course-management/src/server/api.ts
@@ -141,5 +141,34 @@ export function createCourseManagementApi(client: SupabaseClient) {
}
return stats;
},
+
+ // --- Create methods for CRUD ---
+ async createCategory(input: { accountId: string; name: string; description?: string; parentId?: string }) {
+ const { data, error } = await client.from('course_categories').insert({
+ account_id: input.accountId, name: input.name, description: input.description,
+ parent_id: input.parentId,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ async createInstructor(input: { accountId: string; firstName: string; lastName: string; email?: string; phone?: string; qualifications?: string; hourlyRate?: number }) {
+ const { data, error } = await client.from('course_instructors').insert({
+ account_id: input.accountId, first_name: input.firstName, last_name: input.lastName,
+ email: input.email, phone: input.phone, qualifications: input.qualifications,
+ hourly_rate: input.hourlyRate,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ async createLocation(input: { accountId: string; name: string; address?: string; room?: string; capacity?: number }) {
+ const { data, error } = await client.from('course_locations').insert({
+ account_id: input.accountId, name: input.name, address: input.address,
+ room: input.room, capacity: input.capacity,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
};
}
diff --git a/packages/features/event-management/package.json b/packages/features/event-management/package.json
index 67091de0c..e75575157 100644
--- a/packages/features/event-management/package.json
+++ b/packages/features/event-management/package.json
@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
- "./components": "./src/components/index.ts"
+ "./components": "./src/components/index.ts",
+ "./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
+ "@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
+ "react-hook-form": "catalog:",
"zod": "catalog:"
}
}
diff --git a/packages/features/event-management/src/components/create-event-form.tsx b/packages/features/event-management/src/components/create-event-form.tsx
new file mode 100644
index 000000000..bdcd42098
--- /dev/null
+++ b/packages/features/event-management/src/components/create-event-form.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { CreateEventSchema } from '../schema/event.schema';
+import { createEvent } from '../server/actions/event-actions';
+
+interface Props {
+ accountId: string;
+ account: string;
+}
+
+export function CreateEventForm({ accountId, account }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(CreateEventSchema),
+ defaultValues: {
+ accountId,
+ name: '',
+ description: '',
+ eventDate: '',
+ eventTime: '',
+ endDate: '',
+ location: '',
+ capacity: undefined as number | undefined,
+ minAge: undefined as number | undefined,
+ maxAge: undefined as number | undefined,
+ fee: 0,
+ status: 'planned' as const,
+ registrationDeadline: '',
+ contactName: '',
+ contactEmail: '',
+ contactPhone: '',
+ },
+ });
+
+ const { execute, isPending } = useAction(createEvent, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Veranstaltung erfolgreich erstellt');
+ router.push(`/home/${account}/events-cms`);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Erstellen der Veranstaltung');
+ },
+ });
+
+ return (
+
+ execute(data))} className="space-y-6">
+
+ Grunddaten
+
+
+ (
+ Veranstaltungsname *
+ )} />
+
+
+ (
+ Beschreibung
+
+
+ )} />
+
+ (
+ Status
+
+ Geplant
+ Offen
+ Ausgebucht
+ Laufend
+ Abgeschlossen
+ Abgesagt
+
+
+ )} />
+
+
+
+
+ Datum & Ort
+
+ (
+ Veranstaltungsdatum *
+ )} />
+ (
+ Uhrzeit
+ )} />
+ (
+ Enddatum
+ )} />
+ (
+ Veranstaltungsort
+ )} />
+ (
+ Anmeldeschluss
+ )} />
+
+
+
+
+ Teilnehmer & Kosten
+
+ (
+ Max. Teilnehmer
+ field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
+
+ )} />
+ (
+ Gebühr (€)
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+ (
+ Mindestalter
+ field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
+
+ )} />
+ (
+ Höchstalter
+ field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
+
+ )} />
+
+
+
+
+ Kontakt
+
+ (
+ Ansprechpartner
+ )} />
+ (
+ E-Mail
+ )} />
+ (
+ Telefon
+ )} />
+
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird erstellt...' : 'Veranstaltung erstellen'}
+
+
+
+ );
+}
diff --git a/packages/features/event-management/src/components/index.ts b/packages/features/event-management/src/components/index.ts
index cb0ff5c3b..afcad20f8 100644
--- a/packages/features/event-management/src/components/index.ts
+++ b/packages/features/event-management/src/components/index.ts
@@ -1 +1 @@
-export {};
+export { CreateEventForm } from './create-event-form';
diff --git a/packages/features/event-management/src/server/actions/event-actions.ts b/packages/features/event-management/src/server/actions/event-actions.ts
new file mode 100644
index 000000000..896fabd46
--- /dev/null
+++ b/packages/features/event-management/src/server/actions/event-actions.ts
@@ -0,0 +1,51 @@
+'use server';
+
+import { z } from 'zod';
+import { authActionClient } from '@kit/next/safe-action';
+import { getLogger } from '@kit/shared/logger';
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import {
+ CreateEventSchema,
+ EventRegistrationSchema,
+ CreateHolidayPassSchema,
+} from '../../schema/event.schema';
+import { createEventManagementApi } from '../api';
+
+export const createEvent = authActionClient
+ .inputSchema(CreateEventSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createEventManagementApi(client);
+
+ logger.info({ name: 'event.create' }, 'Creating event...');
+ const result = await api.createEvent(input);
+ logger.info({ name: 'event.create' }, 'Event created');
+ return { success: true, data: result };
+ });
+
+export const registerForEvent = authActionClient
+ .inputSchema(EventRegistrationSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createEventManagementApi(client);
+
+ logger.info({ name: 'event.register' }, 'Registering for event...');
+ const result = await api.registerForEvent(input);
+ logger.info({ name: 'event.register' }, 'Registered for event');
+ return { success: true, data: result };
+ });
+
+export const createHolidayPass = authActionClient
+ .inputSchema(CreateHolidayPassSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createEventManagementApi(client);
+
+ logger.info({ name: 'event.createHolidayPass' }, 'Creating holiday pass...');
+ const result = await api.createHolidayPass(input);
+ logger.info({ name: 'event.createHolidayPass' }, 'Holiday pass created');
+ return { success: true, data: result };
+ });
diff --git a/packages/features/event-management/src/server/api.ts b/packages/features/event-management/src/server/api.ts
index 6c9264e38..953eab97f 100644
--- a/packages/features/event-management/src/server/api.ts
+++ b/packages/features/event-management/src/server/api.ts
@@ -79,5 +79,15 @@ export function createEventManagementApi(client: SupabaseClient) {
if (error) throw error;
return data ?? [];
},
+
+ async createHolidayPass(input: { accountId: string; name: string; year: number; description?: string; price?: number; validFrom?: string; validUntil?: string }) {
+ const { data, error } = await client.from('holiday_passes').insert({
+ account_id: input.accountId, name: input.name, year: input.year,
+ description: input.description, price: input.price ?? 0,
+ valid_from: input.validFrom, valid_until: input.validUntil,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
};
}
diff --git a/packages/features/finance/package.json b/packages/features/finance/package.json
index d7c82fb37..85c442426 100644
--- a/packages/features/finance/package.json
+++ b/packages/features/finance/package.json
@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
- "./components": "./src/components/index.ts"
+ "./components": "./src/components/index.ts",
+ "./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
+ "@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
+ "react-hook-form": "catalog:",
"zod": "catalog:"
}
}
diff --git a/packages/features/finance/src/components/create-invoice-form.tsx b/packages/features/finance/src/components/create-invoice-form.tsx
new file mode 100644
index 000000000..e7e0c0832
--- /dev/null
+++ b/packages/features/finance/src/components/create-invoice-form.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm, useFieldArray } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { useMemo } from 'react';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { CreateInvoiceSchema } from '../schema/finance.schema';
+import { createInvoice } from '../server/actions/finance-actions';
+
+interface Props {
+ accountId: string;
+ account: string;
+}
+
+export function CreateInvoiceForm({ accountId, account }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(CreateInvoiceSchema),
+ defaultValues: {
+ accountId,
+ invoiceNumber: '',
+ recipientName: '',
+ recipientAddress: '',
+ issueDate: new Date().toISOString().split('T')[0]!,
+ dueDate: '',
+ taxRate: 19,
+ notes: '',
+ items: [{ description: '', quantity: 1, unitPrice: 0 }],
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: 'items',
+ });
+
+ const watchedItems = form.watch('items');
+ const watchedTaxRate = form.watch('taxRate');
+
+ const { subtotal, taxAmount, total } = useMemo(() => {
+ const sub = (watchedItems ?? []).reduce((sum, item) => {
+ return sum + (item.quantity || 0) * (item.unitPrice || 0);
+ }, 0);
+ const tax = sub * ((watchedTaxRate || 0) / 100);
+ return { subtotal: sub, taxAmount: tax, total: sub + tax };
+ }, [watchedItems, watchedTaxRate]);
+
+ const { execute, isPending } = useAction(createInvoice, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Rechnung erfolgreich erstellt');
+ router.push(`/home/${account}/finance-cms`);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Erstellen der Rechnung');
+ },
+ });
+
+ const formatCurrency = (value: number) =>
+ new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
+
+ return (
+
+ execute(data))} className="space-y-6">
+
+ Rechnungsdaten
+
+ (
+ Rechnungsnummer *
+ )} />
+ (
+ Rechnungsdatum
+ )} />
+ (
+ Fälligkeitsdatum *
+ )} />
+
+
+
+
+ Empfänger
+
+ (
+ Name *
+ )} />
+ (
+ Adresse
+
+
+ )} />
+
+
+
+
+
+
+ Positionen
+ append({ description: '', quantity: 1, unitPrice: 0 })}
+ >
+ + Position hinzufügen
+
+
+
+
+ {fields.map((item, index) => (
+
+
+ (
+ Beschreibung *
+ )} />
+
+
+ (
+ Menge
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+
+
+ (
+ Einzelpreis (€)
+ field.onChange(Number(e.target.value))} />
+
+ )} />
+
+
+ {fields.length > 1 && (
+ remove(index)} className="text-destructive">
+ ✕
+
+ )}
+
+
+ ))}
+
+
+
+
+ Beträge
+
+ (
+
+ MwSt.-Satz (%)
+
+ field.onChange(Number(e.target.value))} />
+
+
+
+ )} />
+
+
+ Zwischensumme (netto)
+ {formatCurrency(subtotal)}
+
+
+ MwSt. ({watchedTaxRate}%)
+ {formatCurrency(taxAmount)}
+
+
+ Gesamtbetrag
+ {formatCurrency(total)}
+
+
+ (
+ Anmerkungen
+
+
+ )} />
+
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird erstellt...' : 'Rechnung erstellen'}
+
+
+
+ );
+}
diff --git a/packages/features/finance/src/components/create-sepa-batch-form.tsx b/packages/features/finance/src/components/create-sepa-batch-form.tsx
new file mode 100644
index 000000000..c6ac1cee9
--- /dev/null
+++ b/packages/features/finance/src/components/create-sepa-batch-form.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { z } from 'zod';
+
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+
+import { createSepaBatch } from '../server/actions/finance-actions';
+
+const FormSchema = z.object({
+ accountId: z.string().uuid(),
+ batchType: z.enum(['direct_debit', 'credit_transfer']),
+ description: z.string().optional(),
+ executionDate: z.string().min(1, 'Ausführungsdatum ist erforderlich'),
+ painFormat: z.string().default('pain.008.003.02'),
+});
+
+interface Props {
+ accountId: string;
+ account: string;
+}
+
+export function CreateSepaBatchForm({ accountId, account }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(FormSchema),
+ defaultValues: {
+ accountId,
+ batchType: 'direct_debit' as const,
+ description: '',
+ executionDate: new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]!,
+ painFormat: 'pain.008.003.02',
+ },
+ });
+
+ const { execute, isPending } = useAction(createSepaBatch, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('SEPA-Einzug erstellt');
+ router.push(`/home/${account}/finance/sepa`);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Erstellen');
+ },
+ });
+
+ return (
+
+ execute(data))} className="space-y-6 max-w-2xl">
+
+
+ SEPA-Einzug erstellen
+
+
+ (
+
+ Typ
+
+
+ Lastschrift (SEPA Core)
+ Überweisung
+
+
+
+
+ )} />
+
+ (
+
+ Beschreibung
+
+
+
+ )} />
+
+ (
+
+ Ausführungsdatum *
+
+
+
+ )} />
+
+
+
+
+ router.back()}>Abbrechen
+
+ {isPending ? 'Wird erstellt...' : 'Einzug erstellen'}
+
+
+
+
+ );
+}
diff --git a/packages/features/finance/src/components/index.ts b/packages/features/finance/src/components/index.ts
index cb0ff5c3b..d6b6b857c 100644
--- a/packages/features/finance/src/components/index.ts
+++ b/packages/features/finance/src/components/index.ts
@@ -1 +1,2 @@
-export {};
+export { CreateInvoiceForm } from './create-invoice-form';
+export { CreateSepaBatchForm } from './create-sepa-batch-form';
diff --git a/packages/features/finance/src/server/actions/finance-actions.ts b/packages/features/finance/src/server/actions/finance-actions.ts
new file mode 100644
index 000000000..68381b927
--- /dev/null
+++ b/packages/features/finance/src/server/actions/finance-actions.ts
@@ -0,0 +1,93 @@
+'use server';
+
+import { z } from 'zod';
+import { authActionClient } from '@kit/next/safe-action';
+import { getLogger } from '@kit/shared/logger';
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import {
+ CreateSepaBatchSchema,
+ AddSepaItemSchema,
+ CreateInvoiceSchema,
+} from '../../schema/finance.schema';
+import { createFinanceApi } from '../api';
+
+export const createSepaBatch = authActionClient
+ .inputSchema(CreateSepaBatchSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createFinanceApi(client);
+ const userId = ctx.user.id;
+
+ logger.info({ name: 'finance.createSepaBatch' }, 'Creating SEPA batch...');
+ const result = await api.createBatch(input, userId);
+ logger.info({ name: 'finance.createSepaBatch' }, 'SEPA batch created');
+ return { success: true, data: result };
+ });
+
+export const addSepaItem = authActionClient
+ .inputSchema(AddSepaItemSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createFinanceApi(client);
+
+ logger.info({ name: 'finance.addSepaItem' }, 'Adding SEPA item...');
+ const result = await api.addItem(input);
+ logger.info({ name: 'finance.addSepaItem' }, 'SEPA item added');
+ return { success: true, data: result };
+ });
+
+export const generateSepaXml = authActionClient
+ .inputSchema(
+ z.object({
+ batchId: z.string().uuid(),
+ accountId: z.string().uuid(),
+ creditorName: z.string(),
+ creditorIban: z.string(),
+ creditorBic: z.string(),
+ creditorId: z.string(),
+ }),
+ )
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createFinanceApi(client);
+
+ logger.info({ name: 'finance.generateSepaXml' }, 'Generating SEPA XML...');
+ const result = await api.generateSepaXml(input.batchId, {
+ name: input.creditorName,
+ iban: input.creditorIban,
+ bic: input.creditorBic,
+ creditorId: input.creditorId,
+ });
+ logger.info({ name: 'finance.generateSepaXml' }, 'SEPA XML generated');
+ return { success: true, xml: result };
+ });
+
+export const createInvoice = authActionClient
+ .inputSchema(CreateInvoiceSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createFinanceApi(client);
+ const userId = ctx.user.id;
+
+ logger.info({ name: 'finance.createInvoice' }, 'Creating invoice...');
+ const result = await api.createInvoice(input, userId);
+ logger.info({ name: 'finance.createInvoice' }, 'Invoice created');
+ return { success: true, data: result };
+ });
+
+// Gap 3: SEPA auto-populate from members
+export const populateBatchFromMembers = authActionClient
+ .inputSchema(z.object({ batchId: z.string().uuid(), accountId: z.string().uuid() }))
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createFinanceApi(client);
+ logger.info({ name: 'sepa.populate' }, 'Populating batch from members...');
+ const result = await api.populateBatchFromMembers(input.batchId, input.accountId);
+ logger.info({ name: 'sepa.populate', count: result.addedCount }, 'Populated');
+ return { success: true, addedCount: result.addedCount };
+ });
diff --git a/packages/features/finance/src/server/api.ts b/packages/features/finance/src/server/api.ts
index b9eebe982..eff530f91 100644
--- a/packages/features/finance/src/server/api.ts
+++ b/packages/features/finance/src/server/api.ts
@@ -148,6 +148,59 @@ export function createFinanceApi(client: SupabaseClient) {
return { ...data, items: items ?? [] };
},
+ // --- SEPA auto-populate from members (Gap 3) ---
+ async populateBatchFromMembers(batchId: string, accountId: string) {
+ // Get all active members with active SEPA mandates + dues categories
+ const { data: members, error: memberError } = await client
+ .from('members')
+ .select('id, first_name, last_name, dues_category_id')
+ .eq('account_id', accountId)
+ .eq('status', 'active');
+ if (memberError) throw memberError;
+
+ const { data: mandates, error: mandateError } = await client
+ .from('sepa_mandates')
+ .select('*')
+ .eq('account_id', accountId)
+ .eq('status', 'active')
+ .eq('is_primary', true);
+ if (mandateError) throw mandateError;
+
+ const { data: categories, error: catError } = await client
+ .from('dues_categories')
+ .select('id, amount')
+ .eq('account_id', accountId);
+ if (catError) throw catError;
+
+ const mandateMap = new Map((mandates ?? []).map((m: any) => [m.member_id, m]));
+ const categoryMap = new Map((categories ?? []).map((c: any) => [c.id, Number(c.amount)]));
+
+ let addedCount = 0;
+ for (const member of (members ?? []) as any[]) {
+ const mandate = mandateMap.get(member.id);
+ if (!mandate) continue;
+
+ const amount = member.dues_category_id ? categoryMap.get(member.dues_category_id) ?? 0 : 0;
+ if (amount <= 0) continue;
+
+ const { error } = await client.from('sepa_items').insert({
+ batch_id: batchId,
+ member_id: member.id,
+ debtor_name: `${member.first_name} ${member.last_name}`,
+ debtor_iban: mandate.iban,
+ debtor_bic: mandate.bic,
+ amount,
+ mandate_id: mandate.mandate_reference,
+ mandate_date: mandate.mandate_date,
+ remittance_info: `Mitgliedsbeitrag ${new Date().getFullYear()}`,
+ });
+ if (!error) addedCount++;
+ }
+
+ await this.recalculateBatchTotals(batchId);
+ return { addedCount };
+ },
+
// --- Utilities ---
validateIban,
};
diff --git a/packages/features/member-management/package.json b/packages/features/member-management/package.json
index 88ce54f90..b7ba90244 100644
--- a/packages/features/member-management/package.json
+++ b/packages/features/member-management/package.json
@@ -12,23 +12,30 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
- "./components": "./src/components/index.ts"
+ "./components": "./src/components/index.ts",
+ "./actions/*": "./src/server/actions/*.ts",
+ "./lib/*": "./src/lib/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
+ "@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
+ "@types/papaparse": "catalog:",
"@types/react": "catalog:",
+ "lucide-react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
+ "papaparse": "catalog:",
"react": "catalog:",
+ "react-hook-form": "catalog:",
"zod": "catalog:"
}
}
diff --git a/packages/features/member-management/src/components/application-workflow.tsx b/packages/features/member-management/src/components/application-workflow.tsx
new file mode 100644
index 000000000..35b5f7a4e
--- /dev/null
+++ b/packages/features/member-management/src/components/application-workflow.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import { useCallback } from 'react';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { toast } from '@kit/ui/sonner';
+import { Badge } from '@kit/ui/badge';
+import { Button } from '@kit/ui/button';
+
+import { approveApplication, rejectApplication } from '../server/actions/member-actions';
+
+interface ApplicationWorkflowProps {
+ applications: Array>;
+ accountId: string;
+ account: string;
+}
+
+const APPLICATION_STATUS_LABELS: Record = {
+ submitted: 'Eingereicht',
+ review: 'In Prüfung',
+ approved: 'Genehmigt',
+ rejected: 'Abgelehnt',
+};
+
+function getApplicationStatusColor(
+ status: string,
+): 'default' | 'secondary' | 'destructive' | 'outline' {
+ switch (status) {
+ case 'approved':
+ return 'default';
+ case 'submitted':
+ case 'review':
+ return 'outline';
+ case 'rejected':
+ return 'destructive';
+ default:
+ return 'secondary';
+ }
+}
+
+export function ApplicationWorkflow({
+ applications,
+ accountId,
+ account,
+}: ApplicationWorkflowProps) {
+ const router = useRouter();
+ const form = useForm();
+
+ const { execute: executeApprove, isPending: isApproving } = useAction(
+ approveApplication,
+ {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Antrag genehmigt – Mitglied wurde erstellt');
+ router.refresh();
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Genehmigen');
+ },
+ },
+ );
+
+ const { execute: executeReject, isPending: isRejecting } = useAction(
+ rejectApplication,
+ {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Antrag wurde abgelehnt');
+ router.refresh();
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Ablehnen');
+ },
+ },
+ );
+
+ const handleApprove = useCallback(
+ (applicationId: string) => {
+ if (
+ !window.confirm(
+ 'Mitglied wird automatisch erstellt. Fortfahren?',
+ )
+ ) {
+ return;
+ }
+ executeApprove({ applicationId, accountId });
+ },
+ [executeApprove, accountId],
+ );
+
+ const handleReject = useCallback(
+ (applicationId: string) => {
+ const reason = window.prompt(
+ 'Bitte geben Sie einen Ablehnungsgrund ein:',
+ );
+ if (reason === null) return; // cancelled
+ executeReject({
+ applicationId,
+ accountId,
+ reviewNotes: reason,
+ });
+ },
+ [executeReject, accountId],
+ );
+
+ const isPending = isApproving || isRejecting;
+
+ return (
+
+
+
Aufnahmeanträge
+
+ {applications.length} Antrag{applications.length !== 1 ? 'e' : ''}
+
+
+
+
+
+
+
+ Name
+ E-Mail
+ Datum
+ Status
+ Aktionen
+
+
+
+ {applications.length === 0 ? (
+
+
+ Keine Aufnahmeanträge vorhanden.
+
+
+ ) : (
+ applications.map((app) => {
+ const appId = String(app.id ?? '');
+ const appStatus = String(app.status ?? 'submitted');
+ const isActionable =
+ appStatus === 'submitted' || appStatus === 'review';
+
+ return (
+
+
+ {String(app.last_name ?? '')},{' '}
+ {String(app.first_name ?? '')}
+
+
+ {String(app.email ?? '—')}
+
+
+ {app.created_at
+ ? new Date(String(app.created_at)).toLocaleDateString(
+ 'de-DE',
+ )
+ : '—'}
+
+
+
+ {APPLICATION_STATUS_LABELS[appStatus] ?? appStatus}
+
+
+
+ {isActionable && (
+
+ handleApprove(appId)}
+ >
+ Genehmigen
+
+ handleReject(appId)}
+ >
+ Ablehnen
+
+
+ )}
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/features/member-management/src/components/create-member-form.tsx b/packages/features/member-management/src/components/create-member-form.tsx
new file mode 100644
index 000000000..584209900
--- /dev/null
+++ b/packages/features/member-management/src/components/create-member-form.tsx
@@ -0,0 +1,243 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { CreateMemberSchema } from '../schema/member.schema';
+import { createMember } from '../server/actions/member-actions';
+
+interface Props {
+ accountId: string;
+ account: string; // slug for redirect
+ duesCategories: Array<{ id: string; name: string; amount: number }>;
+}
+
+export function CreateMemberForm({ accountId, account, duesCategories }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(CreateMemberSchema),
+ defaultValues: {
+ accountId,
+ firstName: '', lastName: '', email: '', phone: '', mobile: '',
+ street: '', houseNumber: '', postalCode: '', city: '', country: 'DE',
+ memberNumber: '', status: 'active' as const, entryDate: new Date().toISOString().split('T')[0]!,
+ iban: '', bic: '', accountHolder: '', gdprConsent: false, notes: '',
+ },
+ });
+
+ const { execute, isPending } = useAction(createMember, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Mitglied erfolgreich erstellt');
+ router.push(`/home/${account}/members-cms`);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Erstellen');
+ },
+ });
+
+ return (
+
+ execute(data))} className="space-y-6">
+
+ Persönliche Daten
+
+ (
+ Vorname *
+ )} />
+ (
+ Nachname *
+ )} />
+ (
+ Geburtsdatum
+ )} />
+ (
+ Geschlecht
+
+ — Bitte wählen —
+ Männlich
+ Weiblich
+ Divers
+
+
+ )} />
+
+
+
+ Kontakt
+
+ (
+ E-Mail
+ )} />
+ (
+ Telefon
+ )} />
+ (
+ Mobil
+ )} />
+
+
+
+ Adresse
+
+ (
+ Straße
+ )} />
+ (
+ Hausnummer
+ )} />
+ (
+ PLZ
+ )} />
+ (
+ Ort
+ )} />
+
+
+
+ Mitgliedschaft
+
+ (
+ Mitgliedsnr.
+ )} />
+ (
+ Status
+
+ Aktiv
+ Inaktiv
+ Ausstehend
+
+
+ )} />
+ (
+ Eintrittsdatum
+ )} />
+ {duesCategories.length > 0 && (
+ (
+ Beitragskategorie
+
+ — Keine —
+ {duesCategories.map(c => {c.name} ({c.amount} €) )}
+
+
+ )} />
+ )}
+
+
+
+ SEPA-Bankdaten
+
+ (
+ IBAN field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} />
+ )} />
+ (
+ BIC
+ )} />
+ (
+ Kontoinhaber
+ )} />
+
+
+
+ {/* Guardian (Gap 4) */}
+
+ Erziehungsberechtigte (Jugend)
+
+ (
+ Name Erziehungsberechtigte/r
+ )} />
+ (
+ Telefon
+ )} />
+ (
+ E-Mail
+ )} />
+
+
+
+ {/* Lifecycle flags (Gap 4) */}
+
+ Mitgliedschaftsmerkmale
+
+ {([
+ ['isHonorary', 'Ehrenmitglied'],
+ ['isFoundingMember', 'Gründungsmitglied'],
+ ['isYouth', 'Jugendmitglied'],
+ ['isRetiree', 'Rentner/Senior'],
+ ['isProbationary', 'Probejahr'],
+ ] as const).map(([name, label]) => (
+ (
+
+
+
+
+ {label}
+
+ )} />
+ ))}
+
+
+
+ {/* GDPR granular (Gap 4) */}
+
+ Datenschutz-Einwilligungen
+
+ {([
+ ['gdprConsent', 'Allgemeine Einwilligung'],
+ ['gdprNewsletter', 'Newsletter'],
+ ['gdprInternet', 'Internet/Homepage'],
+ ['gdprPrint', 'Vereinszeitung'],
+ ['gdprBirthdayInfo', 'Geburtstagsinfo'],
+ ] as const).map(([name, label]) => (
+ (
+
+
+
+
+ {label}
+
+ )} />
+ ))}
+
+
+
+
+ Sonstiges
+
+
+ (
+ Anrede
+
+ — Keine —
+ Herr
+ Frau
+
+
+ )} />
+ (
+ Geburtsort
+ )} />
+ (
+ Adresszusatz
+ )} />
+
+ (
+ Notizen
+ )} />
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird erstellt...' : 'Mitglied erstellen'}
+
+
+
+ );
+}
diff --git a/packages/features/member-management/src/components/dues-category-manager.tsx b/packages/features/member-management/src/components/dues-category-manager.tsx
new file mode 100644
index 000000000..42115c030
--- /dev/null
+++ b/packages/features/member-management/src/components/dues-category-manager.tsx
@@ -0,0 +1,258 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { toast } from '@kit/ui/sonner';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+
+import {
+ createDuesCategory,
+ deleteDuesCategory,
+} from '../server/actions/member-actions';
+
+interface DuesCategoryManagerProps {
+ categories: Array>;
+ accountId: string;
+}
+
+const INTERVAL_LABELS: Record = {
+ monthly: 'Monatlich',
+ quarterly: 'Vierteljährlich',
+ semiannual: 'Halbjährlich',
+ annual: 'Jährlich',
+};
+
+interface CategoryFormValues {
+ name: string;
+ description: string;
+ amount: number;
+ interval: string;
+ isDefault: boolean;
+}
+
+export function DuesCategoryManager({
+ categories,
+ accountId,
+}: DuesCategoryManagerProps) {
+ const router = useRouter();
+ const [showForm, setShowForm] = useState(false);
+
+ const form = useForm({
+ defaultValues: {
+ name: '',
+ description: '',
+ amount: 0,
+ interval: 'annual',
+ isDefault: false,
+ },
+ });
+
+ const { execute: executeCreate, isPending: isCreating } = useAction(
+ createDuesCategory,
+ {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Beitragskategorie erstellt');
+ form.reset();
+ setShowForm(false);
+ router.refresh();
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Erstellen');
+ },
+ },
+ );
+
+ const { execute: executeDelete, isPending: isDeletePending } = useAction(
+ deleteDuesCategory,
+ {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Beitragskategorie gelöscht');
+ router.refresh();
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Löschen');
+ },
+ },
+ );
+
+ const handleSubmit = useCallback(
+ (values: CategoryFormValues) => {
+ executeCreate({
+ accountId,
+ name: values.name,
+ description: values.description,
+ amount: Number(values.amount),
+ interval: values.interval as 'monthly' | 'quarterly' | 'half_yearly' | 'yearly',
+ isDefault: values.isDefault,
+ });
+ },
+ [executeCreate, accountId],
+ );
+
+ const handleDelete = useCallback(
+ (categoryId: string, categoryName: string) => {
+ if (
+ !window.confirm(
+ `Beitragskategorie "${categoryName}" wirklich löschen?`,
+ )
+ ) {
+ return;
+ }
+ executeDelete({ categoryId });
+ },
+ [executeDelete],
+ );
+
+ return (
+
+
+
Beitragskategorien
+ setShowForm(!showForm)}
+ >
+ {showForm ? 'Abbrechen' : 'Neue Kategorie'}
+
+
+
+ {/* Inline Create Form */}
+ {showForm && (
+
+
+ Neue Beitragskategorie
+
+
+
+
+ Name *
+
+
+
+ Betrag (€) *
+
+
+
+ Intervall
+
+ Monatlich
+ Vierteljährlich
+ Halbjährlich
+ Jährlich
+
+
+
+
+
+ Standard
+
+
+
+
+ {isCreating ? 'Erstelle...' : 'Erstellen'}
+
+
+
+
+
+ )}
+
+ {/* Table */}
+
+
+
+
+ Name
+ Beschreibung
+ Betrag
+ Intervall
+ Standard
+ Aktionen
+
+
+
+ {categories.length === 0 ? (
+
+
+ Keine Beitragskategorien vorhanden.
+
+
+ ) : (
+ categories.map((cat) => {
+ const catId = String(cat.id ?? '');
+ const catName = String(cat.name ?? '');
+ const interval = String(cat.interval ?? '');
+ const amount = Number(cat.amount ?? 0);
+ const isDefault = Boolean(cat.is_default);
+
+ return (
+
+ {catName}
+
+ {String(cat.description ?? '—')}
+
+
+ {amount.toLocaleString('de-DE', {
+ style: 'currency',
+ currency: 'EUR',
+ })}
+
+
+ {INTERVAL_LABELS[interval] ?? interval}
+
+
+ {isDefault ? '✓' : '✗'}
+
+
+ handleDelete(catId, catName)}
+ >
+ Löschen
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/features/member-management/src/components/edit-member-form.tsx b/packages/features/member-management/src/components/edit-member-form.tsx
new file mode 100644
index 000000000..2be8cbc29
--- /dev/null
+++ b/packages/features/member-management/src/components/edit-member-form.tsx
@@ -0,0 +1,228 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+
+import { UpdateMemberSchema } from '../schema/member.schema';
+import { updateMember } from '../server/actions/member-actions';
+
+interface Props {
+ member: Record;
+ account: string;
+ accountId: string;
+}
+
+export function EditMemberForm({ member, account, accountId }: Props) {
+ const router = useRouter();
+ const memberId = String(member.id);
+
+ const form = useForm({
+ resolver: zodResolver(UpdateMemberSchema),
+ defaultValues: {
+ memberId,
+ accountId,
+ firstName: String(member.first_name ?? ''),
+ lastName: String(member.last_name ?? ''),
+ email: String(member.email ?? ''),
+ phone: String(member.phone ?? ''),
+ mobile: String(member.mobile ?? ''),
+ street: String(member.street ?? ''),
+ houseNumber: String(member.house_number ?? ''),
+ postalCode: String(member.postal_code ?? ''),
+ city: String(member.city ?? ''),
+ status: String(member.status ?? 'active') as 'active',
+ memberNumber: String(member.member_number ?? ''),
+ salutation: String(member.salutation ?? ''),
+ birthplace: String(member.birthplace ?? ''),
+ street2: String(member.street2 ?? ''),
+ guardianName: String(member.guardian_name ?? ''),
+ guardianPhone: String(member.guardian_phone ?? ''),
+ guardianEmail: String(member.guardian_email ?? ''),
+ iban: String(member.iban ?? ''),
+ bic: String(member.bic ?? ''),
+ accountHolder: String(member.account_holder ?? ''),
+ notes: String(member.notes ?? ''),
+ isHonorary: Boolean(member.is_honorary),
+ isFoundingMember: Boolean(member.is_founding_member),
+ isYouth: Boolean(member.is_youth),
+ isRetiree: Boolean(member.is_retiree),
+ isProbationary: Boolean(member.is_probationary),
+ gdprConsent: Boolean(member.gdpr_consent),
+ gdprNewsletter: Boolean(member.gdpr_newsletter),
+ gdprInternet: Boolean(member.gdpr_internet),
+ gdprPrint: Boolean(member.gdpr_print),
+ gdprBirthdayInfo: Boolean(member.gdpr_birthday_info),
+ },
+ });
+
+ const { execute, isPending } = useAction(updateMember, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Mitglied aktualisiert');
+ router.push(`/home/${account}/members-cms/${memberId}`);
+ router.refresh();
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
+ },
+ });
+
+ return (
+
+ execute(data))} className="space-y-6">
+
+ Persönliche Daten
+
+ (
+ Anrede
+
+ — Herr Frau
+
+
+ )} />
+
+ (
+ Vorname *
+ )} />
+ (
+ Nachname *
+ )} />
+ (
+ Mitgliedsnr.
+ )} />
+ (
+ Geburtsort
+ )} />
+
+
+
+
+ Kontakt
+
+ (
+ E-Mail
+ )} />
+ (
+ Telefon
+ )} />
+ (
+ Mobil
+ )} />
+
+
+
+
+ Adresse
+
+ (
+ Straße
+ )} />
+ (
+ Hausnummer
+ )} />
+ (
+ Adresszusatz
+ )} />
+
+ (
+ PLZ
+ )} />
+ (
+ Ort
+ )} />
+
+
+
+
+ SEPA-Bankdaten
+
+ (
+ IBAN field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} />
+ )} />
+ (
+ BIC
+ )} />
+ (
+ Kontoinhaber
+ )} />
+
+
+
+
+ Erziehungsberechtigte
+
+ (
+ Name
+ )} />
+ (
+ Telefon
+ )} />
+ (
+ E-Mail
+ )} />
+
+
+
+
+ Merkmale & Datenschutz
+
+
+ {([
+ ['isHonorary', 'Ehrenmitglied'], ['isFoundingMember', 'Gründungsmitglied'],
+ ['isYouth', 'Jugend'], ['isRetiree', 'Rentner'],
+ ['isProbationary', 'Probejahr'],
+ ] as const).map(([name, label]) => (
+ (
+
+
+ {label}
+
+ )} />
+ ))}
+
+
+
DSGVO-Einwilligungen
+
+ {([
+ ['gdprConsent', 'Allgemein'], ['gdprNewsletter', 'Newsletter'],
+ ['gdprInternet', 'Internet'], ['gdprPrint', 'Zeitung'],
+ ['gdprBirthdayInfo', 'Geburtstag'],
+ ] as const).map(([name, label]) => (
+ (
+
+
+ {label}
+
+ )} />
+ ))}
+
+
+
+
+
+
+ Notizen
+
+ (
+
+ )} />
+
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird gespeichert...' : 'Änderungen speichern'}
+
+
+
+ );
+}
diff --git a/packages/features/member-management/src/components/index.ts b/packages/features/member-management/src/components/index.ts
index 2804219cb..176706564 100644
--- a/packages/features/member-management/src/components/index.ts
+++ b/packages/features/member-management/src/components/index.ts
@@ -1,3 +1,8 @@
-export {};
-// Phase 4 components: members-table, member-form, member-detail,
-// application-workflow, dues-category-manager, member-statistics-dashboard
+export { CreateMemberForm } from './create-member-form';
+export { EditMemberForm } from './edit-member-form';
+export { MembersDataTable } from './members-data-table';
+export { MemberDetailView } from './member-detail-view';
+export { ApplicationWorkflow } from './application-workflow';
+export { DuesCategoryManager } from './dues-category-manager';
+export { MandateManager } from './mandate-manager';
+export { MemberImportWizard } from './member-import-wizard';
diff --git a/packages/features/member-management/src/components/mandate-manager.tsx b/packages/features/member-management/src/components/mandate-manager.tsx
new file mode 100644
index 000000000..05a326359
--- /dev/null
+++ b/packages/features/member-management/src/components/mandate-manager.tsx
@@ -0,0 +1,309 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { toast } from '@kit/ui/sonner';
+import { Badge } from '@kit/ui/badge';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+
+import { formatIban } from '../lib/member-utils';
+import { createMandate, revokeMandate } from '../server/actions/member-actions';
+
+interface MandateManagerProps {
+ mandates: Array>;
+ memberId: string;
+ accountId: string;
+}
+
+const SEQUENCE_LABELS: Record = {
+ FRST: 'Erstlastschrift',
+ RCUR: 'Wiederkehrend',
+ FNAL: 'Letzte',
+ OOFF: 'Einmalig',
+};
+
+function getMandateStatusColor(
+ status: string,
+): 'default' | 'secondary' | 'destructive' | 'outline' {
+ switch (status) {
+ case 'active':
+ return 'default';
+ case 'pending':
+ return 'outline';
+ case 'revoked':
+ case 'expired':
+ return 'destructive';
+ default:
+ return 'secondary';
+ }
+}
+
+const MANDATE_STATUS_LABELS: Record = {
+ active: 'Aktiv',
+ pending: 'Ausstehend',
+ revoked: 'Widerrufen',
+ expired: 'Abgelaufen',
+};
+
+interface MandateFormValues {
+ mandateReference: string;
+ iban: string;
+ bic: string;
+ accountHolder: string;
+ mandateDate: string;
+ sequence: string;
+}
+
+export function MandateManager({
+ mandates,
+ memberId,
+ accountId,
+}: MandateManagerProps) {
+ const router = useRouter();
+ const [showForm, setShowForm] = useState(false);
+
+ const form = useForm({
+ defaultValues: {
+ mandateReference: '',
+ iban: '',
+ bic: '',
+ accountHolder: '',
+ mandateDate: new Date().toISOString().split('T')[0]!,
+ sequence: 'FRST',
+ },
+ });
+
+ const { execute: executeCreate, isPending: isCreating } = useAction(
+ createMandate,
+ {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('SEPA-Mandat erstellt');
+ form.reset();
+ setShowForm(false);
+ router.refresh();
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Erstellen');
+ },
+ },
+ );
+
+ const { execute: executeRevoke, isPending: isRevoking } = useAction(
+ revokeMandate,
+ {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Mandat widerrufen');
+ router.refresh();
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Widerrufen');
+ },
+ },
+ );
+
+ const handleSubmit = useCallback(
+ (values: MandateFormValues) => {
+ executeCreate({
+ memberId,
+ accountId,
+ mandateReference: values.mandateReference,
+ iban: values.iban,
+ bic: values.bic,
+ accountHolder: values.accountHolder,
+ mandateDate: values.mandateDate,
+ sequence: values.sequence as "FRST" | "RCUR" | "FNAL" | "OOFF",
+ });
+ },
+ [executeCreate, memberId, accountId],
+ );
+
+ const handleRevoke = useCallback(
+ (mandateId: string, reference: string) => {
+ if (
+ !window.confirm(
+ `Mandat "${reference}" wirklich widerrufen?`,
+ )
+ ) {
+ return;
+ }
+ executeRevoke({ mandateId });
+ },
+ [executeRevoke],
+ );
+
+ return (
+
+ );
+}
diff --git a/packages/features/member-management/src/components/member-detail-view.tsx b/packages/features/member-management/src/components/member-detail-view.tsx
new file mode 100644
index 000000000..a9a924d3d
--- /dev/null
+++ b/packages/features/member-management/src/components/member-detail-view.tsx
@@ -0,0 +1,227 @@
+'use client';
+
+import { useCallback } from 'react';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { toast } from '@kit/ui/sonner';
+import { Badge } from '@kit/ui/badge';
+import { Button } from '@kit/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+
+import {
+ STATUS_LABELS,
+ getMemberStatusColor,
+ formatAddress,
+ formatIban,
+ computeAge,
+ computeMembershipYears,
+} from '../lib/member-utils';
+import { deleteMember, updateMember } from '../server/actions/member-actions';
+
+interface MemberDetailViewProps {
+ member: Record;
+ account: string;
+ accountId: string;
+}
+
+function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value ?? '—'}
+
+ );
+}
+
+export function MemberDetailView({ member, account, accountId }: MemberDetailViewProps) {
+ const router = useRouter();
+
+ const memberId = String(member.id ?? '');
+ const status = String(member.status ?? 'active');
+ const firstName = String(member.first_name ?? '');
+ const lastName = String(member.last_name ?? '');
+ const fullName = `${firstName} ${lastName}`.trim();
+
+ const form = useForm();
+
+ const { execute: executeDelete, isPending: isDeleting } = useAction(deleteMember, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Mitglied wurde gekündigt');
+ router.push(`/home/${account}/members-cms`);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Kündigen');
+ },
+ });
+
+ const { execute: executeUpdate, isPending: isUpdating } = useAction(updateMember, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Mitglied wurde archiviert');
+ router.refresh();
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Archivieren');
+ },
+ });
+
+ const handleDelete = useCallback(() => {
+ if (!window.confirm(`Möchten Sie ${fullName} wirklich kündigen? Diese Aktion kann nicht rückgängig gemacht werden.`)) {
+ return;
+ }
+ executeDelete({ memberId, accountId });
+ }, [executeDelete, memberId, accountId, fullName]);
+
+ const handleArchive = useCallback(() => {
+ if (!window.confirm(`Möchten Sie ${fullName} wirklich archivieren?`)) {
+ return;
+ }
+ executeUpdate({
+ memberId,
+ accountId,
+ isArchived: true,
+ });
+ }, [executeUpdate, memberId, accountId, fullName]);
+
+ const age = computeAge(member.date_of_birth as string | null | undefined);
+ const membershipYears = computeMembershipYears(member.entry_date as string | null | undefined);
+ const address = formatAddress(member);
+ const iban = formatIban(member.iban as string | null | undefined);
+
+ return (
+
+ {/* Header */}
+
+
+
+
{fullName}
+
+ {STATUS_LABELS[status] ?? status}
+
+
+
+ Mitgliedsnr. {String(member.member_number ?? '—')}
+
+
+
+
+ router.push(`/home/${account}/members-cms/${memberId}/edit`)
+ }
+ >
+ Bearbeiten
+
+
+ {isUpdating ? 'Archiviere...' : 'Archivieren'}
+
+
+ {isDeleting ? 'Wird gekündigt...' : 'Kündigen'}
+
+
+
+
+ {/* Detail Cards */}
+
+ {/* Persönliche Daten */}
+
+
+ Persönliche Daten
+
+
+
+
+
+
+
+
+
+
+ {/* Kontakt */}
+
+
+ Kontakt
+
+
+
+
+
+
+
+
+ {/* Adresse */}
+
+
+ Adresse
+
+
+
+
+
+
+
+
+
+ {/* Mitgliedschaft */}
+
+
+ Mitgliedschaft
+
+
+
+
+ {STATUS_LABELS[status] ?? status}
+
+ }
+ />
+
+ 0 ? `${membershipYears} Jahre` : '—'}
+ />
+
+
+
+
+
+
+
+
+ {/* Back */}
+
+ router.back()}>
+ ← Zurück zur Übersicht
+
+
+
+ );
+}
diff --git a/packages/features/member-management/src/components/member-import-wizard.tsx b/packages/features/member-management/src/components/member-import-wizard.tsx
new file mode 100644
index 000000000..ea98b3620
--- /dev/null
+++ b/packages/features/member-management/src/components/member-import-wizard.tsx
@@ -0,0 +1,281 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import Papa from 'papaparse';
+
+import { Button } from '@kit/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Badge } from '@kit/ui/badge';
+import { toast } from '@kit/ui/sonner';
+import { useAction } from 'next-safe-action/hooks';
+import { Upload, ArrowRight, ArrowLeft, CheckCircle, AlertTriangle } from 'lucide-react';
+
+import { createMember } from '../server/actions/member-actions';
+
+const MEMBER_FIELDS = [
+ { key: 'memberNumber', label: 'Mitgliedsnr.' },
+ { key: 'salutation', label: 'Anrede' },
+ { key: 'firstName', label: 'Vorname' },
+ { key: 'lastName', label: 'Nachname' },
+ { key: 'dateOfBirth', label: 'Geburtsdatum' },
+ { key: 'email', label: 'E-Mail' },
+ { key: 'phone', label: 'Telefon' },
+ { key: 'mobile', label: 'Mobil' },
+ { key: 'street', label: 'Straße' },
+ { key: 'houseNumber', label: 'Hausnummer' },
+ { key: 'postalCode', label: 'PLZ' },
+ { key: 'city', label: 'Ort' },
+ { key: 'entryDate', label: 'Eintrittsdatum' },
+ { key: 'iban', label: 'IBAN' },
+ { key: 'bic', label: 'BIC' },
+ { key: 'accountHolder', label: 'Kontoinhaber' },
+ { key: 'notes', label: 'Notizen' },
+] as const;
+
+interface Props {
+ accountId: string;
+ account: string;
+}
+
+type Step = 'upload' | 'mapping' | 'preview' | 'importing' | 'done';
+
+export function MemberImportWizard({ accountId, account }: Props) {
+ const router = useRouter();
+ const [step, setStep] = useState('upload');
+ const [rawData, setRawData] = useState([]);
+ const [headers, setHeaders] = useState([]);
+ const [mapping, setMapping] = useState>({});
+ const [importResults, setImportResults] = useState<{ success: number; errors: string[] }>({ success: 0, errors: [] });
+
+ const { execute: executeCreate } = useAction(createMember);
+
+ // Step 1: Parse file
+ const handleFileUpload = useCallback((e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ Papa.parse(file, {
+ delimiter: ';',
+ encoding: 'UTF-8',
+ complete: (result) => {
+ const data = result.data as string[][];
+ if (data.length < 2) {
+ toast.error('Datei enthält keine Daten');
+ return;
+ }
+ setHeaders(data[0]!);
+ setRawData(data.slice(1).filter(row => row.some(cell => cell?.trim())));
+
+ // Auto-map by header name similarity
+ const autoMap: Record = {};
+ for (const field of MEMBER_FIELDS) {
+ const match = data[0]!.findIndex(h =>
+ h.toLowerCase().includes(field.label.toLowerCase().replace('.', '')) ||
+ h.toLowerCase().includes(field.key.toLowerCase())
+ );
+ if (match >= 0) autoMap[field.key] = String(match);
+ }
+ setMapping(autoMap);
+ setStep('mapping');
+ toast.success(`${data.length - 1} Zeilen erkannt`);
+ },
+ error: (err) => {
+ toast.error(`Fehler beim Lesen: ${err.message}`);
+ },
+ });
+ }, []);
+
+ // Step 3: Execute import
+ const executeImport = useCallback(async () => {
+ setStep('importing');
+ let success = 0;
+ const errors: string[] = [];
+
+ for (let i = 0; i < rawData.length; i++) {
+ const row = rawData[i]!;
+ try {
+ const memberData: Record = { accountId };
+ for (const field of MEMBER_FIELDS) {
+ const colIdx = mapping[field.key];
+ if (colIdx !== undefined && row[Number(colIdx)]) {
+ memberData[field.key] = row[Number(colIdx)]!.trim();
+ }
+ }
+
+ if (!memberData.firstName || !memberData.lastName) {
+ errors.push(`Zeile ${i + 2}: Vor-/Nachname fehlt`);
+ continue;
+ }
+
+ await executeCreate(memberData as any);
+ success++;
+ } catch (err) {
+ errors.push(`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`);
+ }
+ }
+
+ setImportResults({ success, errors });
+ setStep('done');
+ toast.success(`${success} Mitglieder importiert`);
+ }, [rawData, mapping, accountId, executeCreate]);
+
+ const getMappedValue = (rowIdx: number, fieldKey: string): string => {
+ const colIdx = mapping[fieldKey];
+ if (colIdx === undefined) return '';
+ return rawData[rowIdx]?.[Number(colIdx)]?.trim() ?? '';
+ };
+
+ return (
+
+ {/* Step indicator */}
+
+ {(['upload', 'mapping', 'preview', 'done'] as const).map((s, i) => {
+ const labels = ['Datei hochladen', 'Spalten zuordnen', 'Vorschau & Import', 'Fertig'];
+ const isActive = ['upload', 'mapping', 'preview', 'importing', 'done'].indexOf(step) >= i;
+ return (
+
+
{i + 1}
+
{labels[i]}
+ {i < 3 &&
}
+
+ );
+ })}
+
+
+ {/* Step 1: Upload */}
+ {step === 'upload' && (
+
+ Datei hochladen
+
+
+
+
CSV-Datei auswählen
+
Semikolon-getrennt (;), UTF-8
+
+
+
+
+ )}
+
+ {/* Step 2: Column mapping */}
+ {step === 'mapping' && (
+
+ Spalten zuordnen
+
+ {rawData.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den Mitgliedsfeldern zu.
+
+ {MEMBER_FIELDS.map(field => (
+
+ {field.label}
+ →
+ setMapping(prev => ({ ...prev, [field.key]: e.target.value }))}
+ className="flex h-9 w-64 rounded-md border border-input bg-background px-3 py-1 text-sm"
+ >
+ — Nicht zuordnen —
+ {headers.map((h, i) => (
+ {h}
+ ))}
+
+ {mapping[field.key] !== undefined && rawData[0] && (
+ z.B. "{rawData[0][Number(mapping[field.key])]}"
+ )}
+
+ ))}
+
+
+
setStep('upload')}> Zurück
+
setStep('preview')}>Vorschau
+
+
+
+ )}
+
+ {/* Step 3: Preview + execute */}
+ {step === 'preview' && (
+
+ Vorschau ({rawData.length} Einträge)
+
+
+
+
+
+ #
+ {MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
+ {f.label}
+ ))}
+
+
+
+ {rawData.slice(0, 20).map((_, i) => {
+ const hasName = getMappedValue(i, 'firstName') && getMappedValue(i, 'lastName');
+ return (
+
+ {i + 1} {!hasName && }
+ {MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
+ {getMappedValue(i, f.key) || '—'}
+ ))}
+
+ );
+ })}
+
+
+
+ {rawData.length > 20 && ... und {rawData.length - 20} weitere Einträge
}
+
+
setStep('mapping')}> Zurück
+
+
+ {rawData.length} Mitglieder importieren
+
+
+
+
+ )}
+
+ {/* Importing */}
+ {step === 'importing' && (
+
+
+
+ Importiere Mitglieder...
+ Bitte warten Sie, bis der Import abgeschlossen ist.
+
+
+ )}
+
+ {/* Done */}
+ {step === 'done' && (
+
+
+
+ Import abgeschlossen
+
+ {importResults.success} erfolgreich
+ {importResults.errors.length > 0 && (
+ {importResults.errors.length} Fehler
+ )}
+
+ {importResults.errors.length > 0 && (
+
+ {importResults.errors.map((err, i) => (
+
{err}
+ ))}
+
+ )}
+
+ router.push(`/home/${account}/members-cms`)}>
+ Zur Mitgliederliste
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/features/member-management/src/components/members-data-table.tsx b/packages/features/member-management/src/components/members-data-table.tsx
new file mode 100644
index 000000000..f1ecdaf4e
--- /dev/null
+++ b/packages/features/member-management/src/components/members-data-table.tsx
@@ -0,0 +1,230 @@
+'use client';
+
+import { useCallback } from 'react';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { toast } from '@kit/ui/sonner';
+import { Badge } from '@kit/ui/badge';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+
+import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
+
+interface MembersDataTableProps {
+ data: Array>;
+ total: number;
+ page: number;
+ pageSize: number;
+ account: string;
+ duesCategories: Array<{ id: string; name: string }>;
+}
+
+const STATUS_OPTIONS = [
+ { value: '', label: 'Alle' },
+ { value: 'active', label: 'Aktiv' },
+ { value: 'inactive', label: 'Inaktiv' },
+ { value: 'pending', label: 'Ausstehend' },
+ { value: 'resigned', label: 'Ausgetreten' },
+] as const;
+
+export function MembersDataTable({
+ data,
+ total,
+ page,
+ pageSize,
+ account,
+ duesCategories,
+}: MembersDataTableProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const currentSearch = searchParams.get('search') ?? '';
+ const currentStatus = searchParams.get('status') ?? '';
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+
+ const form = useForm({
+ defaultValues: {
+ search: currentSearch,
+ },
+ });
+
+ const updateParams = useCallback(
+ (updates: Record) => {
+ const params = new URLSearchParams(searchParams.toString());
+ for (const [key, value] of Object.entries(updates)) {
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ }
+ // Reset to page 1 on filter change
+ if (!('page' in updates)) {
+ params.delete('page');
+ }
+ router.push(`?${params.toString()}`);
+ },
+ [router, searchParams],
+ );
+
+ const handleSearch = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ const search = form.getValues('search');
+ updateParams({ search });
+ },
+ [form, updateParams],
+ );
+
+ const handleStatusChange = useCallback(
+ (e: React.ChangeEvent) => {
+ updateParams({ status: e.target.value });
+ },
+ [updateParams],
+ );
+
+ const handlePageChange = useCallback(
+ (newPage: number) => {
+ updateParams({ page: String(newPage) });
+ },
+ [updateParams],
+ );
+
+ const handleRowClick = useCallback(
+ (memberId: string) => {
+ router.push(`/home/${account}/members-cms/${memberId}`);
+ },
+ [router, account],
+ );
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+
+ Suchen
+
+
+
+
+
+ {STATUS_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+ router.push(`/home/${account}/members-cms/new`)
+ }
+ >
+ Neues Mitglied
+
+
+
+
+ {/* Table */}
+
+
+
+
+ Nr
+ Name
+ E-Mail
+ Ort
+ Status
+ Eintritt
+
+
+
+ {data.length === 0 ? (
+
+
+ Keine Mitglieder gefunden.
+
+
+ ) : (
+ data.map((member) => {
+ const memberId = String(member.id ?? '');
+ const status = String(member.status ?? 'active');
+ return (
+ handleRowClick(memberId)}
+ className="cursor-pointer border-b transition-colors hover:bg-muted/50"
+ >
+
+ {String(member.member_number ?? '—')}
+
+
+ {String(member.last_name ?? '')},{' '}
+ {String(member.first_name ?? '')}
+
+
+ {String(member.email ?? '—')}
+
+
+ {String(member.city ?? '—')}
+
+
+
+ {STATUS_LABELS[status] ?? status}
+
+
+
+ {member.entry_date
+ ? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
+ : '—'}
+
+
+ );
+ })
+ )}
+
+
+
+
+ {/* Pagination */}
+
+
+ {total} Mitglied{total !== 1 ? 'er' : ''} insgesamt
+
+
+ handlePageChange(page - 1)}
+ >
+ ← Zurück
+
+
+ Seite {page} von {totalPages}
+
+ = totalPages}
+ onClick={() => handlePageChange(page + 1)}
+ >
+ Weiter →
+
+
+
+
+ );
+}
diff --git a/packages/features/member-management/src/lib/member-utils.ts b/packages/features/member-management/src/lib/member-utils.ts
new file mode 100644
index 000000000..094442a19
--- /dev/null
+++ b/packages/features/member-management/src/lib/member-utils.ts
@@ -0,0 +1,69 @@
+/**
+ * Client-side utility functions for member display.
+ */
+
+export function computeAge(dateOfBirth: string | null | undefined): number | null {
+ if (!dateOfBirth) return null;
+ const birth = new Date(dateOfBirth);
+ const today = new Date();
+ let age = today.getFullYear() - birth.getFullYear();
+ const m = today.getMonth() - birth.getMonth();
+ if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
+ return age;
+}
+
+export function computeMembershipYears(entryDate: string | null | undefined): number {
+ if (!entryDate) return 0;
+ const entry = new Date(entryDate);
+ const today = new Date();
+ let years = today.getFullYear() - entry.getFullYear();
+ const m = today.getMonth() - entry.getMonth();
+ if (m < 0 || (m === 0 && today.getDate() < entry.getDate())) years--;
+ return Math.max(0, years);
+}
+
+export function formatSalutation(salutation: string | null | undefined, firstName: string, lastName: string): string {
+ if (salutation) return `${salutation} ${firstName} ${lastName}`;
+ return `${firstName} ${lastName}`;
+}
+
+export function formatAddress(member: Record): string {
+ const parts: string[] = [];
+ if (member.street) {
+ let line = String(member.street);
+ if (member.house_number) line += ` ${member.house_number}`;
+ parts.push(line);
+ }
+ if (member.street2) parts.push(String(member.street2));
+ if (member.postal_code || member.city) {
+ parts.push(`${member.postal_code ?? ''} ${member.city ?? ''}`.trim());
+ }
+ return parts.join(', ');
+}
+
+export function formatIban(iban: string | null | undefined): string {
+ if (!iban) return '—';
+ const cleaned = iban.replace(/\s/g, '');
+ return cleaned.replace(/(.{4})/g, '$1 ').trim();
+}
+
+export function getMemberStatusColor(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
+ switch (status) {
+ case 'active': return 'default';
+ case 'inactive': return 'secondary';
+ case 'pending': return 'outline';
+ case 'resigned':
+ case 'excluded':
+ case 'deceased': return 'destructive';
+ default: return 'secondary';
+ }
+}
+
+export const STATUS_LABELS: Record = {
+ active: 'Aktiv',
+ inactive: 'Inaktiv',
+ pending: 'Ausstehend',
+ resigned: 'Ausgetreten',
+ excluded: 'Ausgeschlossen',
+ deceased: 'Verstorben',
+};
diff --git a/packages/features/member-management/src/schema/member.schema.ts b/packages/features/member-management/src/schema/member.schema.ts
index a756321e5..58a798233 100644
--- a/packages/features/member-management/src/schema/member.schema.ts
+++ b/packages/features/member-management/src/schema/member.schema.ts
@@ -33,12 +33,42 @@ export const CreateMemberSchema = z.object({
accountHolder: z.string().max(128).optional(),
gdprConsent: z.boolean().default(false),
notes: z.string().optional(),
+ // New optional fields
+ salutation: z.string().optional(),
+ street2: z.string().optional(),
+ phone2: z.string().optional(),
+ fax: z.string().optional(),
+ birthplace: z.string().optional(),
+ birthCountry: z.string().default('DE'),
+ isHonorary: z.boolean().default(false),
+ isFoundingMember: z.boolean().default(false),
+ isYouth: z.boolean().default(false),
+ isRetiree: z.boolean().default(false),
+ isProbationary: z.boolean().default(false),
+ isTransferred: z.boolean().default(false),
+ exitDate: z.string().optional(),
+ exitReason: z.string().optional(),
+ guardianName: z.string().optional(),
+ guardianPhone: z.string().optional(),
+ guardianEmail: z.string().optional(),
+ duesYear: z.number().int().optional(),
+ duesPaid: z.boolean().default(false),
+ additionalFees: z.number().default(0),
+ exemptionType: z.string().optional(),
+ exemptionReason: z.string().optional(),
+ exemptionAmount: z.number().optional(),
+ gdprNewsletter: z.boolean().default(false),
+ gdprInternet: z.boolean().default(false),
+ gdprPrint: z.boolean().default(false),
+ gdprBirthdayInfo: z.boolean().default(false),
+ sepaMandateReference: z.string().optional(),
});
export type CreateMemberInput = z.infer;
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
memberId: z.string().uuid(),
+ isArchived: z.boolean().optional(),
});
export type UpdateMemberInput = z.infer;
@@ -50,6 +80,77 @@ export const CreateDuesCategorySchema = z.object({
amount: z.number().min(0),
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).default('yearly'),
isDefault: z.boolean().default(false),
+ isYouth: z.boolean().default(false),
+ isExit: z.boolean().default(false),
});
export type CreateDuesCategoryInput = z.infer;
+
+export const RejectApplicationSchema = z.object({
+ applicationId: z.string().uuid(),
+ accountId: z.string().uuid(),
+ reviewNotes: z.string().optional(),
+});
+
+export const CreateDepartmentSchema = z.object({
+ accountId: z.string().uuid(),
+ name: z.string().min(1).max(128),
+ description: z.string().optional(),
+});
+
+export const CreateMemberRoleSchema = z.object({
+ memberId: z.string().uuid(),
+ accountId: z.string().uuid(),
+ roleName: z.string().min(1),
+ fromDate: z.string().optional(),
+ untilDate: z.string().optional(),
+});
+
+export const CreateMemberHonorSchema = z.object({
+ memberId: z.string().uuid(),
+ accountId: z.string().uuid(),
+ honorName: z.string().min(1),
+ honorDate: z.string().optional(),
+ description: z.string().optional(),
+});
+
+export const CreateSepaMandateSchema = z.object({
+ memberId: z.string().uuid(),
+ accountId: z.string().uuid(),
+ mandateReference: z.string().min(1),
+ iban: z.string().min(15).max(34),
+ bic: z.string().optional(),
+ accountHolder: z.string().min(1),
+ mandateDate: z.string(),
+ sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).default('RCUR'),
+});
+
+export const UpdateDuesCategorySchema = z.object({
+ categoryId: z.string().uuid(),
+ name: z.string().min(1).optional(),
+ description: z.string().optional(),
+ amount: z.number().min(0).optional(),
+ interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).optional(),
+ isDefault: z.boolean().optional(),
+});
+export type UpdateDuesCategoryInput = z.infer;
+
+export const UpdateMandateSchema = z.object({
+ mandateId: z.string().uuid(),
+ iban: z.string().min(15).max(34).optional(),
+ bic: z.string().optional(),
+ accountHolder: z.string().optional(),
+ sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).optional(),
+});
+export type UpdateMandateInput = z.infer;
+
+export const ExportMembersSchema = z.object({
+ accountId: z.string().uuid(),
+ status: z.string().optional(),
+ format: z.enum(['csv', 'excel']).default('csv'),
+});
+
+export const AssignDepartmentSchema = z.object({
+ memberId: z.string().uuid(),
+ departmentId: z.string().uuid(),
+});
diff --git a/packages/features/member-management/src/server/actions/member-actions.ts b/packages/features/member-management/src/server/actions/member-actions.ts
new file mode 100644
index 000000000..f43881460
--- /dev/null
+++ b/packages/features/member-management/src/server/actions/member-actions.ts
@@ -0,0 +1,302 @@
+'use server';
+
+import { z } from 'zod';
+import { authActionClient } from '@kit/next/safe-action';
+import { getLogger } from '@kit/shared/logger';
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import {
+ CreateMemberSchema,
+ UpdateMemberSchema,
+ RejectApplicationSchema,
+ CreateDuesCategorySchema,
+ CreateDepartmentSchema,
+ CreateMemberRoleSchema,
+ CreateMemberHonorSchema,
+ CreateSepaMandateSchema,
+ UpdateDuesCategorySchema,
+ UpdateMandateSchema,
+ ExportMembersSchema,
+ AssignDepartmentSchema,
+} from '../../schema/member.schema';
+import { createMemberManagementApi } from '../api';
+
+export const createMember = authActionClient
+ .inputSchema(CreateMemberSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createMemberManagementApi(client);
+ const userId = ctx.user.id;
+
+ logger.info({ name: 'member.create' }, 'Creating member...');
+ const result = await api.createMember(input, userId);
+ logger.info({ name: 'member.create' }, 'Member created');
+ return { success: true, data: result };
+ });
+
+export const updateMember = authActionClient
+ .inputSchema(UpdateMemberSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createMemberManagementApi(client);
+ const userId = ctx.user.id;
+
+ logger.info({ name: 'member.update' }, 'Updating member...');
+ const result = await api.updateMember(input, userId);
+ logger.info({ name: 'member.update' }, 'Member updated');
+ return { success: true, data: result };
+ });
+
+export const deleteMember = authActionClient
+ .inputSchema(
+ z.object({
+ memberId: z.string().uuid(),
+ accountId: z.string().uuid(),
+ }),
+ )
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createMemberManagementApi(client);
+
+ logger.info({ name: 'member.delete' }, 'Deleting member...');
+ const result = await api.deleteMember(input.memberId);
+ logger.info({ name: 'member.delete' }, 'Member deleted');
+ return { success: true, data: result };
+ });
+
+export const approveApplication = authActionClient
+ .inputSchema(
+ z.object({
+ applicationId: z.string().uuid(),
+ accountId: z.string().uuid(),
+ }),
+ )
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createMemberManagementApi(client);
+ const userId = ctx.user.id;
+
+ logger.info({ name: 'member.approveApplication' }, 'Approving application...');
+ const result = await api.approveApplication(input.applicationId, userId);
+ logger.info({ name: 'member.approveApplication' }, 'Application approved');
+ return { success: true, data: result };
+ });
+
+export const rejectApplication = authActionClient
+ .inputSchema(RejectApplicationSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createMemberManagementApi(client);
+ logger.info({ name: 'members.reject-application' }, 'Rejecting application...');
+ await api.rejectApplication(input.applicationId, ctx.user.id, input.reviewNotes);
+ return { success: true };
+ });
+
+export const createDuesCategory = authActionClient
+ .inputSchema(CreateDuesCategorySchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ const data = await api.createDuesCategory(input);
+ return { success: true, data };
+ });
+
+export const deleteDuesCategory = authActionClient
+ .inputSchema(z.object({ categoryId: z.string().uuid() }))
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ await api.deleteDuesCategory(input.categoryId);
+ return { success: true };
+ });
+
+export const createDepartment = authActionClient
+ .inputSchema(CreateDepartmentSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ const data = await api.createDepartment(input);
+ return { success: true, data };
+ });
+
+export const createMemberRole = authActionClient
+ .inputSchema(CreateMemberRoleSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ const data = await api.createMemberRole(input);
+ return { success: true, data };
+ });
+
+export const deleteMemberRole = authActionClient
+ .inputSchema(z.object({ roleId: z.string().uuid() }))
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ await api.deleteMemberRole(input.roleId);
+ return { success: true };
+ });
+
+export const createMemberHonor = authActionClient
+ .inputSchema(CreateMemberHonorSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ const data = await api.createMemberHonor(input);
+ return { success: true, data };
+ });
+
+export const deleteMemberHonor = authActionClient
+ .inputSchema(z.object({ honorId: z.string().uuid() }))
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ await api.deleteMemberHonor(input.honorId);
+ return { success: true };
+ });
+
+export const createMandate = authActionClient
+ .inputSchema(CreateSepaMandateSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ const data = await api.createMandate(input);
+ return { success: true, data };
+ });
+
+export const revokeMandate = authActionClient
+ .inputSchema(z.object({ mandateId: z.string().uuid() }))
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ await api.revokeMandate(input.mandateId);
+ return { success: true };
+ });
+
+// Gap 1: Update operations
+export const updateDuesCategory = authActionClient
+ .inputSchema(UpdateDuesCategorySchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ const data = await api.updateDuesCategory(input);
+ return { success: true, data };
+ });
+
+export const updateMandate = authActionClient
+ .inputSchema(UpdateMandateSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ const data = await api.updateMandate(input);
+ return { success: true, data };
+ });
+
+// Gap 2: Export
+export const exportMembers = authActionClient
+ .inputSchema(ExportMembersSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ const csv = await api.exportMembersCsv(input.accountId, { status: input.status });
+ return { success: true, csv };
+ });
+
+// Gap 5: Department assignments
+export const assignDepartment = authActionClient
+ .inputSchema(AssignDepartmentSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ await api.assignDepartment(input.memberId, input.departmentId);
+ return { success: true };
+ });
+
+export const removeDepartment = authActionClient
+ .inputSchema(AssignDepartmentSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ await api.removeDepartment(input.memberId, input.departmentId);
+ return { success: true };
+ });
+
+// Gap 2: Excel export
+export const exportMembersExcel = authActionClient
+ .inputSchema(ExportMembersSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ const buffer = await api.exportMembersExcel(input.accountId, { status: input.status });
+ // Return base64 for client-side download
+ return { success: true, base64: buffer.toString('base64'), filename: `mitglieder_${new Date().toISOString().split('T')[0]}.xlsx` };
+ });
+
+// Gap 6: Member card PDF generation
+export const generateMemberCards = authActionClient
+ .inputSchema(z.object({
+ accountId: z.string().uuid(),
+ memberIds: z.array(z.string().uuid()).optional(),
+ orgName: z.string().default('Verein'),
+ }))
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+
+ let query = client.from('members').select('id, first_name, last_name, member_number, entry_date, status')
+ .eq('account_id', input.accountId).eq('status', 'active');
+ if (input.memberIds && input.memberIds.length > 0) {
+ query = query.in('id', input.memberIds);
+ }
+ const { data: members, error } = await query;
+ if (error) throw error;
+
+ const { generateMemberCardsPdf } = await import('../services/member-card-generator');
+ const buffer = await generateMemberCardsPdf(
+ input.orgName,
+ (members ?? []).map((m: any) => ({
+ firstName: m.first_name, lastName: m.last_name,
+ memberNumber: m.member_number ?? '', entryDate: m.entry_date ?? '',
+ status: m.status,
+ })),
+ );
+
+ return { success: true, base64: buffer.toString('base64'), filename: `mitgliedsausweise_${new Date().toISOString().split('T')[0]}.pdf` };
+ });
+
+// Portal Invitations
+export const inviteMemberToPortal = authActionClient
+ .inputSchema(z.object({
+ memberId: z.string().uuid(),
+ accountId: z.string().uuid(),
+ email: z.string().email(),
+ }))
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createMemberManagementApi(client);
+
+ logger.info({ name: 'portal.invite', memberId: input.memberId }, 'Sending portal invitation...');
+
+ const invitation = await api.inviteMemberToPortal(input, ctx.user.id);
+
+ // Create auth user for the member if not exists
+ // In production: send invitation email with the token link
+ // For now: create the user directly via admin API
+ logger.info({ name: 'portal.invite', token: invitation.invite_token }, 'Invitation created');
+
+ return { success: true, data: invitation };
+ });
+
+export const revokePortalInvitation = authActionClient
+ .inputSchema(z.object({ invitationId: z.string().uuid() }))
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createMemberManagementApi(client);
+ await api.revokePortalInvitation(input.invitationId);
+ return { success: true };
+ });
diff --git a/packages/features/member-management/src/server/api.ts b/packages/features/member-management/src/server/api.ts
index 09cbc2224..a65d8504b 100644
--- a/packages/features/member-management/src/server/api.ts
+++ b/packages/features/member-management/src/server/api.ts
@@ -65,6 +65,33 @@ export function createMemberManagementApi(client: SupabaseClient) {
gdpr_consent: input.gdprConsent,
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
notes: input.notes,
+ // New parity fields
+ salutation: input.salutation,
+ street2: input.street2,
+ phone2: input.phone2,
+ fax: input.fax,
+ birthplace: input.birthplace,
+ birth_country: input.birthCountry,
+ is_honorary: input.isHonorary,
+ is_founding_member: input.isFoundingMember,
+ is_youth: input.isYouth,
+ is_retiree: input.isRetiree,
+ is_probationary: input.isProbationary,
+ is_transferred: input.isTransferred,
+ guardian_name: input.guardianName,
+ guardian_phone: input.guardianPhone,
+ guardian_email: input.guardianEmail,
+ dues_year: input.duesYear,
+ dues_paid: input.duesPaid,
+ additional_fees: input.additionalFees,
+ exemption_type: input.exemptionType,
+ exemption_reason: input.exemptionReason,
+ exemption_amount: input.exemptionAmount,
+ gdpr_newsletter: input.gdprNewsletter,
+ gdpr_internet: input.gdprInternet,
+ gdpr_print: input.gdprPrint,
+ gdpr_birthday_info: input.gdprBirthdayInfo,
+ sepa_mandate_reference: input.sepaMandateReference,
created_by: userId,
updated_by: userId,
})
@@ -89,7 +116,45 @@ export function createMemberManagementApi(client: SupabaseClient) {
if (input.status !== undefined) updateData.status = input.status;
if (input.duesCategoryId !== undefined) updateData.dues_category_id = input.duesCategoryId;
if (input.iban !== undefined) updateData.iban = input.iban;
+ if (input.bic !== undefined) updateData.bic = input.bic;
+ if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
if (input.notes !== undefined) updateData.notes = input.notes;
+ if (input.isArchived !== undefined) updateData.is_archived = input.isArchived;
+ // New parity fields
+ if (input.salutation !== undefined) updateData.salutation = input.salutation;
+ if (input.street2 !== undefined) updateData.street2 = input.street2;
+ if (input.phone2 !== undefined) updateData.phone2 = input.phone2;
+ if (input.fax !== undefined) updateData.fax = input.fax;
+ if (input.birthplace !== undefined) updateData.birthplace = input.birthplace;
+ if (input.birthCountry !== undefined) updateData.birth_country = input.birthCountry;
+ if (input.title !== undefined) updateData.title = input.title;
+ if (input.dateOfBirth !== undefined) updateData.date_of_birth = input.dateOfBirth;
+ if (input.gender !== undefined) updateData.gender = input.gender;
+ if (input.country !== undefined) updateData.country = input.country;
+ if (input.entryDate !== undefined) updateData.entry_date = input.entryDate;
+ if (input.exitDate !== undefined) updateData.exit_date = input.exitDate;
+ if (input.exitReason !== undefined) updateData.exit_reason = input.exitReason;
+ if (input.isHonorary !== undefined) updateData.is_honorary = input.isHonorary;
+ if (input.isFoundingMember !== undefined) updateData.is_founding_member = input.isFoundingMember;
+ if (input.isYouth !== undefined) updateData.is_youth = input.isYouth;
+ if (input.isRetiree !== undefined) updateData.is_retiree = input.isRetiree;
+ if (input.isProbationary !== undefined) updateData.is_probationary = input.isProbationary;
+ if (input.isTransferred !== undefined) updateData.is_transferred = input.isTransferred;
+ if (input.guardianName !== undefined) updateData.guardian_name = input.guardianName;
+ if (input.guardianPhone !== undefined) updateData.guardian_phone = input.guardianPhone;
+ if (input.guardianEmail !== undefined) updateData.guardian_email = input.guardianEmail;
+ if (input.duesYear !== undefined) updateData.dues_year = input.duesYear;
+ if (input.duesPaid !== undefined) updateData.dues_paid = input.duesPaid;
+ if (input.additionalFees !== undefined) updateData.additional_fees = input.additionalFees;
+ if (input.exemptionType !== undefined) updateData.exemption_type = input.exemptionType;
+ if (input.exemptionReason !== undefined) updateData.exemption_reason = input.exemptionReason;
+ if (input.exemptionAmount !== undefined) updateData.exemption_amount = input.exemptionAmount;
+ if (input.gdprConsent !== undefined) updateData.gdpr_consent = input.gdprConsent;
+ if (input.gdprNewsletter !== undefined) updateData.gdpr_newsletter = input.gdprNewsletter;
+ if (input.gdprInternet !== undefined) updateData.gdpr_internet = input.gdprInternet;
+ if (input.gdprPrint !== undefined) updateData.gdpr_print = input.gdprPrint;
+ if (input.gdprBirthdayInfo !== undefined) updateData.gdpr_birthday_info = input.gdprBirthdayInfo;
+ if (input.sepaMandateReference !== undefined) updateData.sepa_mandate_reference = input.sepaMandateReference;
const { data, error } = await (client).from('members')
.update(updateData)
@@ -186,5 +251,253 @@ export function createMemberManagementApi(client: SupabaseClient) {
return member;
},
+
+ async rejectApplication(applicationId: string, userId: string, reviewNotes?: string) {
+ const { error } = await client.from('membership_applications')
+ .update({ status: 'rejected' as any, reviewed_by: userId, reviewed_at: new Date().toISOString(), review_notes: reviewNotes })
+ .eq('id', applicationId);
+ if (error) throw error;
+ },
+
+ async createDuesCategory(input: { accountId: string; name: string; description?: string; amount: number; interval?: string; isDefault?: boolean; isYouth?: boolean; isExit?: boolean }) {
+ const { data, error } = await client.from('dues_categories').insert({
+ account_id: input.accountId, name: input.name, description: input.description,
+ amount: input.amount, interval: input.interval ?? 'yearly',
+ is_default: input.isDefault ?? false, is_youth: input.isYouth ?? false, is_exit: input.isExit ?? false,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ async deleteDuesCategory(categoryId: string) {
+ const { error } = await client.from('dues_categories').delete().eq('id', categoryId);
+ if (error) throw error;
+ },
+
+ async listDepartments(accountId: string) {
+ const { data, error } = await client.from('member_departments').select('*')
+ .eq('account_id', accountId).order('sort_order');
+ if (error) throw error;
+ return data ?? [];
+ },
+
+ async createDepartment(input: { accountId: string; name: string; description?: string }) {
+ const { data, error } = await client.from('member_departments').insert({
+ account_id: input.accountId, name: input.name, description: input.description,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ async assignDepartment(memberId: string, departmentId: string) {
+ const { error } = await client.from('member_department_assignments').insert({
+ member_id: memberId, department_id: departmentId,
+ });
+ if (error) throw error;
+ },
+
+ async removeDepartment(memberId: string, departmentId: string) {
+ const { error } = await client.from('member_department_assignments').delete()
+ .eq('member_id', memberId).eq('department_id', departmentId);
+ if (error) throw error;
+ },
+
+ async listMemberRoles(memberId: string) {
+ const { data, error } = await client.from('member_roles').select('*')
+ .eq('member_id', memberId).order('from_date', { ascending: false });
+ if (error) throw error;
+ return data ?? [];
+ },
+
+ async createMemberRole(input: { memberId: string; accountId: string; roleName: string; fromDate?: string; untilDate?: string }) {
+ const { data, error } = await client.from('member_roles').insert({
+ member_id: input.memberId, account_id: input.accountId, role_name: input.roleName,
+ from_date: input.fromDate, until_date: input.untilDate,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ async deleteMemberRole(roleId: string) {
+ const { error } = await client.from('member_roles').delete().eq('id', roleId);
+ if (error) throw error;
+ },
+
+ async listMemberHonors(memberId: string) {
+ const { data, error } = await client.from('member_honors').select('*')
+ .eq('member_id', memberId).order('honor_date', { ascending: false });
+ if (error) throw error;
+ return data ?? [];
+ },
+
+ async createMemberHonor(input: { memberId: string; accountId: string; honorName: string; honorDate?: string; description?: string }) {
+ const { data, error } = await client.from('member_honors').insert({
+ member_id: input.memberId, account_id: input.accountId, honor_name: input.honorName,
+ honor_date: input.honorDate, description: input.description,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ async deleteMemberHonor(honorId: string) {
+ const { error } = await client.from('member_honors').delete().eq('id', honorId);
+ if (error) throw error;
+ },
+
+ async listMandates(memberId: string) {
+ const { data, error } = await client.from('sepa_mandates').select('*')
+ .eq('member_id', memberId).order('is_primary', { ascending: false });
+ if (error) throw error;
+ return data ?? [];
+ },
+
+ async createMandate(input: { memberId: string; accountId: string; mandateReference: string; iban: string; bic?: string; accountHolder: string; mandateDate: string; sequence?: string }) {
+ const { data, error } = await client.from('sepa_mandates').insert({
+ member_id: input.memberId, account_id: input.accountId,
+ mandate_reference: input.mandateReference, iban: input.iban, bic: input.bic,
+ account_holder: input.accountHolder, mandate_date: input.mandateDate,
+ sequence: input.sequence ?? 'RCUR',
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ async revokeMandate(mandateId: string) {
+ const { error } = await client.from('sepa_mandates')
+ .update({ status: 'revoked' as any }).eq('id', mandateId);
+ if (error) throw error;
+ },
+
+ async checkDuplicate(accountId: string, firstName: string, lastName: string, dateOfBirth?: string) {
+ const { data, error } = await client.rpc('check_duplicate_member', {
+ p_account_id: accountId, p_first_name: firstName, p_last_name: lastName,
+ p_date_of_birth: dateOfBirth ?? undefined,
+ });
+ if (error) throw error;
+ return data ?? [];
+ },
+
+ // --- Update operations (Gap 1) ---
+ async updateDuesCategory(input: { categoryId: string; name?: string; description?: string; amount?: number; interval?: string; isDefault?: boolean }) {
+ const updateData: Record = {};
+ if (input.name !== undefined) updateData.name = input.name;
+ if (input.description !== undefined) updateData.description = input.description;
+ if (input.amount !== undefined) updateData.amount = input.amount;
+ if (input.interval !== undefined) updateData.interval = input.interval;
+ if (input.isDefault !== undefined) updateData.is_default = input.isDefault;
+ const { data, error } = await client.from('dues_categories').update(updateData).eq('id', input.categoryId).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ async updateMandate(input: { mandateId: string; iban?: string; bic?: string; accountHolder?: string; sequence?: string }) {
+ const updateData: Record = {};
+ if (input.iban !== undefined) updateData.iban = input.iban;
+ if (input.bic !== undefined) updateData.bic = input.bic;
+ if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
+ if (input.sequence !== undefined) updateData.sequence = input.sequence;
+ const { data, error } = await client.from('sepa_mandates').update(updateData).eq('id', input.mandateId).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ // --- Export (Gap 2) ---
+ async exportMembersCsv(accountId: string, filters?: { status?: string }) {
+ let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
+ if (filters?.status) query = query.eq('status', filters.status as any);
+ const { data, error } = await query;
+ if (error) throw error;
+ const members = data ?? [];
+ if (members.length === 0) return '';
+
+ const headers = ['Mitgliedsnr.', 'Anrede', 'Vorname', 'Nachname', 'Geburtsdatum', 'E-Mail', 'Telefon', 'Mobil', 'Straße', 'Hausnummer', 'PLZ', 'Ort', 'Status', 'Eintrittsdatum', 'IBAN', 'BIC', 'Kontoinhaber'];
+ const rows = members.map((m) => [
+ m.member_number ?? '', m.salutation ?? '', m.first_name, m.last_name,
+ m.date_of_birth ?? '', m.email ?? '', m.phone ?? '', m.mobile ?? '',
+ m.street ?? '', m.house_number ?? '', m.postal_code ?? '', m.city ?? '',
+ m.status, m.entry_date ?? '', m.iban ?? '', m.bic ?? '', m.account_holder ?? '',
+ ].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';'));
+
+ return [headers.join(';'), ...rows].join('\n');
+ },
+
+ // --- Department assign/remove (Gap 5) ---
+ async getDepartmentAssignments(memberId: string) {
+ const { data, error } = await client.from('member_department_assignments').select('department_id').eq('member_id', memberId);
+ if (error) throw error;
+ return (data ?? []).map((d) => d.department_id);
+ },
+
+ async exportMembersExcel(accountId: string, filters?: { status?: string }): Promise {
+ let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
+ if (filters?.status) query = query.eq('status', filters.status as any);
+ const { data, error } = await query;
+ if (error) throw error;
+ const members = data ?? [];
+
+ const ExcelJS = (await import('exceljs')).default;
+ const workbook = new ExcelJS.Workbook();
+ const sheet = workbook.addWorksheet('Mitglieder');
+
+ sheet.columns = [
+ { header: 'Mitgliedsnr.', key: 'member_number', width: 15 },
+ { header: 'Anrede', key: 'salutation', width: 10 },
+ { header: 'Vorname', key: 'first_name', width: 20 },
+ { header: 'Nachname', key: 'last_name', width: 20 },
+ { header: 'Geburtsdatum', key: 'date_of_birth', width: 15 },
+ { header: 'E-Mail', key: 'email', width: 30 },
+ { header: 'Telefon', key: 'phone', width: 18 },
+ { header: 'Mobil', key: 'mobile', width: 18 },
+ { header: 'Straße', key: 'street', width: 25 },
+ { header: 'Hausnummer', key: 'house_number', width: 12 },
+ { header: 'PLZ', key: 'postal_code', width: 10 },
+ { header: 'Ort', key: 'city', width: 20 },
+ { header: 'Status', key: 'status', width: 12 },
+ { header: 'Eintrittsdatum', key: 'entry_date', width: 15 },
+ { header: 'IBAN', key: 'iban', width: 30 },
+ { header: 'BIC', key: 'bic', width: 15 },
+ { header: 'Kontoinhaber', key: 'account_holder', width: 25 },
+ ];
+
+ // Style header row
+ sheet.getRow(1).font = { bold: true };
+ sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F5E9' } };
+
+ for (const m of members) {
+ sheet.addRow(m);
+ }
+
+ const buffer = await workbook.xlsx.writeBuffer();
+ return Buffer.from(buffer);
+ },
+
+ // --- Portal Invitations ---
+ async inviteMemberToPortal(input: { memberId: string; accountId: string; email: string }, invitedBy: string) {
+ const { data, error } = await client.from('member_portal_invitations').insert({
+ account_id: input.accountId, member_id: input.memberId, email: input.email, invited_by: invitedBy,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+
+ async listPortalInvitations(accountId: string) {
+ const { data, error } = await client.from('member_portal_invitations').select('*')
+ .eq('account_id', accountId).order('created_at', { ascending: false });
+ if (error) throw error;
+ return data ?? [];
+ },
+
+ async revokePortalInvitation(invitationId: string) {
+ const { error } = await client.from('member_portal_invitations')
+ .update({ status: 'revoked' as any }).eq('id', invitationId);
+ if (error) throw error;
+ },
+
+ async getMemberByUserId(accountId: string, userId: string) {
+ const { data, error } = await client.from('members').select('*')
+ .eq('account_id', accountId).eq('user_id', userId).maybeSingle();
+ if (error) throw error;
+ return data;
+ },
};
}
diff --git a/packages/features/member-management/src/server/services/member-card-generator.ts b/packages/features/member-management/src/server/services/member-card-generator.ts
new file mode 100644
index 000000000..52756569e
--- /dev/null
+++ b/packages/features/member-management/src/server/services/member-card-generator.ts
@@ -0,0 +1,79 @@
+/**
+ * Member card PDF generator using @react-pdf/renderer.
+ * Generates A4 pages with member ID cards in a grid layout.
+ */
+
+import { renderToBuffer } from '@react-pdf/renderer';
+import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer';
+import React from 'react';
+
+const styles = StyleSheet.create({
+ page: { padding: 20, flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
+ card: {
+ width: '48%', height: 180, border: '1pt solid #ccc', borderRadius: 8,
+ padding: 12, justifyContent: 'space-between',
+ },
+ orgName: { fontSize: 10, fontWeight: 'bold', color: '#0d9488', marginBottom: 6 },
+ cardTitle: { fontSize: 7, color: '#888', textTransform: 'uppercase' as const, letterSpacing: 1 },
+ memberName: { fontSize: 14, fontWeight: 'bold', marginTop: 4 },
+ memberNumber: { fontSize: 9, color: '#666', marginTop: 2 },
+ fieldRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
+ fieldLabel: { fontSize: 7, color: '#888' },
+ fieldValue: { fontSize: 8 },
+ footer: { fontSize: 6, color: '#aaa', textAlign: 'center' as const, marginTop: 8 },
+});
+
+interface MemberCardData {
+ firstName: string;
+ lastName: string;
+ memberNumber: string;
+ entryDate: string;
+ status: string;
+}
+
+interface CardPdfProps {
+ orgName: string;
+ members: MemberCardData[];
+ validYear: number;
+}
+
+function MemberCardDocument({ orgName, members, validYear }: CardPdfProps) {
+ return React.createElement(Document, {},
+ React.createElement(Page, { size: 'A4', style: styles.page },
+ ...members.map((m, i) =>
+ React.createElement(View, { key: i, style: styles.card },
+ React.createElement(View, {},
+ React.createElement(Text, { style: styles.orgName }, orgName),
+ React.createElement(Text, { style: styles.cardTitle }, 'MITGLIEDSAUSWEIS'),
+ React.createElement(Text, { style: styles.memberName }, `${m.firstName} ${m.lastName}`),
+ React.createElement(Text, { style: styles.memberNumber }, `Nr. ${m.memberNumber || '—'}`),
+ ),
+ React.createElement(View, {},
+ React.createElement(View, { style: styles.fieldRow },
+ React.createElement(View, {},
+ React.createElement(Text, { style: styles.fieldLabel }, 'Mitglied seit'),
+ React.createElement(Text, { style: styles.fieldValue }, m.entryDate || '—'),
+ ),
+ React.createElement(View, {},
+ React.createElement(Text, { style: styles.fieldLabel }, 'Gültig'),
+ React.createElement(Text, { style: styles.fieldValue }, String(validYear)),
+ ),
+ ),
+ ),
+ React.createElement(Text, { style: styles.footer }, `${orgName} — Mitgliedsausweis ${validYear}`),
+ )
+ )
+ )
+ );
+}
+
+export async function generateMemberCardsPdf(
+ orgName: string,
+ members: MemberCardData[],
+ validYear?: number,
+): Promise {
+ const year = validYear ?? new Date().getFullYear();
+ const doc = React.createElement(MemberCardDocument, { orgName, members, validYear: year });
+ const buffer = await renderToBuffer(doc as any);
+ return Buffer.from(buffer);
+}
diff --git a/packages/features/newsletter/package.json b/packages/features/newsletter/package.json
index 7f7bdbed5..ac6109f0d 100644
--- a/packages/features/newsletter/package.json
+++ b/packages/features/newsletter/package.json
@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
- "./components": "./src/components/index.ts"
+ "./components": "./src/components/index.ts",
+ "./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
+ "@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
+ "react-hook-form": "catalog:",
"zod": "catalog:"
}
}
diff --git a/packages/features/newsletter/src/components/create-newsletter-form.tsx b/packages/features/newsletter/src/components/create-newsletter-form.tsx
new file mode 100644
index 000000000..017529416
--- /dev/null
+++ b/packages/features/newsletter/src/components/create-newsletter-form.tsx
@@ -0,0 +1,100 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { CreateNewsletterSchema } from '../schema/newsletter.schema';
+import { createNewsletter } from '../server/actions/newsletter-actions';
+
+interface Props {
+ accountId: string;
+ account: string;
+}
+
+export function CreateNewsletterForm({ accountId, account }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(CreateNewsletterSchema),
+ defaultValues: {
+ accountId,
+ subject: '',
+ bodyHtml: '',
+ bodyText: '',
+ scheduledAt: '',
+ },
+ });
+
+ const { execute, isPending } = useAction(createNewsletter, {
+ onSuccess: ({ data }) => {
+ if (data?.success) {
+ toast.success('Newsletter erfolgreich erstellt');
+ router.push(`/home/${account}/newsletter-cms`);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Fehler beim Erstellen des Newsletters');
+ },
+ });
+
+ return (
+
+ execute(data))} className="space-y-6">
+
+ Newsletter-Inhalt
+
+ (
+ Betreff *
+ )} />
+ (
+ Inhalt (HTML) *
+
+
+ )} />
+ (
+ Nur-Text-Version (optional)
+
+
+ )} />
+
+
+
+
+ Zeitplan
+
+ (
+
+ Geplanter Versand (optional)
+
+
+ Leer lassen, um den Newsletter als Entwurf zu speichern.
+
+
+
+ )} />
+
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird erstellt...' : 'Newsletter erstellen'}
+
+
+
+ );
+}
diff --git a/packages/features/newsletter/src/components/index.ts b/packages/features/newsletter/src/components/index.ts
index cb0ff5c3b..a4937ff85 100644
--- a/packages/features/newsletter/src/components/index.ts
+++ b/packages/features/newsletter/src/components/index.ts
@@ -1 +1 @@
-export {};
+export { CreateNewsletterForm } from './create-newsletter-form';
diff --git a/packages/features/newsletter/src/server/actions/newsletter-actions.ts b/packages/features/newsletter/src/server/actions/newsletter-actions.ts
new file mode 100644
index 000000000..db0a79603
--- /dev/null
+++ b/packages/features/newsletter/src/server/actions/newsletter-actions.ts
@@ -0,0 +1,91 @@
+'use server';
+
+import { z } from 'zod';
+import { authActionClient } from '@kit/next/safe-action';
+import { getLogger } from '@kit/shared/logger';
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import {
+ CreateNewsletterSchema,
+ CreateTemplateSchema,
+} from '../../schema/newsletter.schema';
+import { createNewsletterApi } from '../api';
+
+export const createNewsletter = authActionClient
+ .inputSchema(CreateNewsletterSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createNewsletterApi(client);
+ const userId = ctx.user.id;
+
+ logger.info({ name: 'newsletter.create' }, 'Creating newsletter...');
+ const result = await api.createNewsletter(input, userId);
+ logger.info({ name: 'newsletter.create' }, 'Newsletter created');
+ return { success: true, data: result };
+ });
+
+export const createTemplate = authActionClient
+ .inputSchema(
+ z.object({
+ accountId: z.string().uuid(),
+ name: z.string().min(1),
+ subject: z.string().min(1),
+ bodyHtml: z.string().min(1),
+ bodyText: z.string().optional(),
+ variables: z.array(z.string()).optional(),
+ }),
+ )
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createNewsletterApi(client);
+
+ logger.info({ name: 'newsletter.createTemplate' }, 'Creating template...');
+ const result = await api.createTemplate(input);
+ logger.info({ name: 'newsletter.createTemplate' }, 'Template created');
+ return { success: true, data: result };
+ });
+
+export const addRecipients = authActionClient
+ .inputSchema(
+ z.object({
+ newsletterId: z.string().uuid(),
+ accountId: z.string().uuid(),
+ filter: z
+ .object({
+ status: z.array(z.string()).optional(),
+ })
+ .optional(),
+ }),
+ )
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createNewsletterApi(client);
+
+ logger.info({ name: 'newsletter.addRecipients' }, 'Adding recipients...');
+ const result = await api.addRecipientsFromMembers(
+ input.newsletterId,
+ input.accountId,
+ input.filter,
+ );
+ logger.info({ name: 'newsletter.addRecipients' }, 'Recipients added');
+ return { success: true, data: result };
+ });
+
+export const dispatchNewsletter = authActionClient
+ .inputSchema(
+ z.object({
+ newsletterId: z.string().uuid(),
+ }),
+ )
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const logger = await getLogger();
+ const api = createNewsletterApi(client);
+
+ logger.info({ name: 'newsletter.dispatch' }, 'Dispatching newsletter...');
+ const result = await api.dispatch(input.newsletterId);
+ logger.info({ name: 'newsletter.dispatch' }, 'Newsletter dispatched');
+ return { success: true, data: result };
+ });
diff --git a/packages/features/site-builder/package.json b/packages/features/site-builder/package.json
new file mode 100644
index 000000000..157236019
--- /dev/null
+++ b/packages/features/site-builder/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@kit/site-builder",
+ "version": "0.1.0",
+ "private": true,
+ "typesVersions": {
+ "*": {
+ "*": [
+ "src/*"
+ ]
+ }
+ },
+ "exports": {
+ "./api": "./src/server/api.ts",
+ "./schema/*": "./src/schema/*.ts",
+ "./components": "./src/components/index.ts",
+ "./config/*": "./src/config/*.tsx",
+ "./actions/*": "./src/server/actions/*.ts",
+ "./hooks/*": "./src/hooks/*.ts"
+ },
+ "scripts": {
+ "clean": "git clean -xdf .turbo node_modules",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@hookform/resolvers": "catalog:",
+ "@kit/next": "workspace:*",
+ "@kit/shared": "workspace:*",
+ "@kit/supabase": "workspace:*",
+ "@kit/tsconfig": "workspace:*",
+ "@kit/ui": "workspace:*",
+ "@measured/puck": "*",
+ "@supabase/supabase-js": "catalog:",
+ "@types/react": "catalog:",
+ "lucide-react": "catalog:",
+ "next": "catalog:",
+ "next-safe-action": "catalog:",
+ "react": "catalog:",
+ "react-hook-form": "catalog:",
+ "zod": "catalog:"
+ }
+}
diff --git a/packages/features/site-builder/src/components/create-page-form.tsx b/packages/features/site-builder/src/components/create-page-form.tsx
new file mode 100644
index 000000000..dc524e6d0
--- /dev/null
+++ b/packages/features/site-builder/src/components/create-page-form.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { CreatePageSchema } from '../schema/site.schema';
+import { createPage } from '../server/actions/site-builder-actions';
+
+interface Props {
+ accountId: string;
+ account: string;
+}
+
+export function CreatePageForm({ accountId, account }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(CreatePageSchema),
+ defaultValues: {
+ accountId,
+ title: '',
+ slug: '',
+ isHomepage: false,
+ metaDescription: '',
+ },
+ });
+
+ const watchTitle = form.watch('title');
+ const autoSlug = watchTitle
+ .toLowerCase()
+ .replace(/[^a-z0-9äöüß\s-]+/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
+ .replace(/^-|-$/g, '');
+
+ const { execute, isPending } = useAction(createPage, {
+ onSuccess: ({ data }) => {
+ if (data?.success && data.data) {
+ toast.success('Seite erstellt — Editor wird geöffnet');
+ router.push(`/home/${account}/site-builder/${data.data.id}/edit`);
+ }
+ },
+ onError: ({ error }) => toast.error(error.serverError ?? 'Fehler beim Erstellen'),
+ });
+
+ return (
+
+ execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-xl">
+
+ Neue Seite erstellen
+
+ (
+
+ Seitentitel *
+
+
+
+ )} />
+ (
+
+ URL-Pfad
+
+ Leer lassen für automatische Generierung aus dem Titel
+
+
+ )} />
+ (
+
+ Beschreibung (SEO)
+
+
+
+ )} />
+ (
+
+
+
+
+ Als Startseite festlegen
+
+ )} />
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird erstellt...' : 'Seite erstellen & Editor öffnen'}
+
+
+
+ );
+}
diff --git a/packages/features/site-builder/src/components/create-post-form.tsx b/packages/features/site-builder/src/components/create-post-form.tsx
new file mode 100644
index 000000000..4491e2e3b
--- /dev/null
+++ b/packages/features/site-builder/src/components/create-post-form.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { CreatePostSchema } from '../schema/site.schema';
+import { createPost } from '../server/actions/site-builder-actions';
+
+interface Props {
+ accountId: string;
+ account: string;
+}
+
+export function CreatePostForm({ accountId, account }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(CreatePostSchema),
+ defaultValues: {
+ accountId,
+ title: '',
+ slug: '',
+ content: '',
+ excerpt: '',
+ coverImage: '',
+ status: 'draft' as const,
+ },
+ });
+
+ // Auto-generate slug from title
+ const watchTitle = form.watch('title');
+ const autoSlug = watchTitle.toLowerCase().replace(/[^a-z0-9äöüß]+/g, '-').replace(/^-|-$/g, '').replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss');
+
+ const { execute, isPending } = useAction(createPost, {
+ onSuccess: () => { toast.success('Beitrag erstellt'); router.push(`/home/${account}/site-builder/posts`); },
+ onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
+ });
+
+ return (
+
+ execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-3xl">
+
+ Beitrag
+
+ (
+ Titel *
+ )} />
+ (
+ URL-Slug
+ Leer lassen für automatische Generierung
+ )} />
+ (
+ Kurzfassung
+ )} />
+ (
+ Inhalt
+
+
+ )} />
+ (
+ Status
+
+ Entwurf
+ Veröffentlicht
+
+
+ )} />
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird erstellt...' : 'Beitrag erstellen'}
+
+
+
+ );
+}
diff --git a/packages/features/site-builder/src/components/index.ts b/packages/features/site-builder/src/components/index.ts
new file mode 100644
index 000000000..a6385e406
--- /dev/null
+++ b/packages/features/site-builder/src/components/index.ts
@@ -0,0 +1,6 @@
+export { SiteRenderer } from './site-renderer';
+export { SiteEditor } from './site-editor';
+export { SiteSettingsForm } from './site-settings-form';
+export { CreatePostForm } from './create-post-form';
+export { CreatePageForm } from './create-page-form';
+export { PortalLoginForm } from './portal-login-form';
diff --git a/packages/features/site-builder/src/components/portal-login-form.tsx b/packages/features/site-builder/src/components/portal-login-form.tsx
new file mode 100644
index 000000000..d36d6e2d5
--- /dev/null
+++ b/packages/features/site-builder/src/components/portal-login-form.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import { useState } from 'react';
+import { createClient } from '@supabase/supabase-js';
+import { useRouter } from 'next/navigation';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Label } from '@kit/ui/label';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { Shield, LogIn, AlertCircle } from 'lucide-react';
+
+interface Props {
+ slug: string;
+ accountName: string;
+}
+
+export function PortalLoginForm({ slug, accountName }: Props) {
+ const router = useRouter();
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError('');
+
+ try {
+ const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
+ );
+
+ const { data, error: authError } = await supabase.auth.signInWithPassword({
+ email,
+ password,
+ });
+
+ if (authError) {
+ setError('Ungültige Anmeldedaten. Bitte überprüfen Sie E-Mail und Passwort.');
+ setLoading(false);
+ return;
+ }
+
+ if (data.user) {
+ toast.success('Erfolgreich angemeldet');
+ router.push(`/club/${slug}/portal/profile`);
+ router.refresh();
+ }
+ } catch (err) {
+ setError('Verbindungsfehler. Bitte versuchen Sie es erneut.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Mitgliederbereich
+ {accountName}
+
+
+
+ {error && (
+
+ )}
+
+ E-Mail-Adresse
+ setEmail(e.target.value)}
+ placeholder="ihre@email.de"
+ required
+ />
+
+
+ Passwort
+ setPassword(e.target.value)}
+ placeholder="••••••••"
+ required
+ />
+
+
+ {loading ? (
+ 'Wird angemeldet...'
+ ) : (
+ <>
+
+ Anmelden
+ >
+ )}
+
+
+ Zugangsdaten erhalten Sie per Einladung von Ihrem Verein.
+
+
+
+
+ );
+}
diff --git a/packages/features/site-builder/src/components/site-editor.tsx b/packages/features/site-builder/src/components/site-editor.tsx
new file mode 100644
index 000000000..f7204b100
--- /dev/null
+++ b/packages/features/site-builder/src/components/site-editor.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { Puck } from '@measured/puck';
+import '@measured/puck/puck.css';
+import { useAction } from 'next-safe-action/hooks';
+import { toast } from '@kit/ui/sonner';
+import { clubPuckConfig } from '../config/puck-config';
+import { publishPage } from '../server/actions/site-builder-actions';
+
+interface Props {
+ pageId: string;
+ accountId: string;
+ initialData: Record;
+}
+
+export function SiteEditor({ pageId, accountId, initialData }: Props) {
+ const { execute: execPublish } = useAction(publishPage, {
+ onSuccess: () => toast.success('Seite veröffentlicht'),
+ onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
+ });
+
+ const PuckAny = Puck as any;
+
+ return (
+
+
{
+ execPublish({ pageId, puckData: data });
+ }}
+ />
+
+ );
+}
diff --git a/packages/features/site-builder/src/components/site-renderer.tsx b/packages/features/site-builder/src/components/site-renderer.tsx
new file mode 100644
index 000000000..e8818bd91
--- /dev/null
+++ b/packages/features/site-builder/src/components/site-renderer.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import { Render } from '@measured/puck';
+import { clubPuckConfig } from '../config/puck-config';
+
+interface Props {
+ data: Record;
+}
+
+export function SiteRenderer({ data }: Props) {
+ return ;
+}
diff --git a/packages/features/site-builder/src/components/site-settings-form.tsx b/packages/features/site-builder/src/components/site-settings-form.tsx
new file mode 100644
index 000000000..281a2d9e1
--- /dev/null
+++ b/packages/features/site-builder/src/components/site-settings-form.tsx
@@ -0,0 +1,121 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { Button } from '@kit/ui/button';
+import { Input } from '@kit/ui/input';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { SiteSettingsSchema } from '../schema/site.schema';
+import { updateSiteSettings } from '../server/actions/site-builder-actions';
+
+interface Props {
+ accountId: string;
+ account: string;
+ settings: Record | null;
+}
+
+export function SiteSettingsForm({ accountId, account, settings }: Props) {
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(SiteSettingsSchema),
+ defaultValues: {
+ accountId,
+ siteName: String(settings?.site_name ?? ''),
+ siteLogo: String(settings?.site_logo ?? ''),
+ primaryColor: String(settings?.primary_color ?? '#2563eb'),
+ secondaryColor: String(settings?.secondary_color ?? '#64748b'),
+ fontFamily: String(settings?.font_family ?? 'Inter'),
+ contactEmail: String(settings?.contact_email ?? ''),
+ contactPhone: String(settings?.contact_phone ?? ''),
+ contactAddress: String(settings?.contact_address ?? ''),
+ footerText: String(settings?.footer_text ?? ''),
+ impressum: String(settings?.impressum ?? ''),
+ datenschutz: String(settings?.datenschutz ?? ''),
+ isPublic: Boolean(settings?.is_public),
+ navigation: [] as Array<{ label: string; href: string }>,
+ },
+ });
+
+ const { execute, isPending } = useAction(updateSiteSettings, {
+ onSuccess: () => { toast.success('Einstellungen gespeichert'); router.refresh(); },
+ onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
+ });
+
+ return (
+
+ execute(data))} className="space-y-6 max-w-3xl">
+
+ Allgemein
+
+ (
+ Website-Name
+ )} />
+ (
+ Schriftart
+
+ Inter
+ System
+ Georgia
+ Roboto
+
+
+ )} />
+ (
+ Primärfarbe
+ )} />
+ (
+ Sekundärfarbe
+ )} />
+
+
+
+ Kontakt
+
+ (
+ E-Mail
+ )} />
+ (
+ Telefon
+ )} />
+ (
+ Adresse
+ )} />
+
+
+
+ Rechtliches
+
+ (
+ Impressum
+ )} />
+ (
+ Datenschutzerklärung
+ )} />
+
+
+
+ Veröffentlichung
+
+ (
+
+
+
+
Website öffentlich zugänglich
+
Wenn aktiviert, ist Ihre Website unter /club/{account} erreichbar.
+
+
+ )} />
+
+
+
+ router.back()}>Abbrechen
+ {isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}
+
+
+
+ );
+}
diff --git a/packages/features/site-builder/src/config/puck-config.tsx b/packages/features/site-builder/src/config/puck-config.tsx
new file mode 100644
index 000000000..bf5d4ed60
--- /dev/null
+++ b/packages/features/site-builder/src/config/puck-config.tsx
@@ -0,0 +1,360 @@
+import type { Config } from '@measured/puck';
+import React from 'react';
+
+// Block components inline for simplicity
+
+const HeroBlock = ({ title, subtitle, buttonText, buttonLink }: { title: string; subtitle: string; buttonText: string; buttonLink: string }) => (
+
+ {title || 'Willkommen'}
+ {subtitle && {subtitle}
}
+ {buttonText && (
+
+ {buttonText}
+
+ )}
+
+);
+
+const TextBlock = ({ content }: { content: string }) => (
+
' }} />
+);
+
+const ContactFormBlock = ({ title, description, recipientEmail }: { title: string; description: string; recipientEmail: string }) => {
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const form = e.currentTarget;
+ const data = new FormData(form);
+ try {
+ const res = await fetch('/api/club/contact', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ recipientEmail: recipientEmail || '',
+ name: data.get('name'),
+ email: data.get('email'),
+ subject: data.get('subject') || 'Kontaktanfrage',
+ message: data.get('message'),
+ }),
+ });
+ const result = await res.json();
+ if (result.success) {
+ alert('Nachricht erfolgreich gesendet!');
+ form.reset();
+ } else {
+ alert(result.error || 'Fehler beim Senden');
+ }
+ } catch { alert('Verbindungsfehler'); }
+ };
+
+ return (
+
+ );
+};
+
+const MapBlock = ({ latitude, longitude, zoom, height }: { latitude: number; longitude: number; zoom: number; height: number }) => (
+
+);
+
+const ImageGalleryBlock = ({ images, columns }: { images: Array<{ url: string; alt: string }>; columns: number }) => (
+
+
+ {(images || []).map((img, i) => (
+
+ ))}
+
+
+);
+
+const DividerBlock = ({ style, spacing }: { style: string; spacing: string }) => {
+ const py = spacing === 'lg' ? 'py-12' : spacing === 'sm' ? 'py-3' : 'py-6';
+ return (
+
+ {style === 'dots' ? (
+
{[0,1,2].map(i => )}
+ ) : style === 'space' ? null : (
+
+ )}
+
+ );
+};
+
+const NewsletterSignupBlock = ({ title, description, accountId }: { title: string; description: string; accountId?: string }) => {
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const form = e.currentTarget;
+ const data = new FormData(form);
+ try {
+ const res = await fetch('/api/club/newsletter', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ accountId: accountId || '',
+ email: data.get('email'),
+ name: data.get('name') || '',
+ }),
+ });
+ const result = await res.json();
+ if (result.success) {
+ alert('Erfolgreich angemeldet! Bitte bestätigen Sie Ihre E-Mail.');
+ form.reset();
+ } else {
+ alert(result.error || 'Fehler bei der Anmeldung');
+ }
+ } catch { alert('Verbindungsfehler'); }
+ };
+
+ return (
+
+
+
{title || 'Newsletter'}
+ {description &&
{description}
}
+
+
+ Anmelden
+
+
+
+ );
+};
+
+const DownloadBlock = ({ title, files }: { title: string; files: Array<{ label: string; url: string }> }) => (
+
+ {title || 'Downloads'}
+
+
+);
+
+const FooterBlock = ({ text, email, phone }: { text: string; email: string; phone: string }) => (
+
+);
+
+const MemberLoginBlock = ({ title, description }: { title: string; description: string }) => (
+
+);
+
+const NewsFeedBlock = ({ count, showImage }: { count: number; showImage: boolean }) => (
+
+ Neuigkeiten
+
+ {Array.from({ length: count || 3 }, (_, i) => (
+
+
+ {showImage &&
}
+
+
Beitragstitel {i + 1}
+
Kurzbeschreibung des Beitrags...
+
01.01.2026
+
+
+
+ ))}
+
+
+);
+
+const EventListBlock = ({ count, showPastEvents }: { count: number; showPastEvents: boolean }) => (
+
+ Veranstaltungen
+
+ {Array.from({ length: count || 3 }, (_, i) => (
+
+
+ {15 + i}
+ Apr
+
+
+
Veranstaltung {i + 1}
+
10:00 — Vereinsheim
+
+
+ ))}
+
+
+);
+
+const CourseCatalogBlock = ({ count, showPrice }: { count: number; showPrice: boolean }) => (
+
+ Kursangebot
+
+ {Array.from({ length: count || 4 }, (_, i) => (
+
+
Kurs {i + 1}
+
Mo, 18:00 — 20:00
+
+ {showPrice && 49,00 € }
+ 5/15 Plätze
+
+
+ ))}
+
+
+);
+
+const CardShopBlock = ({ title, description }: { title: string; description: string }) => (
+
+ {title || 'Mitgliedschaft'}
+ {description && {description}
}
+
+ {['Basis', 'Standard', 'Familie'].map((name, i) => (
+
+
{name}
+
{[5, 10, 18][i]} €
+
pro Monat
+
Auswählen
+
+ ))}
+
+
+);
+
+const ColumnsBlock = ({ columns }: { columns: number }) => (
+
+
+ {Array.from({ length: columns || 2 }, (_, i) => (
+
+ Spalte {i + 1}
+
+ ))}
+
+
+);
+
+export const clubPuckConfig: Config = {
+ categories: {
+ layout: { title: 'Layout', components: ['Columns', 'Divider'] },
+ content: { title: 'Inhalt', components: ['Hero', 'Text', 'ImageGallery'] },
+ club: { title: 'Verein', components: ['NewsFeed', 'EventList', 'MemberLogin', 'CardShop', 'CourseCatalog'] },
+ communication: { title: 'Kommunikation', components: ['ContactForm', 'NewsletterSignup', 'Download'] },
+ embed: { title: 'Einbetten', components: ['Map'] },
+ navigation: { title: 'Navigation', components: ['Footer'] },
+ },
+ components: {
+ Hero: {
+ fields: {
+ title: { type: 'text' },
+ subtitle: { type: 'textarea' },
+ buttonText: { type: 'text' },
+ buttonLink: { type: 'text' },
+ },
+ defaultProps: { title: 'Willkommen bei unserem Verein', subtitle: '', buttonText: '', buttonLink: '' },
+ render: HeroBlock as any,
+ },
+ Text: {
+ fields: { content: { type: 'textarea' } },
+ defaultProps: { content: 'Hier steht Ihr Text...
' },
+ render: TextBlock as any,
+ },
+ ContactForm: {
+ fields: { title: { type: 'text' }, description: { type: 'textarea' }, recipientEmail: { type: 'text' } },
+ defaultProps: { title: 'Kontakt', description: 'Schreiben Sie uns eine Nachricht.', recipientEmail: '' },
+ render: ContactFormBlock as any,
+ },
+ Map: {
+ fields: {
+ latitude: { type: 'number' },
+ longitude: { type: 'number' },
+ zoom: { type: 'number' },
+ height: { type: 'number' },
+ },
+ defaultProps: { latitude: 48.1351, longitude: 11.5820, zoom: 15, height: 400 },
+ render: MapBlock as any,
+ },
+ ImageGallery: {
+ fields: {
+ images: { type: 'array', arrayFields: { url: { type: 'text' }, alt: { type: 'text' } } } as any,
+ columns: { type: 'number' },
+ },
+ defaultProps: { images: [], columns: 3 },
+ render: ImageGalleryBlock as any,
+ },
+ Divider: {
+ fields: {
+ style: { type: 'select', options: [{ label: 'Linie', value: 'line' }, { label: 'Punkte', value: 'dots' }, { label: 'Abstand', value: 'space' }] },
+ spacing: { type: 'select', options: [{ label: 'Klein', value: 'sm' }, { label: 'Mittel', value: 'md' }, { label: 'Groß', value: 'lg' }] },
+ },
+ defaultProps: { style: 'line', spacing: 'md' },
+ render: DividerBlock as any,
+ },
+ NewsletterSignup: {
+ fields: { title: { type: 'text' }, description: { type: 'textarea' } },
+ defaultProps: { title: 'Newsletter abonnieren', description: 'Bleiben Sie auf dem Laufenden.' },
+ render: NewsletterSignupBlock as any,
+ },
+ Download: {
+ fields: {
+ title: { type: 'text' },
+ files: { type: 'array', arrayFields: { label: { type: 'text' }, url: { type: 'text' } } } as any,
+ },
+ defaultProps: { title: 'Downloads', files: [] },
+ render: DownloadBlock as any,
+ },
+ Footer: {
+ fields: { text: { type: 'text' }, email: { type: 'text' }, phone: { type: 'text' } },
+ defaultProps: { text: '© 2026 Unser Verein', email: '', phone: '' },
+ render: FooterBlock as any,
+ },
+ MemberLogin: {
+ fields: { title: { type: 'text' }, description: { type: 'textarea' } },
+ defaultProps: { title: 'Mitgliederbereich', description: 'Melden Sie sich an, um auf Ihren persönlichen Bereich zuzugreifen.' },
+ render: MemberLoginBlock as any,
+ },
+ NewsFeed: {
+ fields: { count: { type: 'number' }, showImage: { type: 'radio', options: [{ label: 'Ja', value: true }, { label: 'Nein', value: false }] } },
+ defaultProps: { count: 5, showImage: true },
+ render: NewsFeedBlock as any,
+ },
+ EventList: {
+ fields: { count: { type: 'number' }, showPastEvents: { type: 'radio', options: [{ label: 'Ja', value: true }, { label: 'Nein', value: false }] } },
+ defaultProps: { count: 5, showPastEvents: false },
+ render: EventListBlock as any,
+ },
+ CourseCatalog: {
+ fields: { count: { type: 'number' }, showPrice: { type: 'radio', options: [{ label: 'Ja', value: true }, { label: 'Nein', value: false }] } },
+ defaultProps: { count: 4, showPrice: true },
+ render: CourseCatalogBlock as any,
+ },
+ CardShop: {
+ fields: { title: { type: 'text' }, description: { type: 'textarea' } },
+ defaultProps: { title: 'Mitgliedschaft', description: 'Werden Sie Mitglied in unserem Verein.' },
+ render: CardShopBlock as any,
+ },
+ Columns: {
+ fields: { columns: { type: 'number' } },
+ defaultProps: { columns: 2 },
+ render: ColumnsBlock as any,
+ },
+ },
+};
diff --git a/packages/features/site-builder/src/schema/site.schema.ts b/packages/features/site-builder/src/schema/site.schema.ts
new file mode 100644
index 000000000..24f87e7b3
--- /dev/null
+++ b/packages/features/site-builder/src/schema/site.schema.ts
@@ -0,0 +1,66 @@
+import { z } from 'zod';
+
+export const CreatePageSchema = z.object({
+ accountId: z.string().uuid(),
+ slug: z.string().min(1).max(128).regex(/^[a-z0-9-]+$/),
+ title: z.string().min(1).max(256),
+ puckData: z.record(z.string(), z.unknown()).default({}),
+ isHomepage: z.boolean().default(false),
+ metaDescription: z.string().optional(),
+});
+export type CreatePageInput = z.infer;
+
+export const UpdatePageSchema = z.object({
+ pageId: z.string().uuid(),
+ title: z.string().optional(),
+ slug: z.string().optional(),
+ puckData: z.record(z.string(), z.unknown()).optional(),
+ isPublished: z.boolean().optional(),
+ isHomepage: z.boolean().optional(),
+ metaDescription: z.string().optional(),
+ metaImage: z.string().optional(),
+});
+
+export const SiteSettingsSchema = z.object({
+ accountId: z.string().uuid(),
+ siteName: z.string().optional(),
+ siteLogo: z.string().optional(),
+ primaryColor: z.string().default('#2563eb'),
+ secondaryColor: z.string().default('#64748b'),
+ fontFamily: z.string().default('Inter'),
+ customCss: z.string().optional(),
+ navigation: z.array(z.object({ label: z.string(), href: z.string() })).default([]),
+ footerText: z.string().optional(),
+ contactEmail: z.string().optional(),
+ contactPhone: z.string().optional(),
+ contactAddress: z.string().optional(),
+ impressum: z.string().optional(),
+ datenschutz: z.string().optional(),
+ isPublic: z.boolean().default(false),
+});
+
+export const CreatePostSchema = z.object({
+ accountId: z.string().uuid(),
+ title: z.string().min(1),
+ slug: z.string().min(1).regex(/^[a-z0-9-]+$/),
+ content: z.string().optional(),
+ excerpt: z.string().optional(),
+ coverImage: z.string().optional(),
+ status: z.enum(['draft', 'published', 'archived']).default('draft'),
+});
+
+export const UpdatePostSchema = z.object({
+ postId: z.string().uuid(),
+ title: z.string().optional(),
+ slug: z.string().optional(),
+ content: z.string().optional(),
+ excerpt: z.string().optional(),
+ coverImage: z.string().optional(),
+ status: z.enum(['draft', 'published', 'archived']).optional(),
+});
+
+export const NewsletterSubscribeSchema = z.object({
+ accountId: z.string().uuid(),
+ email: z.string().email(),
+ name: z.string().optional(),
+});
diff --git a/packages/features/site-builder/src/server/actions/site-builder-actions.ts b/packages/features/site-builder/src/server/actions/site-builder-actions.ts
new file mode 100644
index 000000000..cdaf027b7
--- /dev/null
+++ b/packages/features/site-builder/src/server/actions/site-builder-actions.ts
@@ -0,0 +1,80 @@
+'use server';
+
+import { z } from 'zod';
+import { authActionClient } from '@kit/next/safe-action';
+import { getLogger } from '@kit/shared/logger';
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { CreatePageSchema, UpdatePageSchema, SiteSettingsSchema, CreatePostSchema, UpdatePostSchema, NewsletterSubscribeSchema } from '../../schema/site.schema';
+import { createSiteBuilderApi } from '../api';
+
+export const createPage = authActionClient
+ .inputSchema(CreatePageSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const api = createSiteBuilderApi(client);
+ const data = await api.createPage(input, ctx.user.id);
+ return { success: true, data };
+ });
+
+export const saveDraft = authActionClient
+ .inputSchema(UpdatePageSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const api = createSiteBuilderApi(client);
+ const data = await api.updatePage(input.pageId, { ...input, isPublished: false }, ctx.user.id);
+ return { success: true, data };
+ });
+
+export const publishPage = authActionClient
+ .inputSchema(UpdatePageSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const api = createSiteBuilderApi(client);
+ const data = await api.updatePage(input.pageId, { ...input, isPublished: true }, ctx.user.id);
+ return { success: true, data };
+ });
+
+export const deletePage = authActionClient
+ .inputSchema(z.object({ pageId: z.string().uuid() }))
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createSiteBuilderApi(client);
+ await api.deletePage(input.pageId);
+ return { success: true };
+ });
+
+export const updateSiteSettings = authActionClient
+ .inputSchema(SiteSettingsSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createSiteBuilderApi(client);
+ const data = await api.upsertSiteSettings(input.accountId, input);
+ return { success: true, data };
+ });
+
+export const createPost = authActionClient
+ .inputSchema(CreatePostSchema)
+ .action(async ({ parsedInput: input, ctx }) => {
+ const client = getSupabaseServerClient();
+ const api = createSiteBuilderApi(client);
+ const data = await api.createPost(input, ctx.user.id);
+ return { success: true, data };
+ });
+
+export const updatePost = authActionClient
+ .inputSchema(UpdatePostSchema)
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createSiteBuilderApi(client);
+ const data = await api.updatePost(input.postId, input);
+ return { success: true, data };
+ });
+
+export const deletePost = authActionClient
+ .inputSchema(z.object({ postId: z.string().uuid() }))
+ .action(async ({ parsedInput: input }) => {
+ const client = getSupabaseServerClient();
+ const api = createSiteBuilderApi(client);
+ await api.deletePost(input.postId);
+ return { success: true };
+ });
diff --git a/packages/features/site-builder/src/server/api.ts b/packages/features/site-builder/src/server/api.ts
new file mode 100644
index 000000000..20d8b7655
--- /dev/null
+++ b/packages/features/site-builder/src/server/api.ts
@@ -0,0 +1,136 @@
+import type { SupabaseClient } from '@supabase/supabase-js';
+import type { Database } from '@kit/supabase/database';
+
+export function createSiteBuilderApi(client: SupabaseClient) {
+ return {
+ // Pages
+ async listPages(accountId: string) {
+ const { data, error } = await client.from('site_pages').select('*')
+ .eq('account_id', accountId).order('sort_order');
+ if (error) throw error;
+ return data ?? [];
+ },
+ async getPage(pageId: string) {
+ const { data, error } = await client.from('site_pages').select('*').eq('id', pageId).single();
+ if (error) throw error;
+ return data;
+ },
+ async getPageBySlug(accountId: string, slug: string) {
+ const { data, error } = await client.from('site_pages').select('*')
+ .eq('account_id', accountId).eq('slug', slug).single();
+ if (error) throw error;
+ return data;
+ },
+ async getHomepage(accountId: string) {
+ const { data, error } = await client.from('site_pages').select('*')
+ .eq('account_id', accountId).eq('is_homepage', true).eq('is_published', true).maybeSingle();
+ if (error) throw error;
+ return data;
+ },
+ async createPage(input: { accountId: string; slug: string; title: string; puckData?: Record; isHomepage?: boolean; metaDescription?: string }, userId: string) {
+ const { data, error } = await client.from('site_pages').insert({
+ account_id: input.accountId, slug: input.slug, title: input.title,
+ puck_data: (input.puckData ?? {}) as any, is_homepage: input.isHomepage ?? false,
+ meta_description: input.metaDescription, created_by: userId, updated_by: userId,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+ async updatePage(pageId: string, input: { title?: string; slug?: string; puckData?: Record; isPublished?: boolean; isHomepage?: boolean; metaDescription?: string; metaImage?: string }, userId: string) {
+ const update: Record = { updated_by: userId };
+ if (input.title !== undefined) update.title = input.title;
+ if (input.slug !== undefined) update.slug = input.slug;
+ if (input.puckData !== undefined) update.puck_data = input.puckData;
+ if (input.isPublished !== undefined) {
+ update.is_published = input.isPublished;
+ if (input.isPublished) update.published_at = new Date().toISOString();
+ }
+ if (input.isHomepage !== undefined) update.is_homepage = input.isHomepage;
+ if (input.metaDescription !== undefined) update.meta_description = input.metaDescription;
+ if (input.metaImage !== undefined) update.meta_image = input.metaImage;
+ const { data, error } = await client.from('site_pages').update(update).eq('id', pageId).select().single();
+ if (error) throw error;
+ return data;
+ },
+ async deletePage(pageId: string) {
+ const { error } = await client.from('site_pages').delete().eq('id', pageId);
+ if (error) throw error;
+ },
+ // Settings
+ async getSiteSettings(accountId: string) {
+ const { data, error } = await client.from('site_settings').select('*').eq('account_id', accountId).maybeSingle();
+ if (error) throw error;
+ return data;
+ },
+ async upsertSiteSettings(accountId: string, input: Record) {
+ const row: Record = { account_id: accountId };
+ if (input.siteName !== undefined) row.site_name = input.siteName;
+ if (input.siteLogo !== undefined) row.site_logo = input.siteLogo;
+ if (input.primaryColor !== undefined) row.primary_color = input.primaryColor;
+ if (input.secondaryColor !== undefined) row.secondary_color = input.secondaryColor;
+ if (input.fontFamily !== undefined) row.font_family = input.fontFamily;
+ if (input.navigation !== undefined) row.navigation = input.navigation;
+ if (input.footerText !== undefined) row.footer_text = input.footerText;
+ if (input.contactEmail !== undefined) row.contact_email = input.contactEmail;
+ if (input.contactPhone !== undefined) row.contact_phone = input.contactPhone;
+ if (input.contactAddress !== undefined) row.contact_address = input.contactAddress;
+ if (input.impressum !== undefined) row.impressum = input.impressum;
+ if (input.datenschutz !== undefined) row.datenschutz = input.datenschutz;
+ if (input.isPublic !== undefined) row.is_public = input.isPublic;
+ const { data, error } = await client.from('site_settings').upsert(row as any).select().single();
+ if (error) throw error;
+ return data;
+ },
+ // Posts
+ async listPosts(accountId: string, status?: string) {
+ let query = client.from('cms_posts').select('*').eq('account_id', accountId).order('created_at', { ascending: false });
+ if (status) query = query.eq('status', status);
+ const { data, error } = await query;
+ if (error) throw error;
+ return data ?? [];
+ },
+ async getPost(postId: string) {
+ const { data, error } = await client.from('cms_posts').select('*').eq('id', postId).single();
+ if (error) throw error;
+ return data;
+ },
+ async createPost(input: { accountId: string; title: string; slug: string; content?: string; excerpt?: string; coverImage?: string; status?: string }, userId: string) {
+ const { data, error } = await client.from('cms_posts').insert({
+ account_id: input.accountId, title: input.title, slug: input.slug,
+ content: input.content, excerpt: input.excerpt, cover_image: input.coverImage,
+ status: input.status ?? 'draft', author_id: userId,
+ published_at: input.status === 'published' ? new Date().toISOString() : null,
+ }).select().single();
+ if (error) throw error;
+ return data;
+ },
+ async updatePost(postId: string, input: { title?: string; slug?: string; content?: string; excerpt?: string; coverImage?: string; status?: string }) {
+ const update: Record = {};
+ if (input.title !== undefined) update.title = input.title;
+ if (input.slug !== undefined) update.slug = input.slug;
+ if (input.content !== undefined) update.content = input.content;
+ if (input.excerpt !== undefined) update.excerpt = input.excerpt;
+ if (input.coverImage !== undefined) update.cover_image = input.coverImage;
+ if (input.status !== undefined) {
+ update.status = input.status;
+ if (input.status === 'published') update.published_at = new Date().toISOString();
+ }
+ const { data, error } = await client.from('cms_posts').update(update).eq('id', postId).select().single();
+ if (error) throw error;
+ return data;
+ },
+ async deletePost(postId: string) {
+ const { error } = await client.from('cms_posts').delete().eq('id', postId);
+ if (error) throw error;
+ },
+ // Newsletter
+ async subscribe(accountId: string, email: string, name?: string) {
+ const token = crypto.randomUUID();
+ const { error } = await client.from('newsletter_subscriptions').upsert({
+ account_id: accountId, email, name, confirmation_token: token, is_active: true,
+ }, { onConflict: 'account_id,email' });
+ if (error) throw error;
+ return token;
+ },
+ };
+}
diff --git a/packages/features/site-builder/tsconfig.json b/packages/features/site-builder/tsconfig.json
new file mode 100644
index 000000000..090f168aa
--- /dev/null
+++ b/packages/features/site-builder/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "@kit/tsconfig/base.json",
+ "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
+ "include": ["*.ts", "*.tsx", "src"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts
index b80464da9..66b5fe309 100644
--- a/packages/supabase/src/database.types.ts
+++ b/packages/supabase/src/database.types.ts
@@ -496,6 +496,73 @@ export type Database = {
},
]
}
+ cms_posts: {
+ Row: {
+ account_id: string
+ author_id: string | null
+ content: string | null
+ cover_image: string | null
+ created_at: string
+ excerpt: string | null
+ id: string
+ published_at: string | null
+ slug: string
+ status: string
+ title: string
+ updated_at: string
+ }
+ Insert: {
+ account_id: string
+ author_id?: string | null
+ content?: string | null
+ cover_image?: string | null
+ created_at?: string
+ excerpt?: string | null
+ id?: string
+ published_at?: string | null
+ slug: string
+ status?: string
+ title: string
+ updated_at?: string
+ }
+ Update: {
+ account_id?: string
+ author_id?: string | null
+ content?: string | null
+ cover_image?: string | null
+ created_at?: string
+ excerpt?: string | null
+ id?: string
+ published_at?: string | null
+ slug?: string
+ status?: string
+ title?: string
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "cms_posts_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "cms_posts_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "cms_posts_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
config: {
Row: {
billing_provider: Database["public"]["Enums"]["billing_provider"]
@@ -963,6 +1030,8 @@ export type Database = {
id: string
interval: string
is_default: boolean
+ is_exit: boolean
+ is_youth: boolean
name: string
sort_order: number
}
@@ -974,6 +1043,8 @@ export type Database = {
id?: string
interval?: string
is_default?: boolean
+ is_exit?: boolean
+ is_youth?: boolean
name: string
sort_order?: number
}
@@ -985,6 +1056,8 @@ export type Database = {
id?: string
interval?: string
is_default?: boolean
+ is_exit?: boolean
+ is_youth?: boolean
name?: string
sort_order?: number
}
@@ -1671,126 +1744,499 @@ export type Database = {
},
]
}
+ member_department_assignments: {
+ Row: {
+ department_id: string
+ member_id: string
+ }
+ Insert: {
+ department_id: string
+ member_id: string
+ }
+ Update: {
+ department_id?: string
+ member_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_department_assignments_department_id_fkey"
+ columns: ["department_id"]
+ isOneToOne: false
+ referencedRelation: "member_departments"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_department_assignments_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ member_departments: {
+ Row: {
+ account_id: string
+ created_at: string
+ description: string | null
+ id: string
+ name: string
+ sort_order: number
+ }
+ Insert: {
+ account_id: string
+ created_at?: string
+ description?: string | null
+ id?: string
+ name: string
+ sort_order?: number
+ }
+ Update: {
+ account_id?: string
+ created_at?: string
+ description?: string | null
+ id?: string
+ name?: string
+ sort_order?: number
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_departments_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_departments_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_departments_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ member_honors: {
+ Row: {
+ account_id: string
+ created_at: string
+ description: string | null
+ honor_date: string | null
+ honor_name: string
+ id: string
+ member_id: string
+ }
+ Insert: {
+ account_id: string
+ created_at?: string
+ description?: string | null
+ honor_date?: string | null
+ honor_name: string
+ id?: string
+ member_id: string
+ }
+ Update: {
+ account_id?: string
+ created_at?: string
+ description?: string | null
+ honor_date?: string | null
+ honor_name?: string
+ id?: string
+ member_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_honors_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_honors_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_honors_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_honors_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ member_portal_invitations: {
+ Row: {
+ accepted_at: string | null
+ account_id: string
+ created_at: string
+ email: string
+ expires_at: string
+ id: string
+ invite_token: string
+ invited_by: string | null
+ member_id: string
+ status: string
+ }
+ Insert: {
+ accepted_at?: string | null
+ account_id: string
+ created_at?: string
+ email: string
+ expires_at?: string
+ id?: string
+ invite_token?: string
+ invited_by?: string | null
+ member_id: string
+ status?: string
+ }
+ Update: {
+ accepted_at?: string | null
+ account_id?: string
+ created_at?: string
+ email?: string
+ expires_at?: string
+ id?: string
+ invite_token?: string
+ invited_by?: string | null
+ member_id?: string
+ status?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_portal_invitations_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_portal_invitations_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_portal_invitations_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_portal_invitations_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ member_roles: {
+ Row: {
+ account_id: string
+ created_at: string
+ from_date: string | null
+ id: string
+ is_active: boolean
+ member_id: string
+ role_name: string
+ until_date: string | null
+ }
+ Insert: {
+ account_id: string
+ created_at?: string
+ from_date?: string | null
+ id?: string
+ is_active?: boolean
+ member_id: string
+ role_name: string
+ until_date?: string | null
+ }
+ Update: {
+ account_id?: string
+ created_at?: string
+ from_date?: string | null
+ id?: string
+ is_active?: boolean
+ member_id?: string
+ role_name?: string
+ until_date?: string | null
+ }
+ Relationships: [
+ {
+ foreignKeyName: "member_roles_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_roles_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_roles_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "member_roles_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
members: {
Row: {
account_holder: string | null
account_id: string
+ additional_fees: number | null
+ address_invalid: boolean
bic: string | null
+ birth_country: string | null
+ birthplace: string | null
city: string | null
country: string | null
created_at: string
created_by: string | null
custom_data: Json
+ data_reconciliation_needed: boolean
date_of_birth: string | null
dues_category_id: string | null
+ dues_paid: boolean
+ dues_year: number | null
email: string | null
+ email_confirmed: boolean
entry_date: string
+ exemption_amount: number | null
+ exemption_reason: string | null
+ exemption_type: string | null
exit_date: string | null
exit_reason: string | null
+ fax: string | null
first_name: string
+ gdpr_birthday_info: boolean
gdpr_consent: boolean
gdpr_consent_date: string | null
gdpr_data_source: string | null
+ gdpr_internet: boolean
+ gdpr_newsletter: boolean
+ gdpr_print: boolean
gender: string | null
+ guardian_email: string | null
+ guardian_name: string | null
+ guardian_phone: string | null
house_number: string | null
iban: string | null
id: string
+ is_archived: boolean
+ is_founding_member: boolean
+ is_honorary: boolean
+ is_probationary: boolean
+ is_retiree: boolean
+ is_transferred: boolean
+ is_youth: boolean
last_name: string
member_number: string | null
mobile: string | null
notes: string | null
+ online_access_blocked: boolean
+ online_access_key: string | null
phone: string | null
+ phone2: string | null
postal_code: string | null
+ salutation: string | null
+ sepa_bank_name: string | null
sepa_mandate_date: string | null
sepa_mandate_id: string | null
+ sepa_mandate_reference: string | null
+ sepa_mandate_sequence: string | null
sepa_mandate_status:
| Database["public"]["Enums"]["sepa_mandate_status"]
| null
status: Database["public"]["Enums"]["membership_status"]
street: string | null
+ street2: string | null
title: string | null
updated_at: string
updated_by: string | null
+ user_id: string | null
}
Insert: {
account_holder?: string | null
account_id: string
+ additional_fees?: number | null
+ address_invalid?: boolean
bic?: string | null
+ birth_country?: string | null
+ birthplace?: string | null
city?: string | null
country?: string | null
created_at?: string
created_by?: string | null
custom_data?: Json
+ data_reconciliation_needed?: boolean
date_of_birth?: string | null
dues_category_id?: string | null
+ dues_paid?: boolean
+ dues_year?: number | null
email?: string | null
+ email_confirmed?: boolean
entry_date?: string
+ exemption_amount?: number | null
+ exemption_reason?: string | null
+ exemption_type?: string | null
exit_date?: string | null
exit_reason?: string | null
+ fax?: string | null
first_name: string
+ gdpr_birthday_info?: boolean
gdpr_consent?: boolean
gdpr_consent_date?: string | null
gdpr_data_source?: string | null
+ gdpr_internet?: boolean
+ gdpr_newsletter?: boolean
+ gdpr_print?: boolean
gender?: string | null
+ guardian_email?: string | null
+ guardian_name?: string | null
+ guardian_phone?: string | null
house_number?: string | null
iban?: string | null
id?: string
+ is_archived?: boolean
+ is_founding_member?: boolean
+ is_honorary?: boolean
+ is_probationary?: boolean
+ is_retiree?: boolean
+ is_transferred?: boolean
+ is_youth?: boolean
last_name: string
member_number?: string | null
mobile?: string | null
notes?: string | null
+ online_access_blocked?: boolean
+ online_access_key?: string | null
phone?: string | null
+ phone2?: string | null
postal_code?: string | null
+ salutation?: string | null
+ sepa_bank_name?: string | null
sepa_mandate_date?: string | null
sepa_mandate_id?: string | null
+ sepa_mandate_reference?: string | null
+ sepa_mandate_sequence?: string | null
sepa_mandate_status?:
| Database["public"]["Enums"]["sepa_mandate_status"]
| null
status?: Database["public"]["Enums"]["membership_status"]
street?: string | null
+ street2?: string | null
title?: string | null
updated_at?: string
updated_by?: string | null
+ user_id?: string | null
}
Update: {
account_holder?: string | null
account_id?: string
+ additional_fees?: number | null
+ address_invalid?: boolean
bic?: string | null
+ birth_country?: string | null
+ birthplace?: string | null
city?: string | null
country?: string | null
created_at?: string
created_by?: string | null
custom_data?: Json
+ data_reconciliation_needed?: boolean
date_of_birth?: string | null
dues_category_id?: string | null
+ dues_paid?: boolean
+ dues_year?: number | null
email?: string | null
+ email_confirmed?: boolean
entry_date?: string
+ exemption_amount?: number | null
+ exemption_reason?: string | null
+ exemption_type?: string | null
exit_date?: string | null
exit_reason?: string | null
+ fax?: string | null
first_name?: string
+ gdpr_birthday_info?: boolean
gdpr_consent?: boolean
gdpr_consent_date?: string | null
gdpr_data_source?: string | null
+ gdpr_internet?: boolean
+ gdpr_newsletter?: boolean
+ gdpr_print?: boolean
gender?: string | null
+ guardian_email?: string | null
+ guardian_name?: string | null
+ guardian_phone?: string | null
house_number?: string | null
iban?: string | null
id?: string
+ is_archived?: boolean
+ is_founding_member?: boolean
+ is_honorary?: boolean
+ is_probationary?: boolean
+ is_retiree?: boolean
+ is_transferred?: boolean
+ is_youth?: boolean
last_name?: string
member_number?: string | null
mobile?: string | null
notes?: string | null
+ online_access_blocked?: boolean
+ online_access_key?: string | null
phone?: string | null
+ phone2?: string | null
postal_code?: string | null
+ salutation?: string | null
+ sepa_bank_name?: string | null
sepa_mandate_date?: string | null
sepa_mandate_id?: string | null
+ sepa_mandate_reference?: string | null
+ sepa_mandate_sequence?: string | null
sepa_mandate_status?:
| Database["public"]["Enums"]["sepa_mandate_status"]
| null
status?: Database["public"]["Enums"]["membership_status"]
street?: string | null
+ street2?: string | null
title?: string | null
updated_at?: string
updated_by?: string | null
+ user_id?: string | null
}
Relationships: [
{
@@ -2412,6 +2858,64 @@ export type Database = {
},
]
}
+ newsletter_subscriptions: {
+ Row: {
+ account_id: string
+ confirmation_token: string | null
+ confirmed_at: string | null
+ email: string
+ id: string
+ is_active: boolean
+ name: string | null
+ subscribed_at: string
+ unsubscribed_at: string | null
+ }
+ Insert: {
+ account_id: string
+ confirmation_token?: string | null
+ confirmed_at?: string | null
+ email: string
+ id?: string
+ is_active?: boolean
+ name?: string | null
+ subscribed_at?: string
+ unsubscribed_at?: string | null
+ }
+ Update: {
+ account_id?: string
+ confirmation_token?: string | null
+ confirmed_at?: string | null
+ email?: string
+ id?: string
+ is_active?: boolean
+ name?: string | null
+ subscribed_at?: string
+ unsubscribed_at?: string | null
+ }
+ Relationships: [
+ {
+ foreignKeyName: "newsletter_subscriptions_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "newsletter_subscriptions_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "newsletter_subscriptions_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
newsletter_templates: {
Row: {
account_id: string
@@ -3015,6 +3519,259 @@ export type Database = {
},
]
}
+ sepa_mandates: {
+ Row: {
+ account_holder: string
+ account_id: string
+ bic: string | null
+ created_at: string
+ has_error: boolean
+ iban: string
+ id: string
+ is_primary: boolean
+ last_used_at: string | null
+ mandate_date: string
+ mandate_reference: string
+ member_id: string
+ notes: string | null
+ sequence: string
+ status: Database["public"]["Enums"]["sepa_mandate_status"]
+ updated_at: string
+ }
+ Insert: {
+ account_holder: string
+ account_id: string
+ bic?: string | null
+ created_at?: string
+ has_error?: boolean
+ iban: string
+ id?: string
+ is_primary?: boolean
+ last_used_at?: string | null
+ mandate_date: string
+ mandate_reference: string
+ member_id: string
+ notes?: string | null
+ sequence?: string
+ status?: Database["public"]["Enums"]["sepa_mandate_status"]
+ updated_at?: string
+ }
+ Update: {
+ account_holder?: string
+ account_id?: string
+ bic?: string | null
+ created_at?: string
+ has_error?: boolean
+ iban?: string
+ id?: string
+ is_primary?: boolean
+ last_used_at?: string | null
+ mandate_date?: string
+ mandate_reference?: string
+ member_id?: string
+ notes?: string | null
+ sequence?: string
+ status?: Database["public"]["Enums"]["sepa_mandate_status"]
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "sepa_mandates_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "sepa_mandates_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "sepa_mandates_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "sepa_mandates_member_id_fkey"
+ columns: ["member_id"]
+ isOneToOne: false
+ referencedRelation: "members"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ site_pages: {
+ Row: {
+ account_id: string
+ created_at: string
+ created_by: string | null
+ id: string
+ is_homepage: boolean
+ is_members_only: boolean
+ is_published: boolean
+ meta_description: string | null
+ meta_image: string | null
+ published_at: string | null
+ puck_data: Json
+ slug: string
+ sort_order: number
+ title: string
+ updated_at: string
+ updated_by: string | null
+ }
+ Insert: {
+ account_id: string
+ created_at?: string
+ created_by?: string | null
+ id?: string
+ is_homepage?: boolean
+ is_members_only?: boolean
+ is_published?: boolean
+ meta_description?: string | null
+ meta_image?: string | null
+ published_at?: string | null
+ puck_data?: Json
+ slug: string
+ sort_order?: number
+ title: string
+ updated_at?: string
+ updated_by?: string | null
+ }
+ Update: {
+ account_id?: string
+ created_at?: string
+ created_by?: string | null
+ id?: string
+ is_homepage?: boolean
+ is_members_only?: boolean
+ is_published?: boolean
+ meta_description?: string | null
+ meta_image?: string | null
+ published_at?: string | null
+ puck_data?: Json
+ slug?: string
+ sort_order?: number
+ title?: string
+ updated_at?: string
+ updated_by?: string | null
+ }
+ Relationships: [
+ {
+ foreignKeyName: "site_pages_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "site_pages_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "site_pages_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ site_settings: {
+ Row: {
+ account_id: string
+ contact_address: string | null
+ contact_email: string | null
+ contact_phone: string | null
+ created_at: string
+ custom_css: string | null
+ custom_domain: string | null
+ datenschutz: string | null
+ font_family: string | null
+ footer_text: string | null
+ impressum: string | null
+ is_public: boolean
+ navigation: Json
+ primary_color: string | null
+ secondary_color: string | null
+ site_logo: string | null
+ site_name: string | null
+ social_links: Json | null
+ updated_at: string
+ }
+ Insert: {
+ account_id: string
+ contact_address?: string | null
+ contact_email?: string | null
+ contact_phone?: string | null
+ created_at?: string
+ custom_css?: string | null
+ custom_domain?: string | null
+ datenschutz?: string | null
+ font_family?: string | null
+ footer_text?: string | null
+ impressum?: string | null
+ is_public?: boolean
+ navigation?: Json
+ primary_color?: string | null
+ secondary_color?: string | null
+ site_logo?: string | null
+ site_name?: string | null
+ social_links?: Json | null
+ updated_at?: string
+ }
+ Update: {
+ account_id?: string
+ contact_address?: string | null
+ contact_email?: string | null
+ contact_phone?: string | null
+ created_at?: string
+ custom_css?: string | null
+ custom_domain?: string | null
+ datenschutz?: string | null
+ font_family?: string | null
+ footer_text?: string | null
+ impressum?: string | null
+ is_public?: boolean
+ navigation?: Json
+ primary_color?: string | null
+ secondary_color?: string | null
+ site_logo?: string | null
+ site_name?: string | null
+ social_links?: Json | null
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "site_settings_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: true
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "site_settings_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: true
+ referencedRelation: "user_account_workspace"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "site_settings_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: true
+ referencedRelation: "user_accounts"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
subscription_items: {
Row: {
created_at: string
@@ -3194,6 +3951,22 @@ export type Database = {
Args: { target_team_account_id: string; target_user_id: string }
Returns: boolean
}
+ check_duplicate_member: {
+ Args: {
+ p_account_id: string
+ p_date_of_birth?: string
+ p_first_name: string
+ p_last_name: string
+ }
+ Returns: {
+ date_of_birth: string
+ first_name: string
+ id: string
+ last_name: string
+ member_number: string
+ status: Database["public"]["Enums"]["membership_status"]
+ }[]
+ }
create_invitation: {
Args: { account_id: string; email: string; role: string }
Returns: {
@@ -3327,6 +4100,10 @@ export type Database = {
Args: { account_id: string; user_id: string }
Returns: boolean
}
+ link_member_to_user: {
+ Args: { p_invite_token: string; p_user_id: string }
+ Returns: string
+ }
module_query: {
Args: {
p_filters?: Json
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2b9e0b4d5..4a183094d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,6 +39,9 @@ catalogs:
'@marsidev/react-turnstile':
specifier: ^1.4.2
version: 1.4.2
+ '@measured/puck':
+ specifier: ^0.20.2
+ version: 0.20.2
'@modelcontextprotocol/sdk':
specifier: 1.28.0
version: 1.28.0
@@ -54,6 +57,9 @@ catalogs:
'@react-email/components':
specifier: 1.0.10
version: 1.0.10
+ '@react-pdf/renderer':
+ specifier: ^4.3.2
+ version: 4.3.2
'@sentry/nextjs':
specifier: 10.46.0
version: 10.46.0
@@ -96,6 +102,9 @@ catalogs:
'@types/nodemailer':
specifier: 7.0.11
version: 7.0.11
+ '@types/papaparse':
+ specifier: ^5.5.2
+ version: 5.5.2
'@types/react':
specifier: 19.2.14
version: 19.2.14
@@ -247,10 +256,17 @@ catalogs:
importers:
.:
+ dependencies:
+ '@measured/puck':
+ specifier: 'catalog:'
+ version: 0.20.2(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@manypkg/cli':
specifier: 'catalog:'
version: 0.25.1
+ '@react-pdf/renderer':
+ specifier: 'catalog:'
+ version: 4.3.2(react@19.2.4)
'@tiptap/pm':
specifier: 'catalog:'
version: 3.21.0
@@ -266,6 +282,9 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 25.5.0
+ '@types/papaparse':
+ specifier: 'catalog:'
+ version: 5.5.2
cross-env:
specifier: 'catalog:'
version: 10.1.0
@@ -568,6 +587,9 @@ importers:
specifier: 'catalog:'
version: 4.3.6
devDependencies:
+ '@kit/site-builder':
+ specifier: workspace:*
+ version: link:../../packages/features/site-builder
'@kit/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
@@ -1074,6 +1096,9 @@ importers:
packages/features/booking-management:
devDependencies:
+ '@hookform/resolvers':
+ specifier: 'catalog:'
+ version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
'@kit/next':
specifier: workspace:*
version: link:../../next
@@ -1104,12 +1129,18 @@ importers:
react:
specifier: 'catalog:'
version: 19.2.4
+ react-hook-form:
+ specifier: 'catalog:'
+ version: 7.72.0(react@19.2.4)
zod:
specifier: 'catalog:'
version: 4.3.6
packages/features/course-management:
devDependencies:
+ '@hookform/resolvers':
+ specifier: 'catalog:'
+ version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
'@kit/next':
specifier: workspace:*
version: link:../../next
@@ -1140,6 +1171,9 @@ importers:
react:
specifier: 'catalog:'
version: 19.2.4
+ react-hook-form:
+ specifier: 'catalog:'
+ version: 7.72.0(react@19.2.4)
zod:
specifier: 'catalog:'
version: 4.3.6
@@ -1182,6 +1216,9 @@ importers:
packages/features/event-management:
devDependencies:
+ '@hookform/resolvers':
+ specifier: 'catalog:'
+ version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
'@kit/next':
specifier: workspace:*
version: link:../../next
@@ -1212,12 +1249,18 @@ importers:
react:
specifier: 'catalog:'
version: 19.2.4
+ react-hook-form:
+ specifier: 'catalog:'
+ version: 7.72.0(react@19.2.4)
zod:
specifier: 'catalog:'
version: 4.3.6
packages/features/finance:
devDependencies:
+ '@hookform/resolvers':
+ specifier: 'catalog:'
+ version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
'@kit/next':
specifier: workspace:*
version: link:../../next
@@ -1248,12 +1291,18 @@ importers:
react:
specifier: 'catalog:'
version: 19.2.4
+ react-hook-form:
+ specifier: 'catalog:'
+ version: 7.72.0(react@19.2.4)
zod:
specifier: 'catalog:'
version: 4.3.6
packages/features/member-management:
devDependencies:
+ '@hookform/resolvers':
+ specifier: 'catalog:'
+ version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
'@kit/next':
specifier: workspace:*
version: link:../../next
@@ -1272,18 +1321,30 @@ importers:
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.100.0
+ '@types/papaparse':
+ specifier: 'catalog:'
+ version: 5.5.2
'@types/react':
specifier: 'catalog:'
version: 19.2.14
+ lucide-react:
+ specifier: 'catalog:'
+ version: 1.7.0(react@19.2.4)
next:
specifier: 'catalog:'
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action:
specifier: 'catalog:'
version: 8.1.8(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ papaparse:
+ specifier: 'catalog:'
+ version: 5.5.3
react:
specifier: 'catalog:'
version: 19.2.4
+ react-hook-form:
+ specifier: 'catalog:'
+ version: 7.72.0(react@19.2.4)
zod:
specifier: 'catalog:'
version: 4.3.6
@@ -1329,6 +1390,9 @@ importers:
packages/features/newsletter:
devDependencies:
+ '@hookform/resolvers':
+ specifier: 'catalog:'
+ version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
'@kit/next':
specifier: workspace:*
version: link:../../next
@@ -1359,6 +1423,9 @@ importers:
react:
specifier: 'catalog:'
version: 19.2.4
+ react-hook-form:
+ specifier: 'catalog:'
+ version: 7.72.0(react@19.2.4)
zod:
specifier: 'catalog:'
version: 4.3.6
@@ -1400,6 +1467,54 @@ importers:
specifier: 'catalog:'
version: 19.2.4(react@19.2.4)
+ packages/features/site-builder:
+ devDependencies:
+ '@hookform/resolvers':
+ specifier: 'catalog:'
+ version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
+ '@kit/next':
+ specifier: workspace:*
+ version: link:../../next
+ '@kit/shared':
+ specifier: workspace:*
+ version: link:../../shared
+ '@kit/supabase':
+ specifier: workspace:*
+ version: link:../../supabase
+ '@kit/tsconfig':
+ specifier: workspace:*
+ version: link:../../../tooling/typescript
+ '@kit/ui':
+ specifier: workspace:*
+ version: link:../../ui
+ '@measured/puck':
+ specifier: '*'
+ version: 0.20.2(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ '@supabase/supabase-js':
+ specifier: 'catalog:'
+ version: 2.100.0
+ '@types/react':
+ specifier: 'catalog:'
+ version: 19.2.14
+ lucide-react:
+ specifier: 'catalog:'
+ version: 1.7.0(react@19.2.4)
+ next:
+ specifier: 'catalog:'
+ version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ next-safe-action:
+ specifier: 'catalog:'
+ version: 8.1.8(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react:
+ specifier: 'catalog:'
+ version: 19.2.4
+ react-hook-form:
+ specifier: 'catalog:'
+ version: 7.72.0(react@19.2.4)
+ zod:
+ specifier: 'catalog:'
+ version: 4.3.6
+
packages/features/team-accounts:
dependencies:
nanoid:
@@ -2103,6 +2218,30 @@ packages:
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
+ '@dnd-kit/abstract@0.1.21':
+ resolution: {integrity: sha512-6sJut6/D21xPIK8EFMu+JJeF+fBCOmQKN1BRpeUYFi5m9P1CJpTYbBwfI107h7PHObI6a5bsckiKkRpF2orHpw==}
+
+ '@dnd-kit/collision@0.1.21':
+ resolution: {integrity: sha512-9AJ4NbuwGDexxMCZXZyKdNQhbAe93p6C6IezQaDaWmdCqZHMHmC3+ul7pGefBQfOooSarGwIf8Bn182o9SMa1A==}
+
+ '@dnd-kit/dom@0.1.21':
+ resolution: {integrity: sha512-6UDc1y2Y3oLQKArGlgCrZxz5pdEjRSiQujXOn5JdbuWvKqTdUR5RTYDeicr+y2sVm3liXjTqs3WlUoV+eqhqUQ==}
+
+ '@dnd-kit/geometry@0.1.21':
+ resolution: {integrity: sha512-Tir97wNJbopN2HgkD7AjAcoB3vvrVuUHvwdPALmNDUH0fWR637c4MKQ66YjjZAbUEAR8KL6mlDiHH4MzTLd7CQ==}
+
+ '@dnd-kit/helpers@0.1.18':
+ resolution: {integrity: sha512-k4hVXIb8ysPt+J0KOxbBTc6rG0JSlsrNevI/fCHLbyXvEyj1imxl7yOaAQX13cAZnte88db6JvbgsSWlVjtxbw==}
+
+ '@dnd-kit/react@0.1.18':
+ resolution: {integrity: sha512-OCeCO9WbKnN4rVlEOEe9QWxSIFzP0m/fBFmVYfu2pDSb4pemRkfrvCsI/FH3jonuESYS8qYnN9vc8Vp3EiCWCA==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+
+ '@dnd-kit/state@0.1.21':
+ resolution: {integrity: sha512-pdhntEPvn/QttcF295bOJpWiLsRqA/Iczh1ODOJUxGiR+E4GkYVz9VapNNm9gDq6ST0tr/e1Q2xBztUHlJqQgA==}
+
'@dotenvx/dotenvx@1.57.2':
resolution: {integrity: sha512-lv9+UZPnl/KOvShepevLWm3+/wc1It5kgO5Q580evnvOFMZcgKVEYFwxlL7Ohl9my1yjTsWo28N3PJYUEO8wFQ==}
hasBin: true
@@ -3116,6 +3255,12 @@ packages:
react: ^17.0.2 || ^18.0.0 || ^19.0
react-dom: ^17.0.2 || ^18.0.0 || ^19.0
+ '@measured/puck@0.20.2':
+ resolution: {integrity: sha512-/GuzlsGs1T2S3lY9so4GyHpDBlWnC1h/4rkYuelrLNHvacnXBZyn50hvgRhWAqlLn/xOuJvJeuY740Zemxdt3Q==}
+ deprecated: 'Puck has moved. Please use @puckeditor/core instead: https://www.npmjs.com/package/@puckeditor/core'
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+
'@modelcontextprotocol/sdk@1.28.0':
resolution: {integrity: sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==}
engines: {node: '>=18'}
@@ -3795,6 +3940,9 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+ '@preact/signals-core@1.14.0':
+ resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==}
+
'@prisma/instrumentation@7.4.2':
resolution: {integrity: sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg==}
peerDependencies:
@@ -4413,6 +4561,49 @@ packages:
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
+ '@react-pdf/fns@3.1.2':
+ resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==}
+
+ '@react-pdf/font@4.0.4':
+ resolution: {integrity: sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==}
+
+ '@react-pdf/image@3.0.4':
+ resolution: {integrity: sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==}
+
+ '@react-pdf/layout@4.4.2':
+ resolution: {integrity: sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==}
+
+ '@react-pdf/pdfkit@4.1.0':
+ resolution: {integrity: sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==}
+
+ '@react-pdf/png-js@3.0.0':
+ resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==}
+
+ '@react-pdf/primitives@4.1.1':
+ resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==}
+
+ '@react-pdf/reconciler@2.0.0':
+ resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@react-pdf/render@4.3.2':
+ resolution: {integrity: sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==}
+
+ '@react-pdf/renderer@4.3.2':
+ resolution: {integrity: sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@react-pdf/stylesheet@6.1.2':
+ resolution: {integrity: sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==}
+
+ '@react-pdf/textkit@6.1.0':
+ resolution: {integrity: sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==}
+
+ '@react-pdf/types@2.9.2':
+ resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==}
+
'@react-stately/calendar@3.9.3':
resolution: {integrity: sha512-uw7fCZXoypSBBUsVkbNvJMQWTihZReRbyLIGG3o/ZM630N3OCZhb/h4Uxke4pNu7n527H0V1bAnZgAldIzOYqg==}
peerDependencies:
@@ -5695,6 +5886,9 @@ packages:
'@types/nodemailer@7.0.11':
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
+ '@types/papaparse@5.5.2':
+ resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
+
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
@@ -5831,6 +6025,9 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
+ abs-svg-path@0.1.1:
+ resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
+
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -5967,6 +6164,10 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ base64-js@0.0.8:
+ resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
+ engines: {node: '>= 0.4'}
+
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -5980,6 +6181,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
big-integer@1.6.52:
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
@@ -6022,6 +6226,12 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
+ brotli@1.3.3:
+ resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
+
+ browserify-zlib@0.2.0:
+ resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
+
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -6153,6 +6363,10 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
+ clone@2.1.2:
+ resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
+ engines: {node: '>=0.8'}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -6177,6 +6391,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ color-string@1.9.1:
+ resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
+
colord@2.9.3:
resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
@@ -6295,6 +6512,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ crypto-js@4.2.0:
+ resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+
css-declaration-sorter@7.3.1:
resolution: {integrity: sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA==}
engines: {node: ^14 || ^16 || >=18}
@@ -6435,6 +6655,10 @@ packages:
babel-plugin-macros:
optional: true
+ deep-diff@1.0.2:
+ resolution: {integrity: sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
+
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
@@ -6481,6 +6705,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+ dfa@1.2.0:
+ resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
+
diff@8.0.3:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
@@ -6546,6 +6773,9 @@ packages:
emery@1.4.4:
resolution: {integrity: sha512-mMoO3uGDoiw/DmZ/YekT9gEoC0IFAXNWzYVukY8+/j0Wt8un1IDraIYGx+cMbRh+fHaCDE6Ui7zFAN8ezZSsAA==}
+ emoji-regex-xs@1.0.0:
+ resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
+
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -6777,6 +7007,13 @@ packages:
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
+ flat@5.0.2:
+ resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==}
+ hasBin: true
+
+ fontkit@2.0.4:
+ resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
+
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
@@ -6924,6 +7161,12 @@ packages:
resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==}
engines: {node: '>=16.9.0'}
+ hsl-to-hex@1.0.0:
+ resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
+
+ hsl-to-rgb-for-reals@1.1.1:
+ resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
+
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -6958,6 +7201,9 @@ packages:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
+ hyphen@1.14.1:
+ resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
+
iban@0.0.14:
resolution: {integrity: sha512-+rocNKk+Ga9m8Lr9fTMWd+87JnsBrucm0ZsIx5ROOarZlaDLmd+FKdbtvb0XyoBw9GAFOYG2GuLqoNB16d+p3w==}
@@ -7051,6 +7297,9 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+ is-arrayish@0.3.4:
+ resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
+
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -7146,6 +7395,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
+ is-url@1.2.4:
+ resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
+
is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
@@ -7163,6 +7415,9 @@ packages:
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
+ jay-peg@1.1.1:
+ resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
+
jest-worker@27.5.1:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
@@ -7321,6 +7576,9 @@ packages:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
+ linebreak@1.1.0:
+ resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -7487,6 +7745,9 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+ media-engine@1.0.3:
+ resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
+
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@@ -7796,6 +8057,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
+ normalize-svg-path@1.1.0:
+ resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
+
nosecone@1.3.0:
resolution: {integrity: sha512-AYEacOpXmpbBX+GheA3Lbp8CxxsWtNanMjINFi9mt0pvPUaDCJc3VSffU21O0QDRtBM8hrBWhRVyvTqShaIHdw==}
engines: {node: '>=20'}
@@ -7819,6 +8083,10 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -7898,6 +8166,9 @@ packages:
resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==}
engines: {node: '>=18'}
+ pako@0.2.9:
+ resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
+
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@@ -7924,6 +8195,9 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
+ parse-svg-path@0.1.2:
+ resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
+
parseley@0.12.1:
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
@@ -8378,6 +8652,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ queue@6.0.2:
+ resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
+
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@@ -8416,6 +8693,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
+ react-hotkeys-hook@4.6.2:
+ resolution: {integrity: sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==}
+ peerDependencies:
+ react: '>=16.8.1'
+ react-dom: '>=16.8.1'
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -8566,6 +8849,9 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
+ restructure@3.0.2:
+ resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
+
rettime@0.10.1:
resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==}
@@ -8626,6 +8912,9 @@ packages:
resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
engines: {node: '>=10'}
+ scheduler@0.25.0-rc-603e6108-20241029:
+ resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -8720,6 +9009,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ simple-swizzle@0.2.4:
+ resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
+
sirv@2.0.4:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
@@ -8897,6 +9189,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ svg-arc-to-cubic-bezier@3.2.0:
+ resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
+
svgo@4.0.1:
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
engines: {node: '>=16'}
@@ -8966,6 +9261,9 @@ packages:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
+ tiny-inflate@1.0.3:
+ resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
+
tiny-invariant@1.0.6:
resolution: {integrity: sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==}
@@ -9114,6 +9412,12 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+ unicode-properties@1.4.1:
+ resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
+
+ unicode-trie@2.0.0:
+ resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
+
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -9172,6 +9476,12 @@ packages:
'@types/react':
optional: true
+ use-debounce@9.0.4:
+ resolution: {integrity: sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==}
+ engines: {node: '>= 10.0.0'}
+ peerDependencies:
+ react: '>=16.8.0'
+
use-intl@4.8.3:
resolution: {integrity: sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==}
peerDependencies:
@@ -9227,6 +9537,10 @@ packages:
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
+ vite-compatible-readable-stream@3.6.1:
+ resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
+ engines: {node: '>= 6'}
+
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -9473,6 +9787,9 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
+ yoga-layout@3.2.1:
+ resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
+
zip-stream@4.1.1:
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
engines: {node: '>= 10'}
@@ -9488,6 +9805,24 @@ packages:
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
+ zustand@5.0.12:
+ resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -9725,6 +10060,50 @@ snapshots:
'@discoveryjs/json-ext@0.5.7': {}
+ '@dnd-kit/abstract@0.1.21':
+ dependencies:
+ '@dnd-kit/geometry': 0.1.21
+ '@dnd-kit/state': 0.1.21
+ tslib: 2.8.1
+
+ '@dnd-kit/collision@0.1.21':
+ dependencies:
+ '@dnd-kit/abstract': 0.1.21
+ '@dnd-kit/geometry': 0.1.21
+ tslib: 2.8.1
+
+ '@dnd-kit/dom@0.1.21':
+ dependencies:
+ '@dnd-kit/abstract': 0.1.21
+ '@dnd-kit/collision': 0.1.21
+ '@dnd-kit/geometry': 0.1.21
+ '@dnd-kit/state': 0.1.21
+ tslib: 2.8.1
+
+ '@dnd-kit/geometry@0.1.21':
+ dependencies:
+ '@dnd-kit/state': 0.1.21
+ tslib: 2.8.1
+
+ '@dnd-kit/helpers@0.1.18':
+ dependencies:
+ '@dnd-kit/abstract': 0.1.21
+ tslib: 2.8.1
+
+ '@dnd-kit/react@0.1.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@dnd-kit/abstract': 0.1.21
+ '@dnd-kit/dom': 0.1.21
+ '@dnd-kit/state': 0.1.21
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ tslib: 2.8.1
+
+ '@dnd-kit/state@0.1.21':
+ dependencies:
+ '@preact/signals-core': 1.14.0
+ tslib: 2.8.1
+
'@dotenvx/dotenvx@1.57.2':
dependencies:
commander: 11.1.0
@@ -10680,6 +11059,25 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
+ '@measured/puck@0.20.2(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))':
+ dependencies:
+ '@dnd-kit/helpers': 0.1.18
+ '@dnd-kit/react': 0.1.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ deep-diff: 1.0.2
+ fast-deep-equal: 3.1.3
+ flat: 5.0.2
+ object-hash: 3.0.0
+ react: 19.2.4
+ react-hotkeys-hook: 4.6.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ use-debounce: 9.0.4(react@19.2.4)
+ uuid: 9.0.1
+ zustand: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+ - react-dom
+ - use-sync-external-store
+
'@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.11(hono@4.12.9)
@@ -11264,6 +11662,8 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
+ '@preact/signals-core@1.14.0': {}
+
'@prisma/instrumentation@7.4.2(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -12118,6 +12518,107 @@ snapshots:
dependencies:
react: 19.2.4
+ '@react-pdf/fns@3.1.2': {}
+
+ '@react-pdf/font@4.0.4':
+ dependencies:
+ '@react-pdf/pdfkit': 4.1.0
+ '@react-pdf/types': 2.9.2
+ fontkit: 2.0.4
+ is-url: 1.2.4
+
+ '@react-pdf/image@3.0.4':
+ dependencies:
+ '@react-pdf/png-js': 3.0.0
+ jay-peg: 1.1.1
+
+ '@react-pdf/layout@4.4.2':
+ dependencies:
+ '@react-pdf/fns': 3.1.2
+ '@react-pdf/image': 3.0.4
+ '@react-pdf/primitives': 4.1.1
+ '@react-pdf/stylesheet': 6.1.2
+ '@react-pdf/textkit': 6.1.0
+ '@react-pdf/types': 2.9.2
+ emoji-regex-xs: 1.0.0
+ queue: 6.0.2
+ yoga-layout: 3.2.1
+
+ '@react-pdf/pdfkit@4.1.0':
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@react-pdf/png-js': 3.0.0
+ browserify-zlib: 0.2.0
+ crypto-js: 4.2.0
+ fontkit: 2.0.4
+ jay-peg: 1.1.1
+ linebreak: 1.1.0
+ vite-compatible-readable-stream: 3.6.1
+
+ '@react-pdf/png-js@3.0.0':
+ dependencies:
+ browserify-zlib: 0.2.0
+
+ '@react-pdf/primitives@4.1.1': {}
+
+ '@react-pdf/reconciler@2.0.0(react@19.2.4)':
+ dependencies:
+ object-assign: 4.1.1
+ react: 19.2.4
+ scheduler: 0.25.0-rc-603e6108-20241029
+
+ '@react-pdf/render@4.3.2':
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@react-pdf/fns': 3.1.2
+ '@react-pdf/primitives': 4.1.1
+ '@react-pdf/textkit': 6.1.0
+ '@react-pdf/types': 2.9.2
+ abs-svg-path: 0.1.1
+ color-string: 1.9.1
+ normalize-svg-path: 1.1.0
+ parse-svg-path: 0.1.2
+ svg-arc-to-cubic-bezier: 3.2.0
+
+ '@react-pdf/renderer@4.3.2(react@19.2.4)':
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@react-pdf/fns': 3.1.2
+ '@react-pdf/font': 4.0.4
+ '@react-pdf/layout': 4.4.2
+ '@react-pdf/pdfkit': 4.1.0
+ '@react-pdf/primitives': 4.1.1
+ '@react-pdf/reconciler': 2.0.0(react@19.2.4)
+ '@react-pdf/render': 4.3.2
+ '@react-pdf/types': 2.9.2
+ events: 3.3.0
+ object-assign: 4.1.1
+ prop-types: 15.8.1
+ queue: 6.0.2
+ react: 19.2.4
+
+ '@react-pdf/stylesheet@6.1.2':
+ dependencies:
+ '@react-pdf/fns': 3.1.2
+ '@react-pdf/types': 2.9.2
+ color-string: 1.9.1
+ hsl-to-hex: 1.0.0
+ media-engine: 1.0.3
+ postcss-value-parser: 4.2.0
+
+ '@react-pdf/textkit@6.1.0':
+ dependencies:
+ '@react-pdf/fns': 3.1.2
+ bidi-js: 1.0.3
+ hyphen: 1.14.1
+ unicode-properties: 1.4.1
+
+ '@react-pdf/types@2.9.2':
+ dependencies:
+ '@react-pdf/font': 4.0.4
+ '@react-pdf/primitives': 4.1.1
+ '@react-pdf/stylesheet': 6.1.2
+
'@react-stately/calendar@3.9.3(react@19.2.4)':
dependencies:
'@internationalized/date': 3.12.0
@@ -13411,6 +13912,10 @@ snapshots:
dependencies:
'@types/node': 25.5.0
+ '@types/papaparse@5.5.2':
+ dependencies:
+ '@types/node': 25.5.0
+
'@types/parse-json@4.0.2': {}
'@types/pg-pool@2.0.7':
@@ -13605,6 +14110,8 @@ snapshots:
'@xtuc/long@4.2.2': {}
+ abs-svg-path@0.1.1: {}
+
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -13741,12 +14248,18 @@ snapshots:
balanced-match@4.0.4: {}
+ base64-js@0.0.8: {}
+
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.10: {}
baseline-browser-mapping@2.10.7: {}
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
big-integer@1.6.52: {}
bin-links@6.0.0:
@@ -13805,6 +14318,14 @@ snapshots:
dependencies:
fill-range: 7.1.1
+ brotli@1.3.3:
+ dependencies:
+ base64-js: 1.5.1
+
+ browserify-zlib@0.2.0:
+ dependencies:
+ pako: 1.0.11
+
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.10.7
@@ -13924,6 +14445,8 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
+ clone@2.1.2: {}
+
clsx@2.1.1: {}
cmd-shim@8.0.0: {}
@@ -13948,6 +14471,11 @@ snapshots:
color-name@1.1.4: {}
+ color-string@1.9.1:
+ dependencies:
+ color-name: 1.1.4
+ simple-swizzle: 0.2.4
+
colord@2.9.3: {}
colorette@2.0.20: {}
@@ -14053,6 +14581,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ crypto-js@4.2.0: {}
+
css-declaration-sorter@7.3.1(postcss@8.5.8):
dependencies:
postcss: 8.5.8
@@ -14195,6 +14725,8 @@ snapshots:
optionalDependencies:
babel-plugin-macros: 3.1.0
+ deep-diff@1.0.2: {}
+
deep-extend@0.6.0: {}
deepmerge-ts@7.1.5: {}
@@ -14224,6 +14756,8 @@ snapshots:
dependencies:
dequal: 2.0.3
+ dfa@1.2.0: {}
+
diff@8.0.3: {}
direction@1.0.4: {}
@@ -14287,6 +14821,8 @@ snapshots:
emery@1.4.4: {}
+ emoji-regex-xs@1.0.0: {}
+
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -14618,6 +15154,20 @@ snapshots:
mlly: 1.8.1
rollup: 4.59.0
+ flat@5.0.2: {}
+
+ fontkit@2.0.4:
+ dependencies:
+ '@swc/helpers': 0.5.19
+ brotli: 1.3.3
+ clone: 2.1.2
+ dfa: 1.2.0
+ fast-deep-equal: 3.1.3
+ restructure: 3.0.2
+ tiny-inflate: 1.0.3
+ unicode-properties: 1.4.1
+ unicode-trie: 2.0.0
+
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
@@ -14744,6 +15294,12 @@ snapshots:
hono@4.12.9: {}
+ hsl-to-hex@1.0.0:
+ dependencies:
+ hsl-to-rgb-for-reals: 1.1.1
+
+ hsl-to-rgb-for-reals@1.1.1: {}
+
html-escaper@2.0.2: {}
html-to-text@9.0.5:
@@ -14794,6 +15350,8 @@ snapshots:
human-signals@8.0.1: {}
+ hyphen@1.14.1: {}
+
iban@0.0.14: {}
iceberg-js@0.8.1: {}
@@ -14884,6 +15442,8 @@ snapshots:
is-arrayish@0.2.1: {}
+ is-arrayish@0.3.4: {}
+
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -14944,6 +15504,8 @@ snapshots:
is-unicode-supported@2.1.0: {}
+ is-url@1.2.4: {}
+
is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
@@ -14956,6 +15518,10 @@ snapshots:
isomorphic.js@0.2.5: {}
+ jay-peg@1.1.1:
+ dependencies:
+ restructure: 3.0.2
+
jest-worker@27.5.1:
dependencies:
'@types/node': 25.5.0
@@ -15072,6 +15638,11 @@ snapshots:
lilconfig@3.1.3: {}
+ linebreak@1.1.0:
+ dependencies:
+ base64-js: 0.0.8
+ unicode-trie: 2.0.0
+
lines-and-columns@1.2.4: {}
linkify-it@5.0.0:
@@ -15295,6 +15866,8 @@ snapshots:
mdurl@2.0.0: {}
+ media-engine@1.0.3: {}
+
media-typer@1.1.0: {}
merge-descriptors@2.0.0: {}
@@ -15774,6 +16347,10 @@ snapshots:
normalize-path@3.0.0: {}
+ normalize-svg-path@1.1.0:
+ dependencies:
+ svg-arc-to-cubic-bezier: 3.2.0
+
nosecone@1.3.0: {}
npm-normalize-package-bin@5.0.0: {}
@@ -15793,6 +16370,8 @@ snapshots:
object-assign@4.1.1: {}
+ object-hash@3.0.0: {}
+
object-inspect@1.13.4: {}
object-treeify@1.1.33: {}
@@ -15909,6 +16488,8 @@ snapshots:
registry-url: 6.0.1
semver: 7.7.4
+ pako@0.2.9: {}
+
pako@1.0.11: {}
papaparse@5.5.3: {}
@@ -15938,6 +16519,8 @@ snapshots:
parse-ms@4.0.0: {}
+ parse-svg-path@0.1.2: {}
+
parseley@0.12.1:
dependencies:
leac: 0.6.0
@@ -16397,6 +16980,10 @@ snapshots:
queue-microtask@1.2.3: {}
+ queue@6.0.2:
+ dependencies:
+ inherits: 2.0.4
+
quick-format-unescaped@4.0.4: {}
range-parser@1.2.1: {}
@@ -16439,6 +17026,11 @@ snapshots:
dependencies:
react: 19.2.4
+ react-hotkeys-hook@4.6.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
react-is@16.13.1: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
@@ -16590,6 +17182,8 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
+ restructure@3.0.2: {}
+
rettime@0.10.1: {}
reusify@1.1.0: {}
@@ -16696,6 +17290,8 @@ snapshots:
dependencies:
xmlchars: 2.2.0
+ scheduler@0.25.0-rc-603e6108-20241029: {}
+
scheduler@0.27.0: {}
schema-utils@4.3.3:
@@ -16916,6 +17512,10 @@ snapshots:
signal-exit@4.1.0: {}
+ simple-swizzle@0.2.4:
+ dependencies:
+ is-arrayish: 0.3.4
+
sirv@2.0.4:
dependencies:
'@polka/url': 1.0.0-next.29
@@ -17084,6 +17684,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ svg-arc-to-cubic-bezier@3.2.0: {}
+
svgo@4.0.1:
dependencies:
commander: 11.1.0
@@ -17151,6 +17753,8 @@ snapshots:
dependencies:
real-require: 0.2.0
+ tiny-inflate@1.0.3: {}
+
tiny-invariant@1.0.6: {}
tiny-invariant@1.3.3: {}
@@ -17284,6 +17888,16 @@ snapshots:
undici-types@7.18.2: {}
+ unicode-properties@1.4.1:
+ dependencies:
+ base64-js: 1.5.1
+ unicode-trie: 2.0.0
+
+ unicode-trie@2.0.0:
+ dependencies:
+ pako: 0.2.9
+ tiny-inflate: 1.0.3
+
unicorn-magic@0.3.0: {}
unist-util-is@6.0.1:
@@ -17349,6 +17963,10 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
+ use-debounce@9.0.4(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
use-intl@4.8.3(react@19.2.4):
dependencies:
'@formatjs/fast-memoize': 3.1.0
@@ -17412,6 +18030,12 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
+ vite-compatible-readable-stream@3.6.1:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1):
dependencies:
esbuild: 0.27.4
@@ -17652,6 +18276,8 @@ snapshots:
yoctocolors@2.1.2: {}
+ yoga-layout@3.2.1: {}
+
zip-stream@4.1.1:
dependencies:
archiver-utils: 3.0.4
@@ -17670,4 +18296,11 @@ snapshots:
zod@4.3.6: {}
+ zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
+ optionalDependencies:
+ '@types/react': 19.2.14
+ immer: 11.1.4
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+
zwitch@2.0.4: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 990cbd90c..01a88d24a 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -15,11 +15,13 @@ catalog:
'@manypkg/cli': ^0.25.1
'@markdoc/markdoc': ^0.5.6
'@marsidev/react-turnstile': ^1.4.2
+ '@measured/puck': ^0.20.2
'@modelcontextprotocol/sdk': 1.28.0
'@next/bundle-analyzer': 16.2.1
'@nosecone/next': 1.3.0
'@playwright/test': ^1.58.2
'@react-email/components': 1.0.10
+ '@react-pdf/renderer': ^4.3.2
'@sentry/nextjs': 10.46.0
'@stripe/react-stripe-js': 5.6.1
'@stripe/stripe-js': 8.11.0
@@ -34,6 +36,7 @@ catalog:
'@turbo/gen': ^2.8.20
'@types/node': 25.5.0
'@types/nodemailer': 7.0.11
+ '@types/papaparse': ^5.5.2
'@types/react': 19.2.14
'@types/react-dom': 19.2.3
babel-plugin-react-compiler: 1.0.0