feat(website): Add Foerderantrag pages and website management section
Add public Foerderantrag (funding application) pages to the website. Add website management section to admin-v2 with content and status APIs. Extend Header, LandingContent, and i18n with new navigation entries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
670
admin-v2/app/(admin)/website/manager/page.tsx
Normal file
670
admin-v2/app/(admin)/website/manager/page.tsx
Normal file
@@ -0,0 +1,670 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Website Manager - CMS Dashboard
|
||||
*
|
||||
* Visual CMS dashboard for the BreakPilot website (macmini:3000).
|
||||
* 60/40 split: Section cards with inline editors | Live iframe preview.
|
||||
* Status bar, content stats, reset, save.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import type {
|
||||
WebsiteContent,
|
||||
HeroContent,
|
||||
FeatureContent,
|
||||
FAQItem,
|
||||
PricingPlan,
|
||||
} from '@/lib/content-types'
|
||||
|
||||
const ADMIN_KEY = 'breakpilot-admin-2024'
|
||||
|
||||
// Section metadata for cards
|
||||
const SECTIONS = [
|
||||
{ key: 'hero', name: 'Hero Section', icon: '🎯', scrollTo: 'hero' },
|
||||
{ key: 'features', name: 'Features', icon: '⚡', scrollTo: 'features' },
|
||||
{ key: 'faq', name: 'FAQ', icon: '❓', scrollTo: 'faq' },
|
||||
{ key: 'pricing', name: 'Pricing', icon: '💰', scrollTo: 'pricing' },
|
||||
{ key: 'trust', name: 'Trust Indicators', icon: '🛡️', scrollTo: 'trust' },
|
||||
{ key: 'testimonial', name: 'Testimonial', icon: '💬', scrollTo: 'trust' },
|
||||
] as const
|
||||
|
||||
type SectionKey = (typeof SECTIONS)[number]['key']
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function countWords(content: WebsiteContent): number {
|
||||
const texts: string[] = []
|
||||
// Hero
|
||||
texts.push(content.hero.badge, content.hero.title, content.hero.titleHighlight1, content.hero.titleHighlight2, content.hero.subtitle, content.hero.ctaPrimary, content.hero.ctaSecondary, content.hero.ctaHint)
|
||||
// Features
|
||||
content.features.forEach(f => { texts.push(f.title, f.description) })
|
||||
// FAQ
|
||||
content.faq.forEach(f => { texts.push(f.question, ...f.answer) })
|
||||
// Pricing
|
||||
content.pricing.forEach(p => { texts.push(p.name, p.description, p.features.tasks, p.features.taskDescription, ...p.features.included) })
|
||||
// Trust
|
||||
texts.push(content.trust.item1.value, content.trust.item1.label, content.trust.item2.value, content.trust.item2.label, content.trust.item3.value, content.trust.item3.label)
|
||||
// Testimonial
|
||||
texts.push(content.testimonial.quote, content.testimonial.author, content.testimonial.role)
|
||||
return texts.filter(Boolean).join(' ').split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
|
||||
function sectionComplete(content: WebsiteContent, section: SectionKey): boolean {
|
||||
switch (section) {
|
||||
case 'hero':
|
||||
return !!(content.hero.title && content.hero.subtitle && content.hero.ctaPrimary)
|
||||
case 'features':
|
||||
return content.features.length > 0 && content.features.every(f => f.title && f.description)
|
||||
case 'faq':
|
||||
return content.faq.length > 0 && content.faq.every(f => f.question && f.answer.length > 0)
|
||||
case 'pricing':
|
||||
return content.pricing.length > 0 && content.pricing.every(p => p.name && p.price > 0)
|
||||
case 'trust':
|
||||
return !!(content.trust.item1.value && content.trust.item2.value && content.trust.item3.value)
|
||||
case 'testimonial':
|
||||
return !!(content.testimonial.quote && content.testimonial.author)
|
||||
}
|
||||
}
|
||||
|
||||
function sectionSummary(content: WebsiteContent, section: SectionKey): string {
|
||||
switch (section) {
|
||||
case 'hero':
|
||||
return `"${content.hero.title} ${content.hero.titleHighlight1}"`.slice(0, 50)
|
||||
case 'features':
|
||||
return `${content.features.length} Features`
|
||||
case 'faq':
|
||||
return `${content.faq.length} Fragen`
|
||||
case 'pricing':
|
||||
return `${content.pricing.length} Plaene`
|
||||
case 'trust':
|
||||
return `${content.trust.item1.value}, ${content.trust.item2.value}, ${content.trust.item3.value}`
|
||||
case 'testimonial':
|
||||
return `"${content.testimonial.quote.slice(0, 40)}..."`
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Component ────────────────────────────────────────────────────────
|
||||
|
||||
export default function WebsiteManagerPage() {
|
||||
const [content, setContent] = useState<WebsiteContent | null>(null)
|
||||
const [originalContent, setOriginalContent] = useState<WebsiteContent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [expandedSection, setExpandedSection] = useState<SectionKey | null>(null)
|
||||
const [websiteStatus, setWebsiteStatus] = useState<{ online: boolean; responseTime: number } | null>(null)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
// Load content
|
||||
useEffect(() => {
|
||||
loadContent()
|
||||
checkWebsiteStatus()
|
||||
}, [])
|
||||
|
||||
// Auto-dismiss messages
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
const t = setTimeout(() => setMessage(null), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [message])
|
||||
|
||||
async function loadContent() {
|
||||
try {
|
||||
const res = await fetch('/api/website/content')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setContent(data)
|
||||
setOriginalContent(JSON.parse(JSON.stringify(data)))
|
||||
} else {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Laden des Contents' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Verbindungsfehler beim Laden' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkWebsiteStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/website/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setWebsiteStatus(data)
|
||||
}
|
||||
} catch {
|
||||
setWebsiteStatus({ online: false, responseTime: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!content) return
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await fetch('/api/website/content', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-admin-key': ADMIN_KEY },
|
||||
body: JSON.stringify(content),
|
||||
})
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Erfolgreich gespeichert!' })
|
||||
setOriginalContent(JSON.parse(JSON.stringify(content)))
|
||||
// Reload iframe to reflect changes
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = iframeRef.current.src
|
||||
}
|
||||
} else {
|
||||
const err = await res.json()
|
||||
setMessage({ type: 'error', text: err.error || 'Fehler beim Speichern' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Verbindungsfehler beim Speichern' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetContent() {
|
||||
if (originalContent) {
|
||||
setContent(JSON.parse(JSON.stringify(originalContent)))
|
||||
setMessage({ type: 'success', text: 'Zurueckgesetzt auf letzten gespeicherten Stand' })
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll iframe to section
|
||||
const scrollPreview = useCallback((scrollTo: string) => {
|
||||
if (!iframeRef.current?.contentWindow) return
|
||||
try {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: 'scrollTo', section: scrollTo },
|
||||
'*'
|
||||
)
|
||||
} catch {
|
||||
// cross-origin fallback
|
||||
}
|
||||
}, [])
|
||||
|
||||
function toggleSection(key: SectionKey) {
|
||||
const newExpanded = expandedSection === key ? null : key
|
||||
setExpandedSection(newExpanded)
|
||||
if (newExpanded) {
|
||||
const section = SECTIONS.find(s => s.key === newExpanded)
|
||||
if (section) scrollPreview(section.scrollTo)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="flex items-center gap-3 text-slate-500">
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Lade Website-Content...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-red-600">Content konnte nicht geladen werden.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const wordCount = countWords(content)
|
||||
const completeSections = SECTIONS.filter(s => sectionComplete(content, s.key)).length
|
||||
const completionPct = Math.round((completeSections / SECTIONS.length) * 100)
|
||||
const hasChanges = JSON.stringify(content) !== JSON.stringify(originalContent)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Status Bar ───────────────────────────────────────────────────── */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 px-5 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Website status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${websiteStatus?.online ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-sm text-slate-700">
|
||||
Website {websiteStatus?.online ? 'online' : 'offline'}
|
||||
{websiteStatus?.online && websiteStatus.responseTime > 0 && (
|
||||
<span className="text-slate-400 ml-1">({websiteStatus.responseTime}ms)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Link */}
|
||||
<a
|
||||
href="https://macmini:3000"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-sky-600 hover:text-sky-700 flex items-center gap-1"
|
||||
>
|
||||
Zur Website
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{message && (
|
||||
<span className={`px-3 py-1 rounded-lg text-sm font-medium ${
|
||||
message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{message.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={resetContent}
|
||||
disabled={!hasChanges}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={saving || !hasChanges}
|
||||
className="px-5 py-2 text-sm font-medium text-white bg-sky-600 rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Stats Bar ────────────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Sektionen', value: `${SECTIONS.length}`, icon: '📄' },
|
||||
{ label: 'Woerter', value: wordCount.toLocaleString('de-DE'), icon: '📝' },
|
||||
{ label: 'Vollstaendig', value: `${completionPct}%`, icon: completionPct === 100 ? '✅' : '🔧' },
|
||||
{ label: 'Aenderungen', value: hasChanges ? 'Ungespeichert' : 'Aktuell', icon: hasChanges ? '🟡' : '🟢' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 px-4 py-3 flex items-center gap-3">
|
||||
<span className="text-xl">{stat.icon}</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">{stat.value}</div>
|
||||
<div className="text-xs text-slate-500">{stat.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Main Layout: 60/40 ───────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-5 gap-4" style={{ height: 'calc(100vh - 300px)' }}>
|
||||
{/* ── Left: Section Cards (3/5 = 60%) ──────────────────────────── */}
|
||||
<div className="col-span-3 overflow-y-auto pr-1 space-y-3">
|
||||
{SECTIONS.map((section) => {
|
||||
const isExpanded = expandedSection === section.key
|
||||
const isComplete = sectionComplete(content, section.key)
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
className={`bg-white rounded-xl border transition-all ${
|
||||
isExpanded ? 'border-sky-300 shadow-md' : 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{/* Card Header */}
|
||||
<button
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className="w-full px-5 py-4 flex items-center justify-between text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{section.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{section.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sectionSummary(content, section.key)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isComplete ? (
|
||||
<span className="w-6 h-6 rounded-full bg-green-100 text-green-600 flex items-center justify-center text-xs">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-6 h-6 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center text-xs">!</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-5 h-5 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Inline Editor */}
|
||||
{isExpanded && (
|
||||
<div className="px-5 pb-5 border-t border-slate-100 pt-4">
|
||||
{section.key === 'hero' && <HeroEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'features' && <FeaturesEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'faq' && <FAQEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'pricing' && <PricingEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'trust' && <TrustEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'testimonial' && <TestimonialEditor content={content} setContent={setContent} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Right: Live Preview (2/5 = 40%) ──────────────────────────── */}
|
||||
<div className="col-span-2 bg-white rounded-xl border border-slate-200 overflow-hidden flex flex-col">
|
||||
{/* Preview Header */}
|
||||
<div className="bg-slate-50 border-b border-slate-200 px-4 py-2.5 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-400" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 ml-2">macmini:3000</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (iframeRef.current) iframeRef.current.src = iframeRef.current.src }}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 rounded transition-colors"
|
||||
title="Preview neu laden"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* iframe */}
|
||||
<div className="flex-1 relative bg-slate-100">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="https://macmini:3000/?preview=true"
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
style={{
|
||||
width: '166.67%',
|
||||
height: '166.67%',
|
||||
transform: 'scale(0.6)',
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
title="Website Preview"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Section Editors ─────────────────────────────────────────────────────────
|
||||
|
||||
interface EditorProps {
|
||||
content: WebsiteContent
|
||||
setContent: React.Dispatch<React.SetStateAction<WebsiteContent | null>>
|
||||
}
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors'
|
||||
const labelCls = 'block text-xs font-medium text-slate-600 mb-1'
|
||||
|
||||
// ─── Hero Editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function HeroEditor({ content, setContent }: EditorProps) {
|
||||
function update(field: keyof HeroContent, value: string) {
|
||||
setContent(c => c ? { ...c, hero: { ...c.hero, [field]: value } } : c)
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Badge</label>
|
||||
<input className={inputCls} value={content.hero.badge} onChange={e => update('badge', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Titel</label>
|
||||
<input className={inputCls} value={content.hero.title} onChange={e => update('title', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Highlight 1</label>
|
||||
<input className={inputCls} value={content.hero.titleHighlight1} onChange={e => update('titleHighlight1', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Highlight 2</label>
|
||||
<input className={inputCls} value={content.hero.titleHighlight2} onChange={e => update('titleHighlight2', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Untertitel</label>
|
||||
<textarea className={inputCls} rows={2} value={content.hero.subtitle} onChange={e => update('subtitle', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>CTA Primaer</label>
|
||||
<input className={inputCls} value={content.hero.ctaPrimary} onChange={e => update('ctaPrimary', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>CTA Sekundaer</label>
|
||||
<input className={inputCls} value={content.hero.ctaSecondary} onChange={e => update('ctaSecondary', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>CTA Hinweis</label>
|
||||
<input className={inputCls} value={content.hero.ctaHint} onChange={e => update('ctaHint', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Features Editor ─────────────────────────────────────────────────────────
|
||||
|
||||
function FeaturesEditor({ content, setContent }: EditorProps) {
|
||||
function update(index: number, field: keyof FeatureContent, value: string) {
|
||||
setContent(c => {
|
||||
if (!c) return c
|
||||
const features = [...c.features]
|
||||
features[index] = { ...features[index], [field]: value }
|
||||
return { ...c, features }
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{content.features.map((feature, i) => (
|
||||
<div key={feature.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div>
|
||||
<label className={labelCls}>Icon</label>
|
||||
<input className={`${inputCls} text-center text-lg`} value={feature.icon} onChange={e => update(i, 'icon', e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-5">
|
||||
<label className={labelCls}>Titel</label>
|
||||
<input className={inputCls} value={feature.title} onChange={e => update(i, 'title', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Beschreibung</label>
|
||||
<textarea className={inputCls} rows={2} value={feature.description} onChange={e => update(i, 'description', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── FAQ Editor ──────────────────────────────────────────────────────────────
|
||||
|
||||
function FAQEditor({ content, setContent }: EditorProps) {
|
||||
function updateItem(index: number, field: 'question' | 'answer', value: string) {
|
||||
setContent(c => {
|
||||
if (!c) return c
|
||||
const faq = [...c.faq]
|
||||
if (field === 'answer') {
|
||||
faq[index] = { ...faq[index], answer: value.split('\n') }
|
||||
} else {
|
||||
faq[index] = { ...faq[index], question: value }
|
||||
}
|
||||
return { ...c, faq }
|
||||
})
|
||||
}
|
||||
function addItem() {
|
||||
setContent(c => c ? { ...c, faq: [...c.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }] } : c)
|
||||
}
|
||||
function removeItem(index: number) {
|
||||
setContent(c => c ? { ...c, faq: c.faq.filter((_, i) => i !== index) } : c)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{content.faq.map((item, i) => (
|
||||
<div key={i} className="bg-slate-50 rounded-lg p-3 space-y-2 relative group">
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="absolute top-2 right-2 p-1 text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Entfernen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<label className={labelCls}>Frage {i + 1}</label>
|
||||
<input className={inputCls} value={item.question} onChange={e => updateItem(i, 'question', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Antwort</label>
|
||||
<textarea className={`${inputCls} font-mono`} rows={3} value={item.answer.join('\n')} onChange={e => updateItem(i, 'answer', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addItem} className="w-full py-2 border-2 border-dashed border-slate-300 rounded-lg text-sm text-slate-500 hover:border-sky-400 hover:text-sky-600 transition-colors">
|
||||
+ Frage hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pricing Editor ──────────────────────────────────────────────────────────
|
||||
|
||||
function PricingEditor({ content, setContent }: EditorProps) {
|
||||
function update(index: number, field: string, value: string | number | boolean) {
|
||||
setContent(c => {
|
||||
if (!c) return c
|
||||
const pricing = [...c.pricing]
|
||||
if (field === 'price') {
|
||||
pricing[index] = { ...pricing[index], price: Number(value) }
|
||||
} else if (field === 'popular') {
|
||||
pricing[index] = { ...pricing[index], popular: Boolean(value) }
|
||||
} else if (field.startsWith('features.')) {
|
||||
const sub = field.replace('features.', '')
|
||||
if (sub === 'included' && typeof value === 'string') {
|
||||
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, included: value.split('\n') } }
|
||||
} else {
|
||||
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, [sub]: value } }
|
||||
}
|
||||
} else {
|
||||
pricing[index] = { ...pricing[index], [field]: value }
|
||||
}
|
||||
return { ...c, pricing }
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{content.pricing.map((plan, i) => (
|
||||
<div key={plan.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-slate-800">{plan.name}</span>
|
||||
{plan.popular && <span className="text-xs bg-sky-100 text-sky-700 px-1.5 py-0.5 rounded">Beliebt</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div>
|
||||
<label className={labelCls}>Name</label>
|
||||
<input className={inputCls} value={plan.name} onChange={e => update(i, 'name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Preis (EUR)</label>
|
||||
<input className={inputCls} type="number" step="0.01" value={plan.price} onChange={e => update(i, 'price', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Intervall</label>
|
||||
<input className={inputCls} value={plan.interval} onChange={e => update(i, 'interval', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={plan.popular || false} onChange={e => update(i, 'popular', e.target.checked)} className="w-4 h-4 text-sky-600 rounded" />
|
||||
<span className="text-xs text-slate-600">Beliebt</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Beschreibung</label>
|
||||
<input className={inputCls} value={plan.description} onChange={e => update(i, 'description', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={labelCls}>Aufgaben</label>
|
||||
<input className={inputCls} value={plan.features.tasks} onChange={e => update(i, 'features.tasks', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Aufgaben-Beschreibung</label>
|
||||
<input className={inputCls} value={plan.features.taskDescription} onChange={e => update(i, 'features.taskDescription', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Features (eine pro Zeile)</label>
|
||||
<textarea className={`${inputCls} font-mono`} rows={3} value={plan.features.included.join('\n')} onChange={e => update(i, 'features.included', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Trust Editor ────────────────────────────────────────────────────────────
|
||||
|
||||
function TrustEditor({ content, setContent }: EditorProps) {
|
||||
function update(key: 'item1' | 'item2' | 'item3', field: 'value' | 'label', val: string) {
|
||||
setContent(c => c ? { ...c, trust: { ...c.trust, [key]: { ...c.trust[key], [field]: val } } } : c)
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['item1', 'item2', 'item3'] as const).map((key, i) => (
|
||||
<div key={key} className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div>
|
||||
<label className={labelCls}>Wert {i + 1}</label>
|
||||
<input className={inputCls} value={content.trust[key].value} onChange={e => update(key, 'value', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Label {i + 1}</label>
|
||||
<input className={inputCls} value={content.trust[key].label} onChange={e => update(key, 'label', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Testimonial Editor ──────────────────────────────────────────────────────
|
||||
|
||||
function TestimonialEditor({ content, setContent }: EditorProps) {
|
||||
function update(field: 'quote' | 'author' | 'role', value: string) {
|
||||
setContent(c => c ? { ...c, testimonial: { ...c.testimonial, [field]: value } } : c)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className={labelCls}>Zitat</label>
|
||||
<textarea className={inputCls} rows={3} value={content.testimonial.quote} onChange={e => update('quote', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Autor</label>
|
||||
<input className={inputCls} value={content.testimonial.author} onChange={e => update('author', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Rolle</label>
|
||||
<input className={inputCls} value={content.testimonial.role} onChange={e => update('role', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
admin-v2/app/(admin)/website/page.tsx
Normal file
51
admin-v2/app/(admin)/website/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function WebsitePage() {
|
||||
const category = getCategoryById('website')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Website Content & Management. Verwalten Sie Inhalte, Uebersetzungen und das CMS."
|
||||
audience={['Content Manager', 'Entwickler']}
|
||||
architecture={{
|
||||
services: ['website (Next.js)'],
|
||||
databases: [],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-sky-50 border border-sky-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-sky-800 flex items-center gap-2">
|
||||
<span>🌐</span>
|
||||
Website CMS
|
||||
</h3>
|
||||
<p className="text-sm text-sky-700 mt-2">
|
||||
Die BreakPilot Website wird ueber ein visuelles CMS verwaltet.
|
||||
Inhalte koennen direkt bearbeitet und in mehrere Sprachen uebersetzt werden.
|
||||
Aenderungen werden nach dem Speichern sofort auf der Website sichtbar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
769
admin-v2/app/(admin)/website/uebersetzungen/page.tsx
Normal file
769
admin-v2/app/(admin)/website/uebersetzungen/page.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Uebersetzungen - Website Content Editor
|
||||
*
|
||||
* Allows editing all website texts:
|
||||
* - Hero Section
|
||||
* - Features
|
||||
* - FAQ
|
||||
* - Pricing
|
||||
* - Trust Indicators
|
||||
* - Testimonial
|
||||
*
|
||||
* Includes Live-Preview of website
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { WebsiteContent, HeroContent, FeatureContent } from '@/lib/content-types'
|
||||
|
||||
// Admin Key (in production via login)
|
||||
const ADMIN_KEY = 'breakpilot-admin-2024'
|
||||
|
||||
// Mapping tabs to website sections
|
||||
const SECTION_MAP: Record<string, { selector: string; scrollTo: string }> = {
|
||||
hero: { selector: '#hero', scrollTo: 'hero' },
|
||||
features: { selector: '#features', scrollTo: 'features' },
|
||||
faq: { selector: '#faq', scrollTo: 'faq' },
|
||||
pricing: { selector: '#pricing', scrollTo: 'pricing' },
|
||||
other: { selector: '#trust', scrollTo: 'trust' },
|
||||
}
|
||||
|
||||
export default function UebersetzungenPage() {
|
||||
const [content, setContent] = useState<WebsiteContent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'hero' | 'features' | 'faq' | 'pricing' | 'other'>('hero')
|
||||
const [showPreview, setShowPreview] = useState(true)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
// Scroll preview to section
|
||||
const scrollToSection = useCallback((tab: string) => {
|
||||
if (!iframeRef.current?.contentWindow) return
|
||||
const section = SECTION_MAP[tab]
|
||||
if (section) {
|
||||
try {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: 'scrollTo', section: section.scrollTo },
|
||||
'*'
|
||||
)
|
||||
} catch {
|
||||
// Same-origin policy - fallback
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll to section on tab change
|
||||
useEffect(() => {
|
||||
scrollToSection(activeTab)
|
||||
}, [activeTab, scrollToSection])
|
||||
|
||||
// Load content
|
||||
useEffect(() => {
|
||||
loadContent()
|
||||
}, [])
|
||||
|
||||
async function loadContent() {
|
||||
try {
|
||||
const res = await fetch('/api/website/content')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setContent(data)
|
||||
} else {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Laden' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Laden' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!content) return
|
||||
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/website/content', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-admin-key': ADMIN_KEY,
|
||||
},
|
||||
body: JSON.stringify(content),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Gespeichert!' })
|
||||
} else {
|
||||
const error = await res.json()
|
||||
setMessage({ type: 'error', text: error.error || 'Fehler beim Speichern' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Speichern' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Hero Section update
|
||||
function updateHero(field: keyof HeroContent, value: string) {
|
||||
if (!content) return
|
||||
setContent({
|
||||
...content,
|
||||
hero: { ...content.hero, [field]: value },
|
||||
})
|
||||
}
|
||||
|
||||
// Feature update
|
||||
function updateFeature(index: number, field: keyof FeatureContent, value: string) {
|
||||
if (!content) return
|
||||
const newFeatures = [...content.features]
|
||||
newFeatures[index] = { ...newFeatures[index], [field]: value }
|
||||
setContent({ ...content, features: newFeatures })
|
||||
}
|
||||
|
||||
// FAQ update
|
||||
function updateFAQ(index: number, field: 'question' | 'answer', value: string | string[]) {
|
||||
if (!content) return
|
||||
const newFAQ = [...content.faq]
|
||||
if (field === 'answer' && typeof value === 'string') {
|
||||
newFAQ[index] = { ...newFAQ[index], answer: value.split('\n') }
|
||||
} else if (field === 'question' && typeof value === 'string') {
|
||||
newFAQ[index] = { ...newFAQ[index], question: value }
|
||||
}
|
||||
setContent({ ...content, faq: newFAQ })
|
||||
}
|
||||
|
||||
// Add FAQ
|
||||
function addFAQ() {
|
||||
if (!content) return
|
||||
setContent({
|
||||
...content,
|
||||
faq: [...content.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }],
|
||||
})
|
||||
}
|
||||
|
||||
// Remove FAQ
|
||||
function removeFAQ(index: number) {
|
||||
if (!content) return
|
||||
const newFAQ = content.faq.filter((_, i) => i !== index)
|
||||
setContent({ ...content, faq: newFAQ })
|
||||
}
|
||||
|
||||
// Pricing update
|
||||
function updatePricing(index: number, field: string, value: string | number | boolean) {
|
||||
if (!content) return
|
||||
const newPricing = [...content.pricing]
|
||||
if (field === 'price') {
|
||||
newPricing[index] = { ...newPricing[index], price: Number(value) }
|
||||
} else if (field === 'popular') {
|
||||
newPricing[index] = { ...newPricing[index], popular: Boolean(value) }
|
||||
} else if (field.startsWith('features.')) {
|
||||
const subField = field.replace('features.', '')
|
||||
if (subField === 'included' && typeof value === 'string') {
|
||||
newPricing[index] = {
|
||||
...newPricing[index],
|
||||
features: {
|
||||
...newPricing[index].features,
|
||||
included: value.split('\n'),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
newPricing[index] = {
|
||||
...newPricing[index],
|
||||
features: {
|
||||
...newPricing[index].features,
|
||||
[subField]: value,
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newPricing[index] = { ...newPricing[index], [field]: value }
|
||||
}
|
||||
setContent({ ...content, pricing: newPricing })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-xl text-slate-600">Laden...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-xl text-red-600">Fehler beim Laden</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-lg font-semibold text-slate-900">Uebersetzungen</h1>
|
||||
{/* Preview Toggle */}
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
showPreview
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
title={showPreview ? 'Preview ausblenden' : 'Preview einblenden'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Live-Preview
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{message && (
|
||||
<span
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={saving}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
|
||||
{(['hero', 'features', 'faq', 'pricing', 'other'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{tab === 'hero' && 'Hero'}
|
||||
{tab === 'features' && 'Features'}
|
||||
{tab === 'faq' && 'FAQ'}
|
||||
{tab === 'pricing' && 'Preise'}
|
||||
{tab === 'other' && 'Sonstige'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split Layout: Editor + Preview */}
|
||||
<div className={`grid gap-6 ${showPreview ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Editor Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 max-h-[calc(100vh-280px)] overflow-y-auto">
|
||||
{/* Hero Tab */}
|
||||
{activeTab === 'hero' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Hero Section</h2>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Badge</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.badge}
|
||||
onChange={(e) => updateHero('badge', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Titel (vor Highlight)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.title}
|
||||
onChange={(e) => updateHero('title', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Highlight 1
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.titleHighlight1}
|
||||
onChange={(e) => updateHero('titleHighlight1', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Highlight 2
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.titleHighlight2}
|
||||
onChange={(e) => updateHero('titleHighlight2', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Untertitel</label>
|
||||
<textarea
|
||||
value={content.hero.subtitle}
|
||||
onChange={(e) => updateHero('subtitle', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
CTA Primaer
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.ctaPrimary}
|
||||
onChange={(e) => updateHero('ctaPrimary', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
CTA Sekundaer
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.ctaSecondary}
|
||||
onChange={(e) => updateHero('ctaSecondary', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Hinweis</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.ctaHint}
|
||||
onChange={(e) => updateHero('ctaHint', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features Tab */}
|
||||
{activeTab === 'features' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Features</h2>
|
||||
|
||||
{content.features.map((feature, index) => (
|
||||
<div key={feature.id} className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Icon</label>
|
||||
<input
|
||||
type="text"
|
||||
value={feature.icon}
|
||||
onChange={(e) => updateFeature(index, 'icon', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-2xl text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={feature.title}
|
||||
onChange={(e) => updateFeature(index, 'title', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={feature.description}
|
||||
onChange={(e) => updateFeature(index, 'description', e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FAQ Tab */}
|
||||
{activeTab === 'faq' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-900">FAQ</h2>
|
||||
<button
|
||||
onClick={addFAQ}
|
||||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
+ Frage hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{content.faq.map((item, index) => (
|
||||
<div key={index} className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Frage {index + 1}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.question}
|
||||
onChange={(e) => updateFAQ(index, 'question', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Antwort
|
||||
</label>
|
||||
<textarea
|
||||
value={item.answer.join('\n')}
|
||||
onChange={(e) => updateFAQ(index, 'answer', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFAQ(index)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Frage entfernen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pricing Tab */}
|
||||
{activeTab === 'pricing' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Preise</h2>
|
||||
|
||||
{content.pricing.map((plan, index) => (
|
||||
<div key={plan.id} className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.name}
|
||||
onChange={(e) => updatePricing(index, 'name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Preis (EUR)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={plan.price}
|
||||
onChange={(e) => updatePricing(index, 'price', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Intervall
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.interval}
|
||||
onChange={(e) => updatePricing(index, 'interval', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plan.popular || false}
|
||||
onChange={(e) => updatePricing(index, 'popular', e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 rounded"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">Beliebt</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.description}
|
||||
onChange={(e) => updatePricing(index, 'description', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Aufgaben
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.features.tasks}
|
||||
onChange={(e) => updatePricing(index, 'features.tasks', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Aufgaben-Beschreibung
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.features.taskDescription}
|
||||
onChange={(e) =>
|
||||
updatePricing(index, 'features.taskDescription', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Features (eine pro Zeile)
|
||||
</label>
|
||||
<textarea
|
||||
value={plan.features.included.join('\n')}
|
||||
onChange={(e) => updatePricing(index, 'features.included', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Tab */}
|
||||
{activeTab === 'other' && (
|
||||
<div className="space-y-8">
|
||||
{/* Trust Indicators */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4">Trust Indicators</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(['item1', 'item2', 'item3'] as const).map((key, index) => (
|
||||
<div key={key} className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Wert {index + 1}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.trust[key].value}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
trust: {
|
||||
...content.trust,
|
||||
[key]: { ...content.trust[key], value: e.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Label {index + 1}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.trust[key].label}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
trust: {
|
||||
...content.trust,
|
||||
[key]: { ...content.trust[key], label: e.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4">Testimonial</h2>
|
||||
<div className="border border-slate-200 rounded-lg p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Zitat</label>
|
||||
<textarea
|
||||
value={content.testimonial.quote}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
testimonial: { ...content.testimonial, quote: e.target.value },
|
||||
})
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Autor</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.testimonial.author}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
testimonial: { ...content.testimonial, author: e.target.value },
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Rolle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.testimonial.role}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
testimonial: { ...content.testimonial, role: e.target.value },
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Preview Panel */}
|
||||
{showPreview && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
{/* Preview Header */}
|
||||
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-400"></div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 ml-2">breakpilot.app</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-600 bg-slate-200 px-2 py-1 rounded">
|
||||
{activeTab === 'hero' && 'Hero Section'}
|
||||
{activeTab === 'features' && 'Features'}
|
||||
{activeTab === 'faq' && 'FAQ'}
|
||||
{activeTab === 'pricing' && 'Pricing'}
|
||||
{activeTab === 'other' && 'Trust & Testimonial'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
|
||||
className="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded transition-colors"
|
||||
title="Preview neu laden"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview Frame */}
|
||||
<div className="relative h-[calc(100vh-340px)] bg-slate-100">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`https://macmini:3000/?preview=true§ion=${activeTab}#${activeTab}`}
|
||||
className="w-full h-full border-0 scale-75 origin-top-left"
|
||||
style={{
|
||||
width: '133.33%',
|
||||
height: '133.33%',
|
||||
transform: 'scale(0.75)',
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
title="Website Preview"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
/>
|
||||
{/* Section Indicator */}
|
||||
<div className="absolute bottom-4 left-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
Du bearbeitest: <strong>
|
||||
{activeTab === 'hero' && 'Hero Section (Startbereich)'}
|
||||
{activeTab === 'features' && 'Features (Funktionen)'}
|
||||
{activeTab === 'faq' && 'FAQ (Haeufige Fragen)'}
|
||||
{activeTab === 'pricing' && 'Pricing (Preise)'}
|
||||
{activeTab === 'other' && 'Trust & Testimonial'}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user