push
This commit is contained in:
@@ -1,81 +1,426 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useActionState, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createOrganization } from './actions'
|
||||
import { LandingPagePreview } from './LandingPagePreview'
|
||||
|
||||
const initialState = {
|
||||
success: false,
|
||||
error: '',
|
||||
}
|
||||
const initialState = { success: false, error: '' }
|
||||
|
||||
export function CreateOrgForm() {
|
||||
const [state, formAction, isPending] = useActionState(createOrganization, initialState)
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(1)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
contactEmail: '',
|
||||
adminEmail: '',
|
||||
adminPassword: '',
|
||||
logoUrl: '',
|
||||
plan: 'pilot',
|
||||
primaryColor: '#E63946',
|
||||
secondaryColor: '',
|
||||
landingPageTitle: '',
|
||||
landingPageText: '',
|
||||
landingPageHeroImage: '',
|
||||
landingPageHeroOverlayOpacity: 50,
|
||||
landingPageFeatures: '',
|
||||
landingPageFooter: '',
|
||||
appStoreUrl: '',
|
||||
playStoreUrl: ''
|
||||
})
|
||||
|
||||
const [aiContext, setAiContext] = useState('')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const handleGenerateContent = async () => {
|
||||
if (!formData.name || !aiContext) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/generate-landing-page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orgName: formData.name, context: aiContext })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.title && data.text) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
landingPageTitle: data.title,
|
||||
landingPageText: data.text
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI generation failed', err)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsUploading(true)
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setFormData(prev => ({ ...prev, logoUrl: data.url }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [isHeroUploading, setIsHeroUploading] = useState(false)
|
||||
|
||||
const handleHeroUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsHeroUploading(true)
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setFormData(prev => ({ ...prev, landingPageHeroImage: data.url }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsHeroUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
const nextStep = () => setStep(prev => prev + 1)
|
||||
const prevStep = () => setStep(prev => prev - 1)
|
||||
|
||||
// Reset wizard after success
|
||||
if (state.success && step !== 5) {
|
||||
setStep(5)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-xl border shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Neue Innung anlegen</h2>
|
||||
<div className="flex w-full h-full gap-6">
|
||||
<div className="flex-[3] bg-gray-100 rounded-3xl overflow-hidden relative shadow-inner border border-gray-200 hidden lg:block">
|
||||
<LandingPagePreview formData={formData} />
|
||||
</div>
|
||||
<div className="flex-1 bg-white p-6 sm:p-8 rounded-3xl border shadow-sm overflow-y-auto min-w-[320px] max-w-lg w-full flex flex-col">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 font-outfit shrink-0">Neue Innung anlegen</h2>
|
||||
|
||||
{state.success && (
|
||||
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">
|
||||
Innung wurde erfolgreich angelegt!
|
||||
</div>
|
||||
)}
|
||||
{state.error && (
|
||||
<div className="mb-6 p-4 bg-red-50 text-red-700 rounded-xl text-sm border border-red-100 animate-in fade-in slide-in-from-top-2 shrink-0">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name der Innung
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
placeholder="z.B. Tischler-Innung Berlin"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
{/* Stepper Header (matched to screenshot) */}
|
||||
<div className="flex items-center justify-start gap-2 sm:gap-4 mb-8 shrink-0 overflow-x-auto pb-2">
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<div key={s} className="flex items-center gap-2 sm:gap-4 shrink-0">
|
||||
<div className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all duration-300 ${step >= s ? 'bg-[#E63946] text-white' : 'bg-gray-100 text-gray-400'}`}>
|
||||
{s}
|
||||
</div>
|
||||
{s < 5 && (
|
||||
<div className={`h-[3px] w-8 sm:w-12 rounded-full transition-all duration-500 ${step > s ? 'bg-[#E63946]' : 'bg-gray-100'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kurzbezeichnung (Slug)
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Für interne Zuordnung (nur Kleinbuchstaben, ohne Leerzeichen).</p>
|
||||
<input
|
||||
type="text"
|
||||
name="slug"
|
||||
required
|
||||
placeholder="tischler-berlin"
|
||||
pattern="^[a-z0-9-]+$"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<form action={formAction} className="flex-1 shrink-0 space-y-6">
|
||||
{step !== 1 && (
|
||||
<>
|
||||
<input type="hidden" name="name" value={formData.name} />
|
||||
<input type="hidden" name="slug" value={formData.slug} />
|
||||
</>
|
||||
)}
|
||||
<input type="hidden" name="contactEmail" value={formData.contactEmail} />
|
||||
<input type="hidden" name="adminEmail" value={formData.adminEmail} />
|
||||
<input type="hidden" name="adminPassword" value={formData.adminPassword} />
|
||||
<input type="hidden" name="logoUrl" value={formData.logoUrl} />
|
||||
<input type="hidden" name="plan" value={formData.plan} />
|
||||
<input type="hidden" name="primaryColor" value={formData.primaryColor} />
|
||||
<input type="hidden" name="secondaryColor" value={formData.secondaryColor} />
|
||||
<input type="hidden" name="landingPageTitle" value={formData.landingPageTitle} />
|
||||
<input type="hidden" name="landingPageText" value={formData.landingPageText} />
|
||||
<input type="hidden" name="landingPageHeroImage" value={formData.landingPageHeroImage} />
|
||||
<input type="hidden" name="landingPageFeatures" value={formData.landingPageFeatures} />
|
||||
<input type="hidden" name="landingPageFooter" value={formData.landingPageFooter} />
|
||||
<input type="hidden" name="appStoreUrl" value={formData.appStoreUrl} />
|
||||
<input type="hidden" name="playStoreUrl" value={formData.playStoreUrl} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kontakt E-Mail (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="contactEmail"
|
||||
placeholder="info@tischler-berlin.de"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
{step === 1 && (
|
||||
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Name der Innung</label>
|
||||
<input type="text" name="name" required value={formData.name} onChange={handleChange} placeholder="z.B. Tischler-Innung Berlin" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Kurzbezeichnung (Slug)</label>
|
||||
<input type="text" name="slug" required value={formData.slug} onChange={handleChange} placeholder="z.B. tischler-berlin" pattern="^[a-z0-9\-]+$" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
|
||||
<p className="text-[11px] text-gray-400 mt-2 leading-relaxed">Landingpage unter: <span className="text-[#E63946] font-medium">{formData.slug ? `${formData.slug}.localhost:3032` : 'ihr-slug.localhost:3032'}</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Planungs-Modell</label>
|
||||
<select name="plan" value={formData.plan} onChange={handleChange} className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all bg-white">
|
||||
<option value="pilot">Pilot</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="verband">Verband</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" onClick={nextStep} disabled={!formData.name || !formData.slug} className="w-full bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98] disabled:opacity-50 disabled:scale-100">
|
||||
Weiter zu Branding
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full bg-brand-500 text-white font-medium py-2 px-4 rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Wird angelegt...' : 'Innung anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
{step === 2 && (
|
||||
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Initialer Admin (Email)</label>
|
||||
<input type="email" name="adminEmail" value={formData.adminEmail} onChange={handleChange} placeholder="admin@tischler-berlin.de" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Passwort setzen</label>
|
||||
<input type="text" name="adminPassword" value={formData.adminPassword} onChange={handleChange} placeholder="Sicheres Passwort" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Organisations-Logo</label>
|
||||
<div className="flex items-center gap-4">
|
||||
{formData.logoUrl ? (
|
||||
<div className="w-14 h-14 rounded-xl border border-gray-200 overflow-hidden bg-gray-50 flex items-center justify-center p-2">
|
||||
<img src={formData.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex-1">
|
||||
<div className={`px-4 py-3 border border-gray-200 rounded-xl w-full text-center text-sm font-semibold cursor-pointer transition-all hover:bg-gray-50 ${isUploading ? 'opacity-50' : ''}`}>
|
||||
{isUploading ? 'Wird hochgeladen...' : formData.logoUrl ? 'Logo ändern' : 'Bild auswählen'}
|
||||
</div>
|
||||
<input type="file" onChange={handleUpload} accept="image/*" className="hidden" disabled={isUploading} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Primärfarbe (CI)</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<input type="color" name="primaryColor" value={formData.primaryColor} onChange={handleChange} className="w-14 h-14 p-1 rounded-xl cursor-pointer border border-gray-200" />
|
||||
<div className="flex-1">
|
||||
<input type="text" value={formData.primaryColor?.toUpperCase()} readOnly className="px-4 py-3 border border-gray-200 rounded-xl w-full bg-gray-50 text-gray-500 font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all">
|
||||
Zurück
|
||||
</button>
|
||||
<button type="button" onClick={nextStep} disabled={!formData.adminEmail || !formData.adminPassword} className="flex-[2] bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98] disabled:opacity-50">
|
||||
Weiter zur Landingpage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div className="bg-blue-50/50 p-5 rounded-xl border border-blue-100">
|
||||
<h3 className="text-sm font-bold text-blue-900 mb-2 font-outfit">KI Content-Erstellung</h3>
|
||||
<p className="text-xs text-blue-700 leading-relaxed mb-4">
|
||||
Beschreiben Sie in wenigen Stichpunkten, worauf die Innung fokussiert ist (Region, Tradition, Ausbildung, etc.). Die KI generiert daraus eine moderne Landingpage.
|
||||
</p>
|
||||
<textarea
|
||||
value={aiContext}
|
||||
onChange={(e) => setAiContext(e.target.value)}
|
||||
placeholder="z.B. Kreishandwerkerschaft Niederrhein, Fokus auf Ausbildung und Digitalisierung im Handwerk..."
|
||||
className="w-full px-4 py-3 border border-blue-200 bg-white rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all placeholder:text-gray-400 text-sm min-h-[80px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateContent}
|
||||
disabled={isGenerating || !aiContext}
|
||||
className="mt-3 w-full bg-blue-600 text-white font-semibold py-2.5 px-6 rounded-lg hover:bg-blue-700 transition-all shadow-sm disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Generieren...
|
||||
</>
|
||||
) : '✨ Content generieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Überschrift</label>
|
||||
<input type="text" name="landingPageTitle" value={formData.landingPageTitle} onChange={handleChange} placeholder="Zukunft durch Handwerk" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 font-bold" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Einleitungstext</label>
|
||||
<textarea name="landingPageText" value={formData.landingPageText} onChange={(e) => setFormData(prev => ({ ...prev, landingPageText: e.target.value }))} placeholder="Wir sind..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[100px] text-sm leading-relaxed" />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all">
|
||||
Zurück
|
||||
</button>
|
||||
<button type="button" onClick={nextStep} className="flex-[2] bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98]">
|
||||
Weiter zu Erweitert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Hero-Titelbild</label>
|
||||
<div className="flex items-center gap-4">
|
||||
{formData.landingPageHeroImage ? (
|
||||
<div className="w-24 h-14 rounded-xl border border-gray-200 overflow-hidden bg-gray-50 flex items-center justify-center p-0">
|
||||
<img src={formData.landingPageHeroImage} alt="Hero" className="max-w-full max-h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 h-14 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex-1">
|
||||
<div className={`px-4 py-3 border border-gray-200 rounded-xl w-full text-center text-sm font-semibold cursor-pointer transition-all hover:bg-gray-50 ${isHeroUploading ? 'opacity-50' : ''}`}>
|
||||
{isHeroUploading ? 'Wird hochgeladen...' : formData.landingPageHeroImage ? 'Bild ändern' : 'Bild auswählen'}
|
||||
</div>
|
||||
<input type="file" onChange={handleHeroUpload} accept="image/*" className="hidden" disabled={isHeroUploading} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.landingPageHeroImage && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
|
||||
Hero-Deckkraft (Opacity: {formData.landingPageHeroOverlayOpacity}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
name="landingPageHeroOverlayOpacity"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.landingPageHeroOverlayOpacity}
|
||||
onChange={handleChange}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#E63946]"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Bestimmt, wie stark das Bild abgedunkelt/aufgehellt wird, um den Text lesbar zu machen.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Sekundärfarbe (Optional)</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<input type="color" name="secondaryColor" value={formData.secondaryColor || '#ffffff'} onChange={handleChange} className="w-14 h-14 p-1 rounded-xl cursor-pointer border border-gray-200" />
|
||||
<div className="flex-1">
|
||||
<input type="text" name="secondaryColor" value={formData.secondaryColor?.toUpperCase()} onChange={handleChange} placeholder="#FFFFFF" className="px-4 py-3 border border-gray-200 rounded-xl w-full bg-white text-gray-700 font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Vorteile / Features</label>
|
||||
<textarea name="landingPageFeatures" value={formData.landingPageFeatures} onChange={handleChange} placeholder="Ein Benefit pro Zeile..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[120px] text-sm leading-relaxed" />
|
||||
<p className="text-xs text-gray-400 mt-2">Bitte geben Sie pro Zeile einen Vorteil ein. Diese werden als Checkliste auf der Landingpage angezeigt.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">App Store URL</label>
|
||||
<input type="url" name="appStoreUrl" value={formData.appStoreUrl} onChange={handleChange} placeholder="https://apps.apple.com/..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Google Play URL</label>
|
||||
<input type="url" name="playStoreUrl" value={formData.playStoreUrl} onChange={handleChange} placeholder="https://play.google.com/..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Footer-Text (Impressum, etc.)</label>
|
||||
<textarea name="landingPageFooter" value={formData.landingPageFooter} onChange={handleChange} placeholder="Zusätzliche Infos für den Footer..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[80px] text-sm leading-relaxed" />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all">
|
||||
Zurück
|
||||
</button>
|
||||
<button type="submit" disabled={isPending} className="flex-[2] bg-[#E63946] text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-[#D62839] transition-all shadow-md shadow-red-100 active:scale-[0.98] disabled:opacity-50 flex justify-center items-center gap-2">
|
||||
{isPending ? 'Wird erstellt...' : 'Innung anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<div className="text-center animate-in fade-in zoom-in-95 duration-700 py-4">
|
||||
<div className="w-24 h-24 bg-[#E8F5E9] text-[#2E7D32] rounded-full flex items-center justify-center mx-auto mb-8 animate-in zoom-in-50 duration-500 delay-150">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-10 h-10">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">Innung erfolgreich angelegt!</h3>
|
||||
<p className="text-gray-500 text-sm mb-10">Die Datenumgebung sowie die Subdomain<br />wurden eingerichtet.</p>
|
||||
|
||||
<div className="bg-[#F8FEFB] p-6 rounded-2xl border border-[#E1F5EA] text-left mb-8">
|
||||
<p className="text-[10px] font-bold text-[#8CAB99] uppercase tracking-[0.15em] mb-4">Ihre neue Landingpage (Localhost) / Subdomain</p>
|
||||
<a href={`http://${formData.slug}.localhost:3032`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all">
|
||||
{formData.slug}.localhost:3032
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => {
|
||||
router.push('/superadmin')
|
||||
}} className="w-full bg-[#F3F4F6] text-[#4B5563] font-bold py-4 px-6 rounded-2xl hover:bg-gray-200 transition-all active:scale-[0.98]">
|
||||
Zurück zur Übersicht
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
355
innungsapp/apps/admin/app/superadmin/LandingPagePreview.tsx
Normal file
355
innungsapp/apps/admin/app/superadmin/LandingPagePreview.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
export function LandingPagePreview({ formData }: { formData: any }) {
|
||||
const primaryColor = formData.primaryColor || '#E63946'
|
||||
const secondaryColor = formData.secondaryColor || undefined
|
||||
const title = formData.landingPageTitle || formData.name || 'Zukunft durch Handwerk'
|
||||
const text = formData.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.'
|
||||
const features = formData.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
|
||||
const footer = formData.landingPageFooter || '© 2024 Innung'
|
||||
const sectionTitle = formData.landingPageSectionTitle || `${formData.name || 'Ihre Innung'} – Gemeinsam stark fürs Handwerk`
|
||||
const buttonText = formData.landingPageButtonText || 'Jetzt App laden'
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-white overflow-y-auto font-sans flex flex-col relative">
|
||||
{/* Header */}
|
||||
<header className="px-8 py-6 flex items-center justify-between sticky top-0 z-50 shadow-sm" style={{
|
||||
background: `linear-gradient(to right, #ffffff 0%, ${primaryColor}20 50%, ${primaryColor} 100%)`
|
||||
}}>
|
||||
<div className="flex items-center gap-4">
|
||||
{formData.logoUrl ? (
|
||||
<img src={formData.logoUrl} alt="Logo" className="h-10 object-contain" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-xs font-bold text-gray-400 shadow-sm">LOGO</div>
|
||||
)}
|
||||
<span className="font-bold text-lg text-gray-800">{formData.name || 'Innungs-Logo'}</span>
|
||||
</div>
|
||||
<nav className="flex gap-6 text-sm font-medium text-gray-800 hidden md:flex">
|
||||
<a href="#about" className="hover:text-black">Über uns</a>
|
||||
<a href="#leistungen" className="hover:text-black">Leistungen</a>
|
||||
<a href="#app" className="hover:text-black">App</a>
|
||||
</nav>
|
||||
<a
|
||||
href="#mitglied-werden"
|
||||
className="px-5 py-2.5 rounded-full bg-white font-semibold text-sm cursor-pointer shadow-md hover:bg-gray-50 transition-all"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
Mitglieder verwalten
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section id="about" className="relative px-8 py-20 flex flex-col items-center justify-center text-center overflow-hidden min-h-[400px]">
|
||||
{/* Background Image / Pattern */}
|
||||
{formData.landingPageHeroImage ? (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img src={formData.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" />
|
||||
<div
|
||||
className="absolute inset-0 bg-white"
|
||||
style={{ opacity: formData.landingPageHeroOverlayOpacity !== undefined ? formData.landingPageHeroOverlayOpacity / 100 : 0.5 }}
|
||||
></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 z-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '24px 24px' }}></div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 max-w-3xl mx-auto space-y-6">
|
||||
<div className="inline-block px-4 py-1.5 rounded-full text-xs font-bold tracking-wider uppercase mb-2 shadow-sm" style={{ backgroundColor: `${primaryColor}15`, color: primaryColor }}>
|
||||
{formData.name || 'Ihre Innung'}
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium">
|
||||
{text}
|
||||
</p>
|
||||
<div className="pt-6 flex gap-4 justify-center">
|
||||
<a
|
||||
href="#apps"
|
||||
className="px-8 py-3.5 rounded-full text-white font-semibold shadow-lg hover:opacity-90 transition-all cursor-pointer transform hover:-translate-y-0.5 block"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{buttonText}
|
||||
</a>
|
||||
<a
|
||||
href="#leistungen"
|
||||
className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderColor: secondaryColor || '#e5e7eb',
|
||||
color: secondaryColor || '#374151'
|
||||
}}
|
||||
>
|
||||
Mehr erfahren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features / Benefits */}
|
||||
<section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-center mb-12 text-gray-800">Ihre Vorteile als Mitglied</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{features.split('\n').filter((f: string) => f.trim() !== '').map((feature: string, idx: number) => (
|
||||
<div key={idx} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center text-center space-y-4 hover:shadow-md transition-shadow">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: secondaryColor ? `${secondaryColor}15` : `${primaryColor}15`, color: secondaryColor || primaryColor }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* App Features Grid */}
|
||||
<section id="app" className="px-8 py-20 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16 space-y-4">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-semibold mb-2" style={{ backgroundColor: `${primaryColor}10`, color: primaryColor }}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
Alles in einer App
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-black text-gray-900">{sectionTitle}</h2>
|
||||
<p className="text-lg text-gray-500 max-w-2xl mx-auto">
|
||||
Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Feature 1: Aktuelles */}
|
||||
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
|
||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 2: Termine */}
|
||||
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
|
||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 3: Stellen */}
|
||||
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
|
||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 4: Nachrichten */}
|
||||
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
|
||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 5: Profil */}
|
||||
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
|
||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 6: Partner */}
|
||||
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
|
||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Application Mock */}
|
||||
<section id="apps" className="px-8 py-32 relative overflow-hidden" style={{
|
||||
background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)`
|
||||
}}>
|
||||
|
||||
{/* Decorative background elements */}
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}></div>
|
||||
<div className="absolute top-0 right-0 -mr-40 -mt-40 w-[500px] h-[500px] rounded-full bg-white/20 blur-[100px] pointer-events-none"></div>
|
||||
<div className="absolute bottom-0 left-0 -ml-40 -mb-40 w-[500px] h-[500px] rounded-full border-[40px] border-white/5 pointer-events-none"></div>
|
||||
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-16 relative z-10">
|
||||
<div className="flex-1 text-left space-y-8 text-white">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-sm font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
|
||||
Jetzt verfügbar
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black leading-tight">
|
||||
Laden Sie unsere App herunter
|
||||
</h2>
|
||||
<p className="text-white/80 text-xl leading-relaxed max-w-lg">
|
||||
Bleiben Sie immer auf dem Laufenden mit der {formData.name || 'Innungs'}-App für Mitglieder. Alle News, Termine und Ihr digitaler Mitgliedsausweis direkt auf Ihrem Smartphone.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
{(!formData.appStoreUrl && !formData.playStoreUrl) || formData.appStoreUrl ? (
|
||||
<a href={formData.appStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" className="w-8 h-8 fill-current"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" /></svg>
|
||||
<div>
|
||||
<div className="text-xs text-white/70">Download on the</div>
|
||||
<div className="text-lg font-semibold leading-none">App Store</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
{(!formData.appStoreUrl && !formData.playStoreUrl) || formData.playStoreUrl ? (
|
||||
<a href={formData.playStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-8 h-8 fill-current"><path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l256.6-256L47 0zm425.2 225.6l-58.9-34.1-65.7 64.5 65.7 64.5 60.1-34.1c18-14.3 18-46.5-1.2-60.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z" /></svg>
|
||||
<div>
|
||||
<div className="text-xs text-white/70">GET IT ON</div>
|
||||
<div className="text-lg font-semibold leading-none">Google Play</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full flex justify-center mt-12 md:mt-0 perspective-[2000px]">
|
||||
<div className="relative w-[280px] h-[580px] rounded-[3rem] border-[12px] border-black bg-black shadow-2xl overflow-hidden transform rotate-y-[-15deg] rotate-x-[10deg] rotate-z-[5deg] hover:rotate-y-[0deg] hover:rotate-x-[0deg] hover:rotate-z-[0deg] transition-all duration-700 ease-out">
|
||||
{/* Notch */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-6 bg-black rounded-b-3xl z-20"></div>
|
||||
|
||||
{/* App Screenshot Mockup */}
|
||||
<div className="w-full h-full bg-gray-50 flex flex-col pt-6">
|
||||
{/* App Header */}
|
||||
<div className="px-5 py-4 flex items-center justify-between bg-white border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
{formData.logoUrl ? (
|
||||
<img src={formData.logoUrl} alt="Logo" className="w-8 h-8 object-contain" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-xs shadow-sm" style={{ backgroundColor: primaryColor }}>
|
||||
{formData.name ? formData.name.charAt(0).toUpperCase() : 'I'}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-bold text-sm text-gray-800 truncate w-28">{formData.name || 'Ihre Innung'}</div>
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
{/* App Content */}
|
||||
<div className="p-5 space-y-6 flex-1 overflow-hidden">
|
||||
<div className="w-full h-32 rounded-2xl relative overflow-hidden flex items-end p-4 shadow-sm" style={{ backgroundColor: primaryColor }}>
|
||||
<div className="absolute inset-0 bg-black/10"></div>
|
||||
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div>
|
||||
<div className="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-bold text-gray-800">Aktuelle News</div>
|
||||
<div className="text-xs text-gray-400 font-medium">Alle ansehen</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
|
||||
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-5/6 bg-gray-200 rounded-full"></div>
|
||||
<div className="h-2 w-full bg-gray-100 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
|
||||
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-2/3 bg-gray-200 rounded-full"></div>
|
||||
<div className="h-2 w-4/5 bg-gray-100 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* App Bottom Nav */}
|
||||
<div className="h-[72px] bg-white border-t border-gray-100 flex items-center justify-between px-4 pb-2 pt-2 shadow-[0_-4px_20px_rgba(0,0,0,0.03)] z-20">
|
||||
<div className="flex flex-col items-center gap-1 w-1/6">
|
||||
<svg className="w-5 h-5" style={{ color: primaryColor }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
|
||||
<span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
|
||||
<span className="text-[9px] font-medium">Aktuelles</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span className="text-[9px] font-medium">Termine</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||
<span className="text-[9px] font-medium">Stellen</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
|
||||
<span className="text-[9px] font-medium">Nachricht..</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
||||
<span className="text-[9px] font-medium">Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section id="mitglied-werden" className="px-8 py-24 bg-gray-50 text-center relative z-20">
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">Werden Sie jetzt Teil der Gemeinschaft</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App.
|
||||
</p>
|
||||
<a
|
||||
href="#apps"
|
||||
className="inline-block px-10 py-4 rounded-full text-white font-bold text-lg shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
Jetzt Mitglied werden
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-gray-400 py-12 px-8 text-center text-sm">
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
<div className="text-gray-300 font-bold text-lg mb-6">{formData.name || 'Innungs-Logo'}</div>
|
||||
<div className="whitespace-pre-wrap">{footer}</div>
|
||||
<div className="pt-8 border-t border-gray-800 flex justify-center gap-6">
|
||||
<a href="#" className="hover:text-white transition-colors">Impressum</a>
|
||||
<a href="#" className="hover:text-white transition-colors">Datenschutz</a>
|
||||
<a href="#" className="hover:text-white transition-colors">Kontakt</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +1,482 @@
|
||||
'use server'
|
||||
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { headers } from 'next/headers'
|
||||
import { z } from 'zod'
|
||||
import { sendAdminCredentialsEmail } from '@/lib/email'
|
||||
// @ts-ignore
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
function normalizeEmail(email: string | null | undefined): string {
|
||||
return (email ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a credential (email+password) account for a user.
|
||||
* Tries auth.api.updateUser first (uses better-auth's internal hashing, guaranteed compatible).
|
||||
* Falls back to direct DB write if that fails.
|
||||
*/
|
||||
async function setCredentialPassword(userId: string, password: string) {
|
||||
// Primary: use better-auth's own API to ensure correct hash format
|
||||
try {
|
||||
const authHeaders = await getSanitizedHeaders()
|
||||
await auth.api.updateUser({
|
||||
body: { userId, password },
|
||||
headers: authHeaders,
|
||||
})
|
||||
return
|
||||
} catch (e) {
|
||||
console.error('[setCredentialPassword] auth.api.updateUser failed, falling back to direct write:', e)
|
||||
}
|
||||
|
||||
// Fallback: write directly with better-auth compatible hash
|
||||
const hashedPassword = await hashPassword(password)
|
||||
const updated = await prisma.account.updateMany({
|
||||
where: { userId, providerId: 'credential' },
|
||||
data: { password: hashedPassword, accountId: userId },
|
||||
})
|
||||
if (updated.count === 0) {
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
accountId: userId,
|
||||
providerId: 'credential',
|
||||
password: hashedPassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function requireSuperAdmin() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||
|
||||
// An admin is either specifically the superadmin email OR has the 'admin' role from better-auth admin plugin
|
||||
const isSuperAdmin = session?.user && (
|
||||
session.user.email === superAdminEmail ||
|
||||
(session.user as any).role === 'admin'
|
||||
)
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
const createOrgSchema = z.object({
|
||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||
slug: z.string().min(2, 'Slug muss mindestens 2 Zeichen lang sein').regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'),
|
||||
contactEmail: z.string().email('Ungültige E-Mail Adresse').optional().or(z.literal('')),
|
||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2, 'Slug muss mindestens 2 Zeichen lang sein')
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'),
|
||||
contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')),
|
||||
adminEmail: z.string().email('Ungueltige Admin E-Mail').optional().or(z.literal('')),
|
||||
adminPassword: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein').optional().or(z.literal('')),
|
||||
logoUrl: z.string().optional().nullable(),
|
||||
plan: z.enum(['pilot', 'standard', 'pro', 'verband']).default('pilot'),
|
||||
primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
|
||||
secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
|
||||
landingPageTitle: z.string().optional(),
|
||||
landingPageText: z.string().optional(),
|
||||
landingPageHeroImage: z.string().optional().nullable(),
|
||||
landingPageHeroOverlayOpacity: z.number().min(0).max(100).optional().default(50),
|
||||
landingPageFeatures: z.string().optional(),
|
||||
landingPageSectionTitle: z.string().optional(),
|
||||
landingPageButtonText: z.string().optional(),
|
||||
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const updateOrgSchema = z.object({
|
||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||
plan: z.enum(['pilot', 'standard', 'pro', 'verband']),
|
||||
contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')),
|
||||
logoUrl: z.string().optional().nullable(),
|
||||
primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
|
||||
secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
|
||||
landingPageTitle: z.string().optional(),
|
||||
landingPageText: z.string().optional(),
|
||||
landingPageHeroImage: z.string().optional().nullable(),
|
||||
landingPageFeatures: z.string().optional(),
|
||||
landingPageSectionTitle: z.string().optional(),
|
||||
landingPageButtonText: z.string().optional(),
|
||||
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const createAdminSchema = z.object({
|
||||
orgId: z.string(),
|
||||
name: z.string().min(2, 'Name ist zu kurz'),
|
||||
email: z.string().email('Ungueltige E-Mail Adresse'),
|
||||
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
|
||||
})
|
||||
|
||||
const createMemberSchema = z.object({
|
||||
orgId: z.string(),
|
||||
name: z.string().min(2, 'Name ist zu kurz'),
|
||||
email: z.string().email('Ungueltige E-Mail Adresse'),
|
||||
betrieb: z.string().min(2, 'Betrieb ist zu kurz'),
|
||||
sparte: z.string().min(2, 'Sparte ist zu kurz'),
|
||||
ort: z.string().min(2, 'Ort ist zu kurz'),
|
||||
})
|
||||
|
||||
export async function createOrganization(prevState: any, formData: FormData) {
|
||||
try {
|
||||
const rawData = {
|
||||
name: formData.get('name') as string,
|
||||
slug: (formData.get('slug') as string).toLowerCase(),
|
||||
contactEmail: formData.get('contactEmail') as string,
|
||||
}
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
const validatedData = createOrgSchema.parse(rawData)
|
||||
|
||||
// Check if slug exists
|
||||
const existingOrg = await prisma.organization.findUnique({
|
||||
where: { slug: validatedData.slug }
|
||||
})
|
||||
|
||||
if (existingOrg) {
|
||||
return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' }
|
||||
}
|
||||
|
||||
await prisma.organization.create({
|
||||
data: {
|
||||
name: validatedData.name,
|
||||
slug: validatedData.slug,
|
||||
contactEmail: validatedData.contactEmail || null,
|
||||
plan: 'pilot',
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
|
||||
try {
|
||||
const rawData = {
|
||||
name: (formData.get('name') as string).trim(),
|
||||
slug: (formData.get('slug') as string).trim().toLowerCase(),
|
||||
contactEmail: (formData.get('contactEmail') as string).trim(),
|
||||
adminEmail: normalizeEmail(formData.get('adminEmail') as string),
|
||||
adminPassword: formData.get('adminPassword') as string,
|
||||
logoUrl: formData.get('logoUrl') as string,
|
||||
plan: (formData.get('plan') as string) || 'pilot',
|
||||
primaryColor: formData.get('primaryColor') as string,
|
||||
secondaryColor: formData.get('secondaryColor') as string,
|
||||
landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
|
||||
landingPageText: (formData.get('landingPageText') as string).trim(),
|
||||
landingPageHeroImage: formData.get('landingPageHeroImage') as string,
|
||||
landingPageHeroOverlayOpacity: Number(formData.get('landingPageHeroOverlayOpacity') || '50'),
|
||||
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
|
||||
landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
|
||||
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
|
||||
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
|
||||
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
|
||||
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
|
||||
}
|
||||
|
||||
const validatedData = createOrgSchema.parse(rawData)
|
||||
|
||||
const existingOrg = await prisma.organization.findUnique({
|
||||
where: { slug: validatedData.slug },
|
||||
})
|
||||
|
||||
if (existingOrg) {
|
||||
return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' }
|
||||
}
|
||||
|
||||
const org = await prisma.organization.create({
|
||||
data: {
|
||||
name: validatedData.name,
|
||||
slug: validatedData.slug,
|
||||
contactEmail: validatedData.contactEmail || validatedData.adminEmail || null,
|
||||
plan: validatedData.plan,
|
||||
primaryColor: validatedData.primaryColor || '#E63946',
|
||||
secondaryColor: validatedData.secondaryColor || null,
|
||||
logoUrl: validatedData.logoUrl || null,
|
||||
landingPageTitle: validatedData.landingPageTitle || null,
|
||||
landingPageText: validatedData.landingPageText || null,
|
||||
landingPageHeroImage: validatedData.landingPageHeroImage || null,
|
||||
// @ts-ignore
|
||||
landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity,
|
||||
landingPageFeatures: validatedData.landingPageFeatures || null,
|
||||
landingPageFooter: validatedData.landingPageFooter || null,
|
||||
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
|
||||
landingPageButtonText: validatedData.landingPageButtonText || null,
|
||||
appStoreUrl: validatedData.appStoreUrl || null,
|
||||
playStoreUrl: validatedData.playStoreUrl || null,
|
||||
},
|
||||
})
|
||||
|
||||
if (validatedData.adminEmail) {
|
||||
let user = await prisma.user.findUnique({ where: { email: validatedData.adminEmail } })
|
||||
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
name: validatedData.adminEmail.split('@')[0],
|
||||
email: validatedData.adminEmail,
|
||||
emailVerified: true,
|
||||
mustChangePassword: !!validatedData.adminPassword,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// If user exists, we still want to make sure they are verified and maybe force password change
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
...(validatedData.adminPassword ? { mustChangePassword: true } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.userRole.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: org.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: { role: 'admin' },
|
||||
create: {
|
||||
orgId: org.id,
|
||||
userId: user.id,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
if (validatedData.adminPassword) {
|
||||
await setCredentialPassword(user.id, validatedData.adminPassword)
|
||||
try {
|
||||
await sendAdminCredentialsEmail({
|
||||
to: validatedData.adminEmail,
|
||||
adminName: user.name || validatedData.adminEmail.split('@')[0],
|
||||
orgName: org.name,
|
||||
password: validatedData.adminPassword,
|
||||
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3032',
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('E-Mail konnte nicht gesendet werden:', emailError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOrganization(id: string, prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
name: (formData.get('name') as string).trim(),
|
||||
plan: formData.get('plan') as string,
|
||||
contactEmail: (formData.get('contactEmail') as string).trim(),
|
||||
logoUrl: formData.get('logoUrl') as string,
|
||||
primaryColor: formData.get('primaryColor') as string,
|
||||
secondaryColor: formData.get('secondaryColor') as string,
|
||||
landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
|
||||
landingPageText: (formData.get('landingPageText') as string).trim(),
|
||||
landingPageHeroImage: formData.get('landingPageHeroImage') as string,
|
||||
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
|
||||
landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
|
||||
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
|
||||
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
|
||||
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
|
||||
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
|
||||
}
|
||||
|
||||
const validatedData = updateOrgSchema.parse(rawData)
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: validatedData.name,
|
||||
plan: validatedData.plan,
|
||||
contactEmail: validatedData.contactEmail || null,
|
||||
logoUrl: validatedData.logoUrl || null,
|
||||
primaryColor: validatedData.primaryColor || '#E63946',
|
||||
secondaryColor: validatedData.secondaryColor || null,
|
||||
landingPageTitle: validatedData.landingPageTitle || null,
|
||||
landingPageText: validatedData.landingPageText || null,
|
||||
landingPageHeroImage: validatedData.landingPageHeroImage || null,
|
||||
landingPageFeatures: validatedData.landingPageFeatures || null,
|
||||
landingPageFooter: validatedData.landingPageFooter || null,
|
||||
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
|
||||
landingPageButtonText: validatedData.landingPageButtonText || null,
|
||||
appStoreUrl: validatedData.appStoreUrl || null,
|
||||
playStoreUrl: validatedData.playStoreUrl || null,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
revalidatePath(`/superadmin/organizations/${id}`)
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAiFeature(id: string, enabled: boolean) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id },
|
||||
data: { aiEnabled: enabled },
|
||||
})
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
revalidatePath(`/superadmin/organizations/${id}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function deleteOrganization(id: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.organization.delete({ where: { id } })
|
||||
revalidatePath('/superadmin')
|
||||
redirect('/superadmin')
|
||||
}
|
||||
|
||||
export async function createAdmin(prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
orgId: formData.get('orgId') as string,
|
||||
name: (formData.get('name') as string).trim(),
|
||||
email: normalizeEmail(formData.get('email') as string),
|
||||
password: formData.get('password') as string,
|
||||
}
|
||||
|
||||
const validatedData = createAdminSchema.parse(rawData)
|
||||
|
||||
let user = await prisma.user.findUnique({ where: { email: validatedData.email } })
|
||||
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
emailVerified: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await setCredentialPassword(user.id, validatedData.password)
|
||||
|
||||
await prisma.userRole.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: validatedData.orgId,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: { role: 'admin' },
|
||||
create: {
|
||||
orgId: validatedData.orgId,
|
||||
userId: user.id,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: validatedData.orgId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
try {
|
||||
await sendAdminCredentialsEmail({
|
||||
to: validatedData.email,
|
||||
adminName: validatedData.name,
|
||||
orgName: org?.name || 'Ihre Innung',
|
||||
password: validatedData.password,
|
||||
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3032',
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError)
|
||||
}
|
||||
|
||||
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
console.error('Failed to create admin:', error)
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUserRole(id: string, orgId: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.userRole.delete({ where: { id } })
|
||||
revalidatePath(`/superadmin/organizations/${orgId}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function updateUserRole(id: string, orgId: string, role: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.userRole.update({
|
||||
where: { id },
|
||||
data: { role },
|
||||
})
|
||||
revalidatePath(`/superadmin/organizations/${orgId}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function removeMember(id: string, orgId: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.member.delete({ where: { id } })
|
||||
revalidatePath(`/superadmin/organizations/${orgId}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function createMember(prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
orgId: formData.get('orgId') as string,
|
||||
name: (formData.get('name') as string).trim(),
|
||||
email: normalizeEmail(formData.get('email') as string),
|
||||
betrieb: (formData.get('betrieb') as string).trim(),
|
||||
sparte: (formData.get('sparte') as string).trim(),
|
||||
ort: (formData.get('ort') as string).trim(),
|
||||
}
|
||||
|
||||
const validatedData = createMemberSchema.parse(rawData)
|
||||
|
||||
await prisma.member.create({
|
||||
data: {
|
||||
orgId: validatedData.orgId,
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
betrieb: validatedData.betrieb,
|
||||
sparte: validatedData.sparte,
|
||||
ort: validatedData.ort,
|
||||
status: 'aktiv',
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
console.error('Failed to create member:', error)
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
30
innungsapp/apps/admin/app/superadmin/create/page.tsx
Normal file
30
innungsapp/apps/admin/app/superadmin/create/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { CreateOrgForm } from '../CreateOrgForm'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function CreateOrgPage() {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col p-6 gap-6">
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<Link
|
||||
href="/superadmin"
|
||||
className="p-2.5 bg-white border border-gray-200 text-gray-400 rounded-xl hover:bg-gray-50 hover:text-gray-600 transition-colors"
|
||||
title="Zurück zur Übersicht"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-black text-gray-900 tracking-tight font-outfit">
|
||||
Neue Innung anlegen
|
||||
</h1>
|
||||
<p className="text-sm text-gray-400 font-medium">Legen Sie hier eine neue Innung an und konfigurieren Sie die Branding-Daten.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<CreateOrgForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
innungsapp/apps/admin/app/superadmin/landingpages/page.tsx
Normal file
119
innungsapp/apps/admin/app/superadmin/landingpages/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink, Settings, Layout, Search } from 'lucide-react'
|
||||
|
||||
export default async function LandingPagesOverview({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string }>
|
||||
}) {
|
||||
const { q = '' } = await searchParams
|
||||
|
||||
const organizations = await prisma.organization.findMany({
|
||||
where: q ? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ slug: { contains: q, mode: 'insensitive' } },
|
||||
]
|
||||
} : {},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit">
|
||||
Landingpage-Verwaltung
|
||||
</h1>
|
||||
<p className="text-gray-500 font-medium">Alle Mandanten-Landingpages auf einen Blick verwalten.</p>
|
||||
</div>
|
||||
|
||||
<div className="relative group w-full md:w-72">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-[#E63946] transition-colors">
|
||||
<Search size={18} />
|
||||
</div>
|
||||
<form method="GET">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Landingpage suchen..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white border rounded-xl text-sm outline-none focus:border-[#E63946] focus:ring-4 focus:ring-red-500/5 transition-all shadow-sm"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{organizations.length === 0 ? (
|
||||
<div className="col-span-full py-20 bg-white border border-dashed rounded-3xl flex flex-col items-center justify-center text-center">
|
||||
<div className="bg-gray-50 p-4 rounded-2xl mb-4 text-gray-400">
|
||||
<Layout size={40} strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-gray-500 font-medium">Keine Landingpages gefunden.</p>
|
||||
{q && <Link href="/superadmin/landingpages" className="text-[#E63946] font-bold mt-2 text-sm hover:underline">Suche zurücksetzen</Link>}
|
||||
</div>
|
||||
) : (
|
||||
organizations.map((org) => (
|
||||
<div key={org.id} className="group bg-white rounded-3xl border border-gray-100 p-6 hover:border-[#E63946] hover:shadow-2xl hover:shadow-red-500/5 transition-all duration-500 flex flex-col h-full relative overflow-hidden">
|
||||
{/* Accent line */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-gray-50 via-gray-100 to-gray-50 group-hover:from-red-100 group-hover:via-[#E63946] group-hover:to-red-100 transition-all duration-500" />
|
||||
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-black text-xl text-gray-900 group-hover:text-[#E63946] transition-colors truncate max-w-[200px]">
|
||||
{org.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 text-xs font-mono text-gray-400">
|
||||
<span className="text-[#E63946] opacity-50">/</span>
|
||||
<span>{org.slug}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 bg-gray-50 rounded-2xl text-gray-400 group-hover:bg-red-50 group-hover:text-[#E63946] transition-all duration-500">
|
||||
<Layout size={20} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="bg-gray-50/50 rounded-2xl p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between text-[11px] font-bold uppercase tracking-widest text-gray-400 mb-2">
|
||||
<span>Status</span>
|
||||
<span className="flex items-center gap-1 text-green-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-600 truncate">
|
||||
{org.landingPageTitle || 'Standard-Title'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href={`/${org.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 py-3 bg-gray-50 text-gray-600 rounded-2xl text-sm font-bold hover:bg-gray-100 transition-all border border-transparent"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
Ansehen
|
||||
</a>
|
||||
<Link
|
||||
href={`/superadmin/organizations/${org.id}`}
|
||||
className="flex items-center justify-center gap-2 py-3 bg-gray-900 text-white rounded-2xl text-sm font-bold hover:bg-black transition-all hover:shadow-lg hover:shadow-black/10 shadow-sm"
|
||||
>
|
||||
<Settings size={16} />
|
||||
Editieren
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,8 @@ export default async function SuperAdminLayout({
|
||||
}
|
||||
|
||||
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||
if (session.user.email !== superAdminEmail) {
|
||||
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
|
||||
if (!isSuperAdmin) {
|
||||
redirect('/dashboard') // Normal admins go back to dashboard
|
||||
}
|
||||
|
||||
@@ -25,12 +26,20 @@ export default async function SuperAdminLayout({
|
||||
<header className="bg-gray-900 text-white border-t-2 border-brand-500 border-b border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-12 items-center">
|
||||
<span
|
||||
className="font-bold text-base tracking-tight"
|
||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||
>
|
||||
Super Admin
|
||||
</span>
|
||||
<div className="flex items-center gap-8">
|
||||
<span
|
||||
className="font-bold text-base tracking-tight hover:text-gray-200 transition-colors"
|
||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||
>
|
||||
<Link href="/superadmin">Super Admin</Link>
|
||||
</span>
|
||||
|
||||
{/* Super Admin Navigation */}
|
||||
<nav className="hidden md:flex gap-6 text-sm font-medium text-gray-400">
|
||||
<Link href="/superadmin" className="hover:text-white transition-colors">Übersicht</Link>
|
||||
<Link href="/superadmin/landingpages" className="hover:text-white transition-colors">Landingpages</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{session.user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { createAdmin } from '../../actions'
|
||||
|
||||
export function CreateAdminForm({ orgId }: { orgId: string }) {
|
||||
const [state, action, isPending] = useActionState(createAdmin, { success: false, error: '' })
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="w-full py-2 border-2 border-dashed border-gray-200 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-all font-medium"
|
||||
>
|
||||
+ Administrator hinzufügen
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border rounded-xl p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Neuen Admin anlegen</h3>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="orgId" value={orgId} />
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name</label>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
placeholder="z.B. Max Mustermann"
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="admin@beispiel.de"
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Passwort</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="password"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={Math.random().toString(36).slice(-10)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm font-mono focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 mt-1">Das Passwort muss dem Admin manuell mitgeteilt werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
|
||||
)}
|
||||
|
||||
{state.success && (
|
||||
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Administrator erfolgreich angelegt.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full bg-gray-900 text-white py-2 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isPending ? 'Wird angelegt...' : 'Admin anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { createMember } from '../../actions'
|
||||
|
||||
const initialState = {
|
||||
success: false,
|
||||
error: '',
|
||||
}
|
||||
|
||||
export function CreateMemberForm({ orgId }: { orgId: string }) {
|
||||
const [state, action, isPending] = useActionState(createMember, initialState)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Mitglied manuell hinzufügen</h3>
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="orgId" value={orgId} />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name (Ansprechpartner)</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Anrede Vorname Nachname"
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="email@beispiel.de"
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Betrieb</label>
|
||||
<input
|
||||
name="betrieb"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Name des Betriebs"
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Sparte</label>
|
||||
<input
|
||||
name="sparte"
|
||||
type="text"
|
||||
required
|
||||
placeholder="z.B. Sanitär"
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Ort</label>
|
||||
<input
|
||||
name="ort"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Stadt"
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
|
||||
)}
|
||||
|
||||
{state.success && (
|
||||
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Mitglied erfolgreich angelegt.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full bg-gray-900 text-white py-2 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isPending ? 'Wird angelegt...' : 'Mitglied anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { deleteOrganization } from '../../actions'
|
||||
|
||||
export function DeleteOrgButton({ id, name }: { id: string; name: string }) {
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Innung "${name}" wirklich unwiderruflich löschen? Alle Daten (Mitglieder, News, Termine, Stellen) werden gelöscht.`)) return
|
||||
await deleteOrganization(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full mt-2 px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Innung löschen
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { updateOrganization } from '../../actions'
|
||||
|
||||
interface Props {
|
||||
org: {
|
||||
id: string
|
||||
name: string
|
||||
plan: string
|
||||
contactEmail: string | null
|
||||
logoUrl: string | null
|
||||
primaryColor: string | null
|
||||
secondaryColor: string | null
|
||||
landingPageTitle: string | null
|
||||
landingPageText: string | null
|
||||
landingPageSectionTitle: string | null
|
||||
landingPageButtonText: string | null
|
||||
landingPageHeroImage: string | null
|
||||
landingPageHeroOverlayOpacity: number | null
|
||||
landingPageFeatures: string | null
|
||||
landingPageFooter: string | null
|
||||
appStoreUrl: string | null
|
||||
playStoreUrl: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const initialState = { success: false, error: '' }
|
||||
|
||||
export function EditOrgForm({ org }: Props) {
|
||||
const boundAction = updateOrganization.bind(null, org.id)
|
||||
const [state, formAction, isPending] = useActionState(boundAction, initialState)
|
||||
const [logoUrl, setLogoUrl] = useState(org.logoUrl || '')
|
||||
const [heroImageUrl, setHeroImageUrl] = useState(org.landingPageHeroImage || '')
|
||||
const [isUploading, setIsUploading] = useState<{ logo?: boolean; hero?: boolean }>({})
|
||||
const [themeColor, setThemeColor] = useState(org.primaryColor || '#E63946')
|
||||
const [secondaryColor, setSecondaryColor] = useState(org.secondaryColor || '#FFFFFF')
|
||||
|
||||
let initialFeatures = ''
|
||||
try {
|
||||
if (org.landingPageFeatures) {
|
||||
const parsed = JSON.parse(org.landingPageFeatures)
|
||||
if (Array.isArray(parsed)) {
|
||||
initialFeatures = parsed.join('\n')
|
||||
} else {
|
||||
initialFeatures = org.landingPageFeatures
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
initialFeatures = org.landingPageFeatures || ''
|
||||
}
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'hero') => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsUploading(prev => ({ ...prev, [type]: true }))
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
if (type === 'logo') setLogoUrl(data.url)
|
||||
if (type === 'hero') setHeroImageUrl(data.url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(prev => ({ ...prev, [type]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">Innung bearbeiten</h2>
|
||||
|
||||
{state.success && (
|
||||
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">Änderungen gespeichert.</div>
|
||||
)}
|
||||
{state.error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{state.error}</div>
|
||||
)}
|
||||
|
||||
<form action={formAction} className="space-y-6">
|
||||
{/* BASISDATEN */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Basisdaten</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={org.name}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Plan</label>
|
||||
<select
|
||||
name="plan"
|
||||
defaultValue={org.plan}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 bg-white"
|
||||
>
|
||||
<option value="pilot">Pilot</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="verband">Verband</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
name="contactEmail"
|
||||
defaultValue={org.contactEmail ?? ''}
|
||||
placeholder="info@innung.de"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BRANDING */}
|
||||
<div className="space-y-4 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Branding</h3>
|
||||
|
||||
<input type="hidden" name="logoUrl" value={logoUrl} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Logo</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{logoUrl ? (
|
||||
<div className="w-10 h-10 rounded border bg-gray-50 flex items-center justify-center p-1">
|
||||
<img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded border-2 border-dashed flex items-center justify-center text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex-1 cursor-pointer">
|
||||
<div className={`px-3 py-2 border rounded-lg text-sm text-center font-medium hover:bg-gray-50 transition-colors ${isUploading.logo ? 'opacity-50' : ''}`}>
|
||||
{isUploading.logo ? 'Wird hochgeladen...' : 'Logo ändern'}
|
||||
</div>
|
||||
<input type="file" onChange={(e) => handleUpload(e, 'logo')} accept="image/*" className="hidden" disabled={isUploading.logo} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Primärfarbe</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name="primaryColor"
|
||||
value={themeColor}
|
||||
onChange={(e) => setThemeColor(e.target.value)}
|
||||
className="h-9 w-12 p-1 border rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={themeColor}
|
||||
onChange={(e) => setThemeColor(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm"
|
||||
pattern="^#([A-Fa-f0-9]{6})$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sekundärfarbe</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name="secondaryColor"
|
||||
value={secondaryColor}
|
||||
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||
className="h-9 w-12 p-1 border rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={secondaryColor}
|
||||
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm"
|
||||
pattern="^#([A-Fa-f0-9]{6})$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LANDING PAGE */}
|
||||
<div className="space-y-4 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Landing Page</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
name="landingPageTitle"
|
||||
defaultValue={org.landingPageTitle ?? ''}
|
||||
placeholder="Zukunft des Handwerks gestalten"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Untertitel / Text</label>
|
||||
<textarea
|
||||
name="landingPageText"
|
||||
defaultValue={org.landingPageText ?? ''}
|
||||
rows={3}
|
||||
placeholder="Gemeinsam stark für unsere Region."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Aufmacher Überschrift (Buttons)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="landingPageSectionTitle"
|
||||
defaultValue={org.landingPageSectionTitle ?? ''}
|
||||
placeholder={`${org.name || 'Ihre Innung'} – Gemeinsam stark fürs Handwerk`}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Button Text (CTA)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="landingPageButtonText"
|
||||
defaultValue={org.landingPageButtonText ?? ''}
|
||||
placeholder="Jetzt Mitglied werden"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="landingPageHeroImage" value={heroImageUrl} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Hintergrundbild</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex-1 cursor-pointer">
|
||||
<div className={`px-3 py-2 border rounded-lg text-sm text-center font-medium hover:bg-gray-50 transition-colors ${isUploading.hero ? 'opacity-50' : ''}`}>
|
||||
{isUploading.hero ? 'Wird hochgeladen...' : 'Bild auswählen'}
|
||||
</div>
|
||||
<input type="file" onChange={(e) => handleUpload(e, 'hero')} accept="image/*" className="hidden" disabled={isUploading.hero} />
|
||||
</label>
|
||||
{heroImageUrl && (
|
||||
<button type="button" onClick={() => setHeroImageUrl('')} className="text-red-500 hover:text-red-600 text-sm">
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 flex justify-between">
|
||||
<span>Overlay Deckkraft</span>
|
||||
<span className="text-gray-500">{org.landingPageHeroOverlayOpacity ?? 50}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
name="landingPageHeroOverlayOpacity"
|
||||
min="0"
|
||||
max="100"
|
||||
defaultValue={org.landingPageHeroOverlayOpacity ?? 50}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Legt fest, wie dunkel der Schleier über dem Hintergrundbild ist, damit der Text gut lesbar bleibt.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vorteile / Features</label>
|
||||
<textarea
|
||||
name="landingPageFeatures"
|
||||
defaultValue={initialFeatures}
|
||||
rows={5}
|
||||
placeholder="Ein Benefit pro Zeile..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Bitte geben Sie pro Zeile einen Vorteil ein. Diese werden als Checkliste auf der Landingpage angezeigt.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">App Store URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="appStoreUrl"
|
||||
defaultValue={org.appStoreUrl ?? ''}
|
||||
placeholder="https://apps.apple.com/..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Google Play URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="playStoreUrl"
|
||||
defaultValue={org.playStoreUrl ?? ''}
|
||||
placeholder="https://play.google.com/..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Footer Text</label>
|
||||
<textarea
|
||||
name="landingPageFooter"
|
||||
defaultValue={org.landingPageFooter ?? ''}
|
||||
rows={2}
|
||||
placeholder="© 2024 Innung. Alle Rechte vorbehalten."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full bg-brand-500 text-white font-medium py-2 px-4 rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Wird gespeichert…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { removeMember } from '../../actions'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function MemberActions({ member, orgId }: { member: { id: string, name: string }, orgId: string }) {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!confirm(`Möchten Sie das Mitglied ${member.name} wirklich entfernen?`)) return
|
||||
setIsPending(true)
|
||||
await removeMember(member.id, orgId)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { removeUserRole, updateUserRole } from '../../actions'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function UserRoleActions({ ur, orgId }: { ur: { id: string, role: string, user: { email: string } }, orgId: string }) {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!confirm(`Möchten Sie den Zugriff für ${ur.user.email} wirklich entfernen?`)) return
|
||||
setIsPending(true)
|
||||
await removeUserRole(ur.id, orgId)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
const handleToggleRole = async () => {
|
||||
const newRole = ur.role === 'admin' ? 'member' : 'admin'
|
||||
setIsPending(true)
|
||||
await updateUserRole(ur.id, orgId, newRole)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleToggleRole}
|
||||
disabled={isPending}
|
||||
className="text-xs text-gray-600 hover:text-brand-600 font-medium transition-colors"
|
||||
title={ur.role === 'admin' ? 'Zum Mitglied machen' : 'Zum Admin machen'}
|
||||
>
|
||||
{ur.role === 'admin' ? 'Rolle: Admin' : 'Rolle: Mitglied'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
innungsapp/apps/admin/app/superadmin/organizations/[id]/page.tsx
Normal file
234
innungsapp/apps/admin/app/superadmin/organizations/[id]/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import Link from 'next/link'
|
||||
import { EditOrgForm } from './EditOrgForm'
|
||||
import { DeleteOrgButton } from './DeleteOrgButton'
|
||||
import { CreateAdminForm } from './CreateAdminForm'
|
||||
import { CreateMemberForm } from './CreateMemberForm'
|
||||
import { UserRoleActions } from './UserRoleActions'
|
||||
import { MemberActions } from './MemberActions'
|
||||
import { toggleAiFeature } from '../../actions'
|
||||
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
pilot: 'bg-gray-100 text-gray-700',
|
||||
standard: 'bg-blue-100 text-blue-800',
|
||||
pro: 'bg-purple-100 text-purple-800',
|
||||
verband: 'bg-amber-100 text-amber-800',
|
||||
}
|
||||
|
||||
export default async function OrgDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
userRoles: true,
|
||||
news: true,
|
||||
termine: true,
|
||||
stellen: true,
|
||||
},
|
||||
},
|
||||
userRoles: {
|
||||
include: { user: true },
|
||||
},
|
||||
members: {
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, name: true, betrieb: true, status: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!org) notFound()
|
||||
|
||||
const planColor = PLAN_COLORS[org.plan] ?? 'bg-gray-100 text-gray-700'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Link href="/superadmin" className="hover:text-gray-900 transition-colors">
|
||||
← Alle Innungen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{org.name}</h1>
|
||||
<span className={`text-xs font-semibold px-2.5 py-0.5 rounded ${planColor}`}>
|
||||
{org.plan}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
|
||||
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded text-[11px]">{org.slug}</span>
|
||||
<span>•</span>
|
||||
<span>Erstellt {format(org.createdAt, 'dd. MMMM yyyy', { locale: de })}</span>
|
||||
{org.avvAccepted && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-green-600">AVV akzeptiert</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
{[
|
||||
{ label: 'Mitglieder', value: org._count.members },
|
||||
{ label: 'Admins', value: org._count.userRoles },
|
||||
{ label: 'News', value: org._count.news },
|
||||
{ label: 'Termine', value: org._count.termine },
|
||||
{ label: 'Stellen', value: org._count.stellen },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white rounded-xl border p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Edit form */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<EditOrgForm org={org} />
|
||||
|
||||
{/* KI-Assistent */}
|
||||
<div className="bg-white rounded-xl border p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">KI-Assistent</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Aktiviert den KI-Chat-Assistenten für Mitglieder dieser Innung.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-medium ${org.aiEnabled ? 'text-green-700' : 'text-gray-400'}`}>
|
||||
{org.aiEnabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||
</span>
|
||||
<form action={async () => {
|
||||
'use server'
|
||||
await toggleAiFeature(org.id, !org.aiEnabled)
|
||||
}}>
|
||||
<button
|
||||
type="submit"
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${org.aiEnabled ? 'bg-green-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${org.aiEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="bg-white rounded-xl border border-red-200 p-4">
|
||||
<h3 className="text-sm font-semibold text-red-700 mb-1">Gefahrenzone</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Das Löschen einer Innung entfernt alle zugehörigen Daten unwiderruflich.
|
||||
</p>
|
||||
<DeleteOrgButton id={org.id} name={org.name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: admins + recent members */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Admins */}
|
||||
<div className="bg-white rounded-xl border overflow-hidden">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-900">
|
||||
Nutzer & Rollen ({org.userRoles.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50/50 border-b">
|
||||
<CreateAdminForm orgId={org.id} />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{org.userRoles.length === 0 ? (
|
||||
<p className="p-4 text-sm text-gray-400">Noch keine Nutzer zugewiesen.</p>
|
||||
) : (
|
||||
org.userRoles.map((ur) => (
|
||||
<div key={ur.id} className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{ur.user.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{ur.user.email}
|
||||
<span className="ml-2 font-mono text-[10px] bg-gray-100 px-1 py-0.5 rounded">
|
||||
{ur.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{ur.user.emailVerified ? (
|
||||
<span className="text-[10px] text-green-600 bg-green-50 px-2 py-0.5 rounded-full uppercase font-bold tracking-wider">
|
||||
Verifiziert
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full uppercase font-bold tracking-wider">
|
||||
Eingeladen
|
||||
</span>
|
||||
)}
|
||||
<UserRoleActions ur={ur} orgId={org.id} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent members */}
|
||||
<div className="bg-white rounded-xl border overflow-hidden">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-900">
|
||||
Mitglieder
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400">{org._count.members} gesamt</span>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50/50 border-b">
|
||||
<CreateMemberForm orgId={org.id} />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{org.members.length === 0 ? (
|
||||
<p className="p-4 text-sm text-gray-400">Noch keine Mitglieder.</p>
|
||||
) : (
|
||||
org.members.map((m) => (
|
||||
<div key={m.id} className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{m.name}</div>
|
||||
<div className="text-xs text-gray-500">{m.betrieb}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${m.status === 'aktiv'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{m.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{format(m.createdAt, 'dd.MM.yy', { locale: de })}
|
||||
</span>
|
||||
<MemberActions member={m} orgId={org.id} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,81 +1,233 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { CreateOrgForm } from './CreateOrgForm'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import Link from 'next/link'
|
||||
import { toggleAiFeature } from './actions'
|
||||
|
||||
export default async function SuperAdminPage() {
|
||||
const organizations = await prisma.organization.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
userRoles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
pilot: 'Pilot',
|
||||
standard: 'Standard',
|
||||
pro: 'Pro',
|
||||
verband: 'Verband',
|
||||
}
|
||||
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
pilot: 'bg-gray-100 text-gray-700',
|
||||
standard: 'bg-blue-100 text-blue-800',
|
||||
pro: 'bg-purple-100 text-purple-800',
|
||||
verband: 'bg-amber-100 text-amber-800',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default async function SuperAdminPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; page?: string }>
|
||||
}) {
|
||||
const { q = '', page = '1' } = await searchParams
|
||||
const currentPage = Math.max(1, parseInt(page, 10))
|
||||
const skip = (currentPage - 1) * PAGE_SIZE
|
||||
|
||||
const where = q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ slug: { contains: q, mode: 'insensitive' } },
|
||||
{ contactEmail: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}
|
||||
|
||||
const [organizations, total] = await Promise.all([
|
||||
prisma.organization.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: PAGE_SIZE,
|
||||
include: { _count: { select: { members: true, userRoles: true } } },
|
||||
}),
|
||||
prisma.organization.count({ where }),
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Innungs-Verwaltung (Multi-Tenant)</h1>
|
||||
<p className="text-gray-500 mt-1">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Form: Create new org */}
|
||||
<div className="lg:col-span-1">
|
||||
<CreateOrgForm />
|
||||
<div className="max-w-[1400px] mx-auto space-y-12 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-left space-y-2">
|
||||
<h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit">
|
||||
Innungs-Verwaltung <span className="text-[#E63946]">PRO</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 font-medium">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
|
||||
</div>
|
||||
|
||||
{/* List of orgs */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Aktive Innungen ({organizations.length})</h2>
|
||||
<Link
|
||||
href="/superadmin/create"
|
||||
className="bg-[#E63946] text-white font-bold py-3 px-6 rounded-xl hover:bg-[#D62839] transition-all shadow-md shadow-red-100 flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Neue Innung anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-12 items-start">
|
||||
{/* List */}
|
||||
<div className="space-y-6">
|
||||
{/* Search & Filter */}
|
||||
<div className="bg-white p-2 rounded-2xl border shadow-sm flex items-center">
|
||||
<form method="GET" className="flex-1 flex gap-2">
|
||||
<div className="relative flex-1 group">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-[#E63946] transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Innung suchen..."
|
||||
className="w-full pl-9 pr-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2.5 bg-gray-900 text-white rounded-xl text-sm font-bold hover:bg-black transition-all active:scale-[0.98]"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
{q && (
|
||||
<Link
|
||||
href="/superadmin"
|
||||
className="p-2.5 bg-gray-50 text-gray-400 rounded-xl hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h2 className="text-sm font-bold text-gray-400 uppercase tracking-widest">
|
||||
Registrierte Innungen ({total})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
<div className="flex flex-col gap-4">
|
||||
{organizations.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Bisher keine Innungen angelegt.
|
||||
<div className="bg-white p-12 text-center rounded-2xl border border-dashed border-gray-200">
|
||||
<div className="text-gray-300 mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1} stroke="currentColor" className="w-12 h-12 mx-auto">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-1.5 0H21m-8.47-17.69-6 6a.75.75 0 0 0-.215.53V21m1.5 0H1.875a.375.375 0 0 1-.375-.375V11.25c0-4.46 3.07-8.189 7.5-9.088a9 9 0 0 1 1.585-.152Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 font-medium">
|
||||
{q ? 'Keine Treffer für Ihre Suche.' : 'Bisher keine Innungen angelegt.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
organizations.map((org) => (
|
||||
<div key={org.id} className="p-5 hover:bg-gray-50 border-l-[3px] border-transparent hover:border-brand-500 transition-all">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-lg">{org.name}</h3>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
|
||||
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded text-[11px]">{org.slug}</span>
|
||||
<span>•</span>
|
||||
<span>{org.contactEmail || 'Keine E-Mail'}</span>
|
||||
<div key={org.id} className="group bg-white p-6 rounded-2xl border hover:border-[#E63946] hover:shadow-xl hover:shadow-red-500/5 transition-all duration-300 relative overflow-hidden">
|
||||
<div className="flex justify-between items-start gap-6 relative z-10">
|
||||
<Link href={`/superadmin/organizations/${org.id}`} className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-bold text-lg text-gray-900 group-hover:text-[#E63946] transition-colors">{org.name}</h3>
|
||||
<span className={`text-[10px] font-black uppercase tracking-tighter px-2 py-0.5 rounded-full border ${PLAN_COLORS[org.plan] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||
{org.plan}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400 font-medium">
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
<span className="text-[#E63946]">@</span>
|
||||
<span>{org.slug}</span>
|
||||
</div>
|
||||
<span className="w-1 h-1 rounded-full bg-gray-200" />
|
||||
<span>{org.contactEmail || 'Keine Kontaktmail'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 lg:opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-2 group-hover:translate-x-0">
|
||||
<form action={async () => {
|
||||
'use server'
|
||||
await toggleAiFeature(org.id, !org.aiEnabled)
|
||||
}}>
|
||||
<button
|
||||
type="submit"
|
||||
className={`p-2 rounded-xl border transition-all ${org.aiEnabled
|
||||
? 'bg-green-50 text-green-600 border-green-100 hover:bg-red-50 hover:text-red-600'
|
||||
: 'bg-gray-50 text-gray-400 border-gray-100 hover:bg-green-50 hover:text-green-600'}`}
|
||||
title={org.aiEnabled ? 'KI Deaktivieren' : 'KI Aktivieren'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.59 8.31m5.84 6.06a6.01 6.01 0 0 1-5.84-1.29m0 0a6.01 6.01 0 0 1 0-8.5l.08.08a6.01 6.01 0 0 1 0 8.42Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
href={`/superadmin/organizations/${org.id}`}
|
||||
className="p-2 bg-gray-900 text-white rounded-xl hover:bg-black transition-all"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-semibold px-2.5 py-0.5 rounded">
|
||||
{org.plan}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-sm">
|
||||
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
|
||||
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Mitglieder</span>
|
||||
<div className="mt-6 flex items-center gap-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Mitglieder</span>
|
||||
<span className="font-bold text-gray-900">{org._count.members}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
|
||||
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Admins</span>
|
||||
<div className="w-px h-6 bg-gray-100" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Admins</span>
|
||||
<span className="font-bold text-gray-900">{org._count.userRoles}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
|
||||
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Erstellt am</span>
|
||||
<span className="font-bold text-gray-900">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
|
||||
<div className="w-px h-6 bg-gray-100 ml-auto" />
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Erstellt am</span>
|
||||
<span className="text-xs font-semibold text-gray-600">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="pt-8 flex items-center justify-between border-t border-gray-100">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase tracking-widest">
|
||||
Seite {currentPage} / {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{currentPage > 1 && (
|
||||
<Link
|
||||
href={`/superadmin?${new URLSearchParams({ q, page: String(currentPage - 1) })}`}
|
||||
className="px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-600 hover:bg-gray-50 transition-all active:scale-[0.98]"
|
||||
>
|
||||
← Zurück
|
||||
</Link>
|
||||
)}
|
||||
{currentPage < totalPages && (
|
||||
<Link
|
||||
href={`/superadmin?${new URLSearchParams({ q, page: String(currentPage + 1) })}`}
|
||||
className="px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-600 hover:bg-gray-50 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Weiter →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user