feat: MyEasyCMS v2 — Full SaaS rebuild
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled

Complete rebuild of 22-year-old PHP CMS as modern SaaS:

Database (15 migrations, 42+ tables):
- Foundation: account_settings, audit_log, GDPR register, cms_files
- Module Engine: modules, fields, records, permissions, relations + RPC
- Members: 45+ field member profiles, departments, roles, honors, SEPA mandates
- Courses: courses, sessions, categories, instructors, locations, attendance
- Bookings: rooms, guests, bookings with availability
- Events: events, registrations, holiday passes
- Finance: SEPA batches/items (pain.008/001 XML), invoices
- Newsletter: campaigns, templates, recipients, subscriptions
- Site Builder: site_pages (Puck JSON), site_settings, cms_posts
- Portal Auth: member_portal_invitations, user linking

Feature Packages (9):
- @kit/module-builder — dynamic low-code CRUD engine
- @kit/member-management — 31 API methods, 21 actions, 8 components
- @kit/course-management, @kit/booking-management, @kit/event-management
- @kit/finance — SEPA XML generator + IBAN validator
- @kit/newsletter — campaigns + dispatch
- @kit/document-generator — PDF/Excel/Word
- @kit/site-builder — Puck visual editor, 15 blocks, public rendering

Pages (60+):
- Dashboard with real stats from all APIs
- Full CRUD for all 8 domains with react-hook-form + Zod
- Recharts statistics
- German i18n throughout
- Member portal with auth + invitation system
- Public club websites via Puck at /club/[slug]

Infrastructure:
- Dockerfile (multi-stage, standalone output)
- docker-compose.yml (Supabase self-hosted + Next.js)
- Kong API gateway config
- .env.production.example
This commit is contained in:
Zaid Marzguioui
2026-03-29 23:17:38 +02:00
parent 61ff48cb73
commit 1294caa7fa
120 changed files with 11013 additions and 1858 deletions

View File

@@ -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 (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-xl">
<Card>
<CardHeader><CardTitle>Neue Seite erstellen</CardTitle></CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="title" render={({ field }) => (
<FormItem>
<FormLabel>Seitentitel *</FormLabel>
<FormControl><Input placeholder="z.B. Startseite, Über uns, Kontakt" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem>
<FormLabel>URL-Pfad</FormLabel>
<FormControl><Input placeholder={autoSlug || 'wird-automatisch-generiert'} {...field} /></FormControl>
<p className="text-xs text-muted-foreground">Leer lassen für automatische Generierung aus dem Titel</p>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="metaDescription" render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung (SEO)</FormLabel>
<FormControl><Input placeholder="Kurze Beschreibung für Suchmaschinen" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="isHomepage" render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl>
<input type="checkbox" checked={field.value} onChange={field.onChange} className="h-4 w-4" />
</FormControl>
<FormLabel className="!mt-0">Als Startseite festlegen</FormLabel>
</FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Seite erstellen & Editor öffnen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -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 (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-3xl">
<Card>
<CardHeader><CardTitle>Beitrag</CardTitle></CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="title" render={({ field }) => (
<FormItem><FormLabel>Titel *</FormLabel><FormControl><Input placeholder="Vereinsnachrichten..." {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem><FormLabel>URL-Slug</FormLabel><FormControl><Input placeholder={autoSlug || 'wird-automatisch-generiert'} {...field} /></FormControl>
<p className="text-xs text-muted-foreground">Leer lassen für automatische Generierung</p><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="excerpt" render={({ field }) => (
<FormItem><FormLabel>Kurzfassung</FormLabel><FormControl><Input placeholder="Kurze Zusammenfassung..." {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="content" render={({ field }) => (
<FormItem><FormLabel>Inhalt</FormLabel><FormControl>
<textarea {...field} rows={12} className="w-full rounded-md border px-3 py-2 text-sm font-mono" placeholder="Beitragsinhalt (HTML erlaubt)..." />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="draft">Entwurf</option>
<option value="published">Veröffentlicht</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Beitrag erstellen'}</Button>
</div>
</form>
</Form>
);
}

View File

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

View File

@@ -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 (
<Card className="w-full max-w-md mx-auto">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Shield className="h-6 w-6 text-primary" />
</div>
<CardTitle>Mitgliederbereich</CardTitle>
<p className="text-sm text-muted-foreground">{accountName}</p>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<div className="space-y-2">
<Label>E-Mail-Adresse</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ihre@email.de"
required
/>
</div>
<div className="space-y-2">
<Label>Passwort</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
'Wird angemeldet...'
) : (
<>
<LogIn className="mr-2 h-4 w-4" />
Anmelden
</>
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
Zugangsdaten erhalten Sie per Einladung von Ihrem Verein.
</p>
</form>
</CardContent>
</Card>
);
}

View File

@@ -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<string, unknown>;
}
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 (
<div className="h-screen">
<PuckAny
config={clubPuckConfig}
data={initialData}
onPublish={async (data: any) => {
execPublish({ pageId, puckData: data });
}}
/>
</div>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { Render } from '@measured/puck';
import { clubPuckConfig } from '../config/puck-config';
interface Props {
data: Record<string, unknown>;
}
export function SiteRenderer({ data }: Props) {
return <Render config={clubPuckConfig} data={data as any} />;
}

View File

@@ -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<string, unknown> | 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 (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6 max-w-3xl">
<Card>
<CardHeader><CardTitle>Allgemein</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="siteName" render={({ field }) => (
<FormItem><FormLabel>Website-Name</FormLabel><FormControl><Input placeholder="Mein Verein" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="fontFamily" render={({ field }) => (
<FormItem><FormLabel>Schriftart</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="Inter">Inter</option>
<option value="system-ui">System</option>
<option value="Georgia">Georgia</option>
<option value="Roboto">Roboto</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="primaryColor" render={({ field }) => (
<FormItem><FormLabel>Primärfarbe</FormLabel><FormControl><Input type="color" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="secondaryColor" render={({ field }) => (
<FormItem><FormLabel>Sekundärfarbe</FormLabel><FormControl><Input type="color" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="contactEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactAddress" render={({ field }) => (
<FormItem className="col-span-full"><FormLabel>Adresse</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Rechtliches</CardTitle></CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="impressum" render={({ field }) => (
<FormItem><FormLabel>Impressum</FormLabel><FormControl><textarea {...field} rows={6} className="w-full rounded-md border px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="datenschutz" render={({ field }) => (
<FormItem><FormLabel>Datenschutzerklärung</FormLabel><FormControl><textarea {...field} rows={6} className="w-full rounded-md border px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Veröffentlichung</CardTitle></CardHeader>
<CardContent>
<FormField control={form.control} name="isPublic" render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl><input type="checkbox" checked={field.value} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<div>
<FormLabel>Website öffentlich zugänglich</FormLabel>
<p className="text-xs text-muted-foreground">Wenn aktiviert, ist Ihre Website unter /club/{account} erreichbar.</p>
</div>
</FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -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 }) => (
<section className="relative bg-gradient-to-br from-primary/10 to-primary/5 py-20 px-6 text-center">
<h1 className="text-4xl font-bold md:text-5xl">{title || 'Willkommen'}</h1>
{subtitle && <p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">{subtitle}</p>}
{buttonText && (
<a href={buttonLink || '#'} className="mt-8 inline-block rounded-md bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90">
{buttonText}
</a>
)}
</section>
);
const TextBlock = ({ content }: { content: string }) => (
<section className="py-12 px-6 max-w-3xl mx-auto prose prose-neutral dark:prose-invert" dangerouslySetInnerHTML={{ __html: content || '<p>Text eingeben...</p>' }} />
);
const ContactFormBlock = ({ title, description, recipientEmail }: { title: string; description: string; recipientEmail: string }) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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 (
<section className="py-12 px-6 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-2">{title || 'Kontakt'}</h2>
{description && <p className="text-muted-foreground mb-6">{description}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<input name="name" placeholder="Name" className="w-full rounded-md border px-3 py-2 text-sm" required />
<input name="email" placeholder="E-Mail" type="email" className="w-full rounded-md border px-3 py-2 text-sm" required />
<textarea name="message" placeholder="Nachricht" rows={4} className="w-full rounded-md border px-3 py-2 text-sm" required />
<button type="submit" className="rounded-md bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90">Senden</button>
</form>
</section>
);
};
const MapBlock = ({ latitude, longitude, zoom, height }: { latitude: number; longitude: number; zoom: number; height: number }) => (
<section className="py-6 px-6">
<iframe
src={`https://www.openstreetmap.org/export/embed.html?bbox=${longitude-0.01},${latitude-0.01},${longitude+0.01},${latitude+0.01}&layer=mapnik&marker=${latitude},${longitude}`}
style={{ width: '100%', height: height || 400, border: 0, borderRadius: '0.5rem' }}
title="Karte"
/>
</section>
);
const ImageGalleryBlock = ({ images, columns }: { images: Array<{ url: string; alt: string }>; columns: number }) => (
<section className="py-12 px-6">
<div className={`grid gap-4 grid-cols-1 sm:grid-cols-${columns || 3}`} style={{ gridTemplateColumns: `repeat(${columns || 3}, 1fr)` }}>
{(images || []).map((img, i) => (
<img key={i} src={img.url} alt={img.alt || ''} className="rounded-lg object-cover w-full aspect-square" />
))}
</div>
</section>
);
const DividerBlock = ({ style, spacing }: { style: string; spacing: string }) => {
const py = spacing === 'lg' ? 'py-12' : spacing === 'sm' ? 'py-3' : 'py-6';
return (
<div className={`${py} px-6`}>
{style === 'dots' ? (
<div className="flex justify-center gap-2">{[0,1,2].map(i => <span key={i} className="h-2 w-2 rounded-full bg-muted-foreground/30" />)}</div>
) : style === 'space' ? null : (
<hr className="border-border" />
)}
</div>
);
};
const NewsletterSignupBlock = ({ title, description, accountId }: { title: string; description: string; accountId?: string }) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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 (
<section className="py-12 px-6 bg-muted/50">
<div className="max-w-md mx-auto text-center">
<h2 className="text-2xl font-bold">{title || 'Newsletter'}</h2>
{description && <p className="mt-2 text-muted-foreground">{description}</p>}
<form onSubmit={handleSubmit} className="mt-6 flex gap-2">
<input name="email" placeholder="Ihre E-Mail-Adresse" type="email" required className="flex-1 rounded-md border px-3 py-2 text-sm" />
<button type="submit" className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90">Anmelden</button>
</form>
</div>
</section>
);
};
const DownloadBlock = ({ title, files }: { title: string; files: Array<{ label: string; url: string }> }) => (
<section className="py-12 px-6 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-4">{title || 'Downloads'}</h2>
<ul className="space-y-2">
{(files || []).map((file, i) => (
<li key={i}>
<a href={file.url} download className="flex items-center gap-2 rounded-md border p-3 hover:bg-muted/50 text-sm">
📄 {file.label}
</a>
</li>
))}
</ul>
</section>
);
const FooterBlock = ({ text, email, phone }: { text: string; email: string; phone: string }) => (
<footer className="bg-muted py-8 px-6 text-center text-sm text-muted-foreground">
{text && <p>{text}</p>}
<div className="mt-2 flex justify-center gap-4">
{email && <a href={`mailto:${email}`}>{email}</a>}
{phone && <a href={`tel:${phone}`}>{phone}</a>}
</div>
</footer>
);
const MemberLoginBlock = ({ title, description }: { title: string; description: string }) => (
<section className="py-12 px-6 text-center">
<h2 className="text-2xl font-bold">{title || 'Mitgliederbereich'}</h2>
{description && <p className="mt-2 text-muted-foreground">{description}</p>}
<a href="portal" className="mt-6 inline-block rounded-md bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground">Zum Mitgliederbereich </a>
</section>
);
const NewsFeedBlock = ({ count, showImage }: { count: number; showImage: boolean }) => (
<section className="py-12 px-6 max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Neuigkeiten</h2>
<div className="space-y-4">
{Array.from({ length: count || 3 }, (_, i) => (
<div key={i} className="rounded-lg border p-4 hover:bg-muted/30 transition-colors">
<div className="flex gap-4">
{showImage && <div className="h-20 w-20 shrink-0 rounded bg-muted" />}
<div>
<h3 className="font-semibold">Beitragstitel {i + 1}</h3>
<p className="text-sm text-muted-foreground mt-1">Kurzbeschreibung des Beitrags...</p>
<p className="text-xs text-muted-foreground mt-2">01.01.2026</p>
</div>
</div>
</div>
))}
</div>
</section>
);
const EventListBlock = ({ count, showPastEvents }: { count: number; showPastEvents: boolean }) => (
<section className="py-12 px-6 max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Veranstaltungen</h2>
<div className="space-y-3">
{Array.from({ length: count || 3 }, (_, i) => (
<div key={i} className="flex items-center gap-4 rounded-lg border p-4">
<div className="flex h-14 w-14 shrink-0 flex-col items-center justify-center rounded-lg bg-primary/10 text-primary">
<span className="text-lg font-bold">{15 + i}</span>
<span className="text-xs">Apr</span>
</div>
<div>
<h3 className="font-semibold">Veranstaltung {i + 1}</h3>
<p className="text-xs text-muted-foreground">10:00 Vereinsheim</p>
</div>
</div>
))}
</div>
</section>
);
const CourseCatalogBlock = ({ count, showPrice }: { count: number; showPrice: boolean }) => (
<section className="py-12 px-6 max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Kursangebot</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{Array.from({ length: count || 4 }, (_, i) => (
<div key={i} className="rounded-lg border p-4">
<h3 className="font-semibold">Kurs {i + 1}</h3>
<p className="text-sm text-muted-foreground mt-1">Mo, 18:00 20:00</p>
<div className="mt-3 flex items-center justify-between">
{showPrice && <span className="text-sm font-semibold text-primary">49,00 </span>}
<span className="text-xs text-muted-foreground">5/15 Plätze</span>
</div>
</div>
))}
</div>
</section>
);
const CardShopBlock = ({ title, description }: { title: string; description: string }) => (
<section className="py-12 px-6 max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-2">{title || 'Mitgliedschaft'}</h2>
{description && <p className="text-muted-foreground mb-6">{description}</p>}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{['Basis', 'Standard', 'Familie'].map((name, i) => (
<div key={name} className="rounded-lg border p-6 text-center hover:border-primary transition-colors">
<h3 className="text-lg font-bold">{name}</h3>
<p className="text-3xl font-bold text-primary mt-2">{[5, 10, 18][i]} </p>
<p className="text-xs text-muted-foreground">pro Monat</p>
<button className="mt-4 w-full rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground">Auswählen</button>
</div>
))}
</div>
</section>
);
const ColumnsBlock = ({ columns }: { columns: number }) => (
<section className="py-8 px-6">
<div className="grid gap-6" style={{ gridTemplateColumns: `repeat(${columns || 2}, 1fr)` }}>
{Array.from({ length: columns || 2 }, (_, i) => (
<div key={i} className="min-h-[100px] rounded-lg border-2 border-dashed border-muted-foreground/20 p-4 flex items-center justify-center text-sm text-muted-foreground">
Spalte {i + 1}
</div>
))}
</div>
</section>
);
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: '<p>Hier steht Ihr Text...</p>' },
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,
},
},
};

View File

@@ -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<typeof CreatePageSchema>;
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(),
});

View File

@@ -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 };
});

View File

@@ -0,0 +1,136 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createSiteBuilderApi(client: SupabaseClient<Database>) {
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<string, unknown>; 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<string, unknown>; isPublished?: boolean; isHomepage?: boolean; metaDescription?: string; metaImage?: string }, userId: string) {
const update: Record<string, unknown> = { 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<string, unknown>) {
const row: Record<string, unknown> = { 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<string, unknown> = {};
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;
},
};
}