feat: MyEasyCMS v2 — Full SaaS rebuild
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
6
packages/features/site-builder/src/components/index.ts
Normal file
6
packages/features/site-builder/src/components/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
360
packages/features/site-builder/src/config/puck-config.tsx
Normal file
360
packages/features/site-builder/src/config/puck-config.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
66
packages/features/site-builder/src/schema/site.schema.ts
Normal file
66
packages/features/site-builder/src/schema/site.schema.ts
Normal 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(),
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
136
packages/features/site-builder/src/server/api.ts
Normal file
136
packages/features/site-builder/src/server/api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user