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:
131
apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx
Normal file
131
apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export default async function PortalProfilePage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||
);
|
||||
|
||||
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
||||
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
|
||||
|
||||
// Get current user
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) redirect(`/club/${slug}/portal`);
|
||||
|
||||
// Find member linked to this user
|
||||
const { data: member } = await supabase.from('members')
|
||||
.select('*')
|
||||
.eq('account_id', account.id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!member) {
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center">
|
||||
<Card className="max-w-md">
|
||||
<CardContent className="p-8 text-center">
|
||||
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
|
||||
<h2 className="text-lg font-bold">Kein Mitglied</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem Verein verknüpft.
|
||||
Bitte wenden Sie sich an Ihren Vereinsadministrator.
|
||||
</p>
|
||||
<Link href={`/club/${slug}/portal`}>
|
||||
<Button variant="outline" className="mt-4">← Zurück</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const m = member;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30">
|
||||
<header className="border-b bg-background px-6 py-4">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-lg font-bold">Mein Profil</h1>
|
||||
</div>
|
||||
<Link href={`/club/${slug}/portal`}><Button variant="ghost" size="sm">← Zurück zum Portal</Button></Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto py-8 px-6 space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<UserCircle className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{String(m.first_name)} {String(m.last_name)}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nr. {String(m.member_number ?? '—')} — Mitglied seit {m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2"><Mail className="h-4 w-4" />Kontaktdaten</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2"><Label>Vorname</Label><Input defaultValue={String(m.first_name)} readOnly /></div>
|
||||
<div className="space-y-2"><Label>Nachname</Label><Input defaultValue={String(m.last_name)} readOnly /></div>
|
||||
<div className="space-y-2"><Label>E-Mail</Label><Input defaultValue={String(m.email ?? '')} /></div>
|
||||
<div className="space-y-2"><Label>Telefon</Label><Input defaultValue={String(m.phone ?? '')} /></div>
|
||||
<div className="space-y-2"><Label>Mobil</Label><Input defaultValue={String(m.mobile ?? '')} /></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2"><MapPin className="h-4 w-4" />Adresse</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2"><Label>Straße</Label><Input defaultValue={String(m.street ?? '')} /></div>
|
||||
<div className="space-y-2"><Label>Hausnummer</Label><Input defaultValue={String(m.house_number ?? '')} /></div>
|
||||
<div className="space-y-2"><Label>PLZ</Label><Input defaultValue={String(m.postal_code ?? '')} /></div>
|
||||
<div className="space-y-2"><Label>Ort</Label><Input defaultValue={String(m.city ?? '')} /></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2"><Shield className="h-4 w-4" />Datenschutz-Einwilligungen</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[
|
||||
{ key: 'gdpr_newsletter', label: 'Newsletter per E-Mail', value: m.gdpr_newsletter },
|
||||
{ key: 'gdpr_internet', label: 'Veröffentlichung auf der Homepage', value: m.gdpr_internet },
|
||||
{ key: 'gdpr_print', label: 'Veröffentlichung in der Vereinszeitung', value: m.gdpr_print },
|
||||
{ key: 'gdpr_birthday_info', label: 'Geburtstagsinfo an Mitglieder', value: m.gdpr_birthday_info },
|
||||
].map(({ key, label, value }) => (
|
||||
<label key={key} className="flex items-center gap-3 text-sm">
|
||||
<input type="checkbox" defaultChecked={Boolean(value)} className="h-4 w-4 rounded border-input" />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button>Änderungen speichern</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user