Template Upload Workflow
Ziel: Content Creator lädt hoch → System validiert → Template landet zur Prüfung
Content-Creator-Workflow (vor dem Upload)
- Prompt kopieren – Aus Website Template Prompt oder Kiosk Template Prompt
- Screenshots – Sektionsweise (Hero, Karten, Footer) – besser als Ganzseite
- v0 – Screenshots + Prompt einfügen → v0 generiert
- ZIP herunterladen – Von v0
v0-Optimierungen (empfohlen)
- Prompt: Kopiere den Prompt aus dem Website Template Prompt – Screenshots + Prompt einfügen, v0 generiert das Design.
- Sources: Füge die CockpitOS-Dokumentation oder
types.tsals Source in dein v0-Projekt ein – v0 nutzt sie als Kontext für präzisere Generierungen. - Cursor + v0: Du kannst v0 als Modell in Cursor nutzen (v0 API Key, Endpoint
https://api.v0.dev/v1, Modellev0-1.5-md/v0-1.5-lg) – dann generierst du direkt im Projekt.
Template-Typen
1. Website Templates (Center-Website)
- Zweck: Öffentliche Center-Website (z.B. palais-vest.de)
- Struktur: Gleiche Seiten wie CockpitOS (Home, Shops, Centerplan, Services, News, Impressum, etc.)
- Speicherort:
apps/center-website/components/templates/[name]/ - DB-Feld:
ShoppingCenter.websiteTemplate(geplant; Template-Selector im Dashboard fehlt noch) - Verwendung: Multi-Tenant Website für Besucher
Hinweis: Template-Selector für Website-Templates (pro Center/Organisation) ist noch nicht implementiert. Kiosk-Templates haben einen Selector; Website-Templates werden derzeit manuell integriert.
2. Kiosk Templates (Digital Signage)
- Zweck: Touchscreen-Kioske im Center
- Props:
config,shops,services,content,kioskConfig - Speicherort:
apps/digital-signage/src/components/templates/[name]/ - DB-Feld:
ShoppingCenter.kioskTemplate - Verwendung: Interaktive Kioske mit Screens (Home, Shops, Centerplan, etc.)
Wichtig: Beim Upload muss der Template-Typ ausgewählt werden!
Workflow-Übersicht
Content-Creator
↓
1. Template-ZIP hochladen (Dashboard → Templates → Upload)
2. Template-Typ wählen (Website oder Kiosk)
3. Name vergeben (z.B. template-modern) – bei neuem Template
4. Automatische Validierung
5. Preview (falls generiert)
6. Einreichen → ZIP bleibt in BunnyCDN, Metadaten in DB
↓
Du (Entwickler/Cursor)
↓
7. ZIP herunterladen (Dashboard → Templates → Status → "ZIP herunterladen")
8. Lokal entpacken, Code prüfen, Anpassungen machen
9. Verschieben nach apps/center-website/components/templates/ (oder digital-signage)
10. Commit & Push
↓
11. Template ist nutzbar (nach Freigabe/Integration)
Warum kein Server-Dateisystem?
- Render hat ephemeralen Speicher – bei jedem Deploy ist der Inhalt weg.
- Dashboard und Center-Website sind separate Render-Services – selbst mit Persistent Disk am Dashboard würde die Center-Website die Templates nie sehen.
- Templates müssen im Git-Repo liegen, damit sie beim Build der Center-Website mit in den Bundle kommen.
- Lösung: ZIP bleibt in BunnyCDN (persistent). Developer lädt ZIP herunter, entpackt lokal, verschiebt ins Repo, commit & push.
Dashboard UI: Template Upload (vereinfacht)
Seite: /dashboard/templates/upload
// apps/dashboard/src/app/dashboard/templates/upload/page.tsx
'use client';
import { useState } from 'react';
import { FileUpload } from '@/components/file-upload';
import { Button } from '@mall-os/ui';
import { Card, CardContent, CardHeader, CardTitle } from '@mall-os/ui';
import { toast } from 'sonner';
import { CheckCircle, XCircle } from 'lucide-react';
type TemplateType = 'website' | 'kiosk';
export default function TemplateUploadPage() {
const [templateType, setTemplateType] = useState<TemplateType>('website');
const [uploadedZip, setUploadedZip] = useState<string | null>(null);
const [validationResult, setValidationResult] = useState<any>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isUpdate, setIsUpdate] = useState(false);
const [existingTemplateName, setExistingTemplateName] = useState<string | null>(null);
const handleZipUpload = async (fileUrl: string) => {
setUploadedZip(fileUrl);
// Automatische Validierung starten
const res = await fetch('/api/templates/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
zipUrl: fileUrl,
templateType: templateType // website oder kiosk
})
});
const { validation } = await res.json();
setValidationResult(validation);
if (validation.valid) {
// Preview generieren
const previewRes = await fetch('/api/templates/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zipUrl: fileUrl })
});
const { previewUrl: preview } = await previewRes.json();
setPreviewUrl(preview);
}
};
const handleSubmit = async () => {
if (!validationResult?.valid) {
toast.error('Template-Validierung fehlgeschlagen');
return;
}
setIsSubmitting(true);
try {
// Template zur Review einreichen (wird in templates-pending/ gespeichert)
const res = await fetch('/api/templates/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
zipUrl: uploadedZip,
validation: validationResult,
previewUrl,
templateType: templateType, // website oder kiosk
isUpdate: isUpdate,
existingTemplateName: isUpdate ? existingTemplateName : null
})
});
const result = await res.json();
if (result.success) {
toast.success(`Template wurde gespeichert! Du findest es in: templates-pending/${result.templateName}`);
// Weiterleitung zu Status-Seite
window.location.href = `/dashboard/templates/status/${result.templateName}`;
}
} catch (error) {
toast.error('Fehler beim Speichern des Templates');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-4xl">
<h1 className="text-3xl font-bold mb-6">
{isUpdate ? 'Template aktualisieren' : 'Neues Template hochladen'}
</h1>
{/* Template Type Selection */}
<Card className="mb-6">
<CardHeader>
<CardTitle>1. Template-Typ auswählen</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setTemplateType('website')}
className={`p-4 border-2 rounded-lg text-left transition-all ${
templateType === 'website'
? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50'
}`}
>
<h3 className="font-semibold mb-2">Website Template</h3>
<p className="text-sm text-muted-foreground">
Für öffentliche Center-Websites (z.B. palais-vest.de)
</p>
</button>
<button
onClick={() => setTemplateType('kiosk')}
className={`p-4 border-2 rounded-lg text-left transition-all ${
templateType === 'kiosk'
? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50'
}`}
>
<h3 className="font-semibold mb-2">📺 Kiosk Template</h3>
<p className="text-sm text-muted-foreground">
Für Touchscreen-Kioske im Center
</p>
</button>
</div>
</CardContent>
</Card>
{/* Update Existing Template */}
<Card className="mb-6">
<CardHeader>
<CardTitle>2. Neues Template oder Update?</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-4">
<button
onClick={() => {
setIsUpdate(false);
setExistingTemplateName(null);
}}
className={`px-4 py-2 rounded-lg border-2 ${
!isUpdate
? 'border-primary bg-primary/5'
: 'border-muted'
}`}
>
Neues Template
</button>
<button
onClick={() => setIsUpdate(true)}
className={`px-4 py-2 rounded-lg border-2 ${
isUpdate
? 'border-primary bg-primary/5'
: 'border-muted'
}`}
>
Bestehendes Template aktualisieren
</button>
</div>
{isUpdate && (
<div className="mt-4">
<label className="block text-sm font-medium mb-2">
Template-Name (bestehend)
</label>
<input
type="text"
value={existingTemplateName || ''}
onChange={(e) => setExistingTemplateName(e.target.value)}
placeholder="z.B. template-modern"
className="w-full px-3 py-2 border rounded-lg"
/>
<p className="text-xs text-muted-foreground mt-2">
Name des Templates, das aktualisiert werden soll
</p>
</div>
)}
</CardContent>
</Card>
{/* Upload Section */}
<Card className="mb-6">
<CardHeader>
<CardTitle>3. Template-ZIP hochladen</CardTitle>
</CardHeader>
<CardContent>
<FileUpload
type="document"
accept=".zip"
maxSize={50}
onUpload={handleZipUpload}
currentValue={uploadedZip || undefined}
/>
<p className="text-sm text-muted-foreground mt-4">
Lade die ZIP-Datei hoch, die du von v0 heruntergeladen hast.
Die Datei wird automatisch validiert und zur Prüfung gespeichert.
</p>
</CardContent>
</Card>
{/* Validation Results */}
{validationResult && (
<Card className="mb-6">
<CardHeader>
<CardTitle>4. Validierung</CardTitle>
</CardHeader>
<CardContent>
{validationResult.valid ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-5 w-5" />
<span className="font-semibold">Template-Struktur ist korrekt</span>
</div>
<ul className="list-disc list-inside text-sm space-y-1 mt-4">
{validationResult.checks.map((check: any) => (
<li key={check.name} className={check.passed ? 'text-green-600' : 'text-red-600'}>
{check.name}: {check.passed ? 'OK' : 'FEHLER'}
{check.message && <span className="text-muted-foreground ml-2">({check.message})</span>}
</li>
))}
</ul>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-red-600">
<XCircle className="h-5 w-5" />
<span className="font-semibold">Validierung fehlgeschlagen</span>
</div>
<ul className="list-disc list-inside text-sm space-y-1 mt-4">
{validationResult.errors.map((error: string) => (
<li key={error} className="text-red-600">{error}</li>
))}
</ul>
<p className="text-sm text-muted-foreground mt-4">
Bitte korrigiere die Fehler in v0 und lade das Template erneut hoch.
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Preview */}
{previewUrl && (
<Card className="mb-6">
<CardHeader>
<CardTitle>5. Vorschau</CardTitle>
</CardHeader>
<CardContent>
<iframe
src={previewUrl}
className="w-full h-[600px] border rounded-lg"
title="Template Preview"
/>
<p className="text-sm text-muted-foreground mt-4">
So sieht dein Template aus. Du kannst es mit Beispieldaten testen.
</p>
</CardContent>
</Card>
)}
{/* Submit Button */}
{validationResult?.valid && (
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => window.location.reload()}>
Abbrechen
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? 'Speichere...' : 'Zur Prüfung einreichen'}
</Button>
</div>
)}
</div>
);
}
API: Template Validierung & Speicherung
/api/templates/validate (gleich wie vorher)
/api/templates/submit (vereinfacht - kein Git)
// apps/dashboard/src/app/api/templates/submit/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { unzip } from 'unzipit';
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { prisma } from '@mall-os/database';
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { zipUrl, validation, previewUrl, templateType, isUpdate, existingTemplateName } = await request.json();
// Template-Typ bestimmen (website oder kiosk)
const type = templateType || 'website';
const basePath = type === 'website'
? 'apps/center-website/components/templates'
: 'apps/digital-signage/src/components/templates';
// Template-Name bestimmen
let templateName: string;
if (isUpdate && existingTemplateName) {
// Update: Gleicher Name, Version erhöhen
templateName = existingTemplateName;
} else {
// Neu: Neuer Name mit Timestamp
templateName = `template-${Date.now()}`;
}
// 1. ZIP herunterladen
const zipResponse = await fetch(zipUrl);
const zipBuffer = await zipResponse.arrayBuffer();
// 2. Template-Ordner bestimmen
const templateDir = join(process.cwd(), 'templates-pending', type, templateName);
// 3. Ordner erstellen
mkdirSync(templateDir, { recursive: true });
// 4. ZIP extrahieren
const { entries } = await unzip(zipBuffer);
// 5. Alle Dateien schreiben
for (const [path, entry] of Object.entries(entries)) {
const content = await entry.text();
const filePath = join(templateDir, path);
// Unterordner erstellen falls nötig
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
if (dir !== templateDir) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(filePath, content, 'utf-8');
}
// 6. Template-Metadaten speichern (in DB)
const submissionData: any = {
id: templateName,
name: templateName,
type: type, // website oder kiosk
status: 'pending',
submittedBy: session.user.id,
zipUrl,
previewUrl: previewUrl || null,
validation: validation,
templatePath: `templates-pending/${type}/${templateName}`,
targetPath: `${basePath}/${templateName}`
};
if (isUpdate) {
submissionData.isUpdate = true;
submissionData.existingTemplateName = existingTemplateName;
submissionData.version = await getNextVersion(existingTemplateName, type);
}
await prisma.templateSubmission.create({
data: submissionData
});
return NextResponse.json({
success: true,
templateName,
templateType: type,
templatePath: `templates-pending/${type}/${templateName}`,
targetPath: `${basePath}/${templateName}`,
isUpdate,
message: isUpdate
? `Template-Update wurde gespeichert in: templates-pending/${type}/${templateName}`
: `Template wurde gespeichert in: templates-pending/${type}/${templateName}`
});
} catch (error) {
console.error('Error submitting template:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}
Template Status Seite (für Content-Creator)
// apps/dashboard/src/app/dashboard/templates/status/[name]/page.tsx
'use client';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@mall-os/ui';
import { Badge } from '@mall-os/ui';
export default function TemplateStatusPage() {
const params = useParams();
const templateName = params.name as string;
const [template, setTemplate] = useState<any>(null);
useEffect(() => {
loadTemplate();
}, [templateName]);
const loadTemplate = async () => {
const res = await fetch(`/api/templates/${templateName}`);
const data = await res.json();
setTemplate(data.template);
};
if (!template) return <div>Lade...</div>;
return (
<div className="container mx-auto py-8 max-w-4xl">
<h1 className="text-3xl font-bold mb-6">Template Status</h1>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{template.name}</CardTitle>
<Badge variant={
template.status === 'pending' ? 'warning' :
template.status === 'approved' ? 'success' :
'destructive'
}>
{template.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-semibold">
{template.status === 'pending' && 'Wartet auf Prüfung'}
{template.status === 'approved' && 'Wurde genehmigt und ist verfügbar'}
{template.status === 'rejected' && 'Wurde abgelehnt'}
</p>
</div>
{template.previewUrl && (
<div>
<p className="text-sm text-muted-foreground mb-2">Vorschau</p>
<iframe
src={template.previewUrl}
className="w-full h-[400px] border rounded-lg"
/>
</div>
)}
{template.status === 'rejected' && template.rejectionReason && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm font-semibold text-red-800 mb-2">Ablehnungsgrund:</p>
<p className="text-sm text-red-700">{template.rejectionReason}</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}
Template-Ordner-Struktur
Nach Upload sieht dein Codebase so aus:
templates-pending/
├── website/ # Website Templates
│ ├── template-1234567890/
│ │ ├── index.ts
│ │ ├── Template.tsx
│ │ ├── types.ts
│ │ ├── components/
│ │ │ ├── Header.tsx
│ │ │ ├── Hero.tsx
│ │ │ ├── TileGrid.tsx
│ │ │ └── Footer.tsx
│ │ ├── DATA_REQUIREMENTS.md
│ │ ├── IMPLEMENTATION_NOTES.md
│ │ └── README.md
│ └── template-modern/ # Update eines bestehenden Templates
│ └── ... (neue Version)
└── kiosk/ # Kiosk Templates
├── template-1234567891/
│ ├── index.ts
│ ├── KioskTemplate.tsx
│ ├── screens/
│ │ ├── HomeScreen.tsx
│ │ ├── ShopsScreen.tsx
│ │ └── ...
│ └── ...
└── ...
Wichtig: Templates sind nach Typ getrennt (website/ und kiosk/)
Dein Workflow in Cursor
1. Template in Cursor öffnen
1. Cursor öffnen
2. templates-pending/website/template-1234567890/ öffnen (für Website Template)
ODER
templates-pending/kiosk/template-1234567891/ öffnen (für Kiosk Template)
3. Code prüfen (Review-Checklist verwenden)
2. Template prüfen & anpassen
# In Cursor:
# - Code durchgehen
# - Kleine Anpassungen machen
# - Testen falls nötig
3. Template aktivieren
Option A: Manuell verschieben
# Website Template:
mv templates-pending/website/template-1234567890 apps/center-website/components/templates/template-name
# Kiosk Template:
mv templates-pending/kiosk/template-1234567891 apps/digital-signage/src/components/templates/template-name
Option B: Über Dashboard
// Dashboard Button: "Template aktivieren"
// Verschiebt automatisch an den richtigen Ort basierend auf Template-Typ
4. Commit & Push (wie gewohnt)
# In Cursor:
git add apps/center-website/components/templates/template-name
git commit -m "feat: Template template-name hinzugefügt"
git push
5. Template im Dashboard registrieren
// Einmalig: Template zur Liste hinzufügen
// apps/dashboard/src/components/center/website-configuration/template-section.tsx
const templates = [
// ... bestehende Templates
{
id: 'template-name',
name: 'Template Name',
description: 'Beschreibung',
available: true,
}
];
Vereinfachter Workflow
Für Content-Creator:
- ZIP hochladen (Drag & Drop)
- Validierung sehen
- Preview sehen
- Einreichen → Template landet in
templates-pending/
Für dich (Cursor):
- Template in Cursor öffnen (
templates-pending/template-xxx/) - Code prüfen (Review-Checklist)
- Anpassungen machen falls nötig
- Verschieben nach
apps/center-website/components/templates/ - Commit & Push (wie gewohnt)
- Template ist live
Kein Git-Branching, kein PR-System nötig!
Datenbank-Schema (erweitert)
model TemplateSubmission {
id String @id @default(cuid())
name String
type String // 'website' oder 'kiosk'
status String // pending, approved, rejected
submittedBy String
submittedAt DateTime @default(now())
zipUrl String
previewUrl String?
validation Json
templatePath String // templates-pending/website/template-xxx oder templates-pending/kiosk/template-xxx
targetPath String // apps/center-website/components/templates/... oder apps/digital-signage/src/components/templates/...
// Update-Felder
isUpdate Boolean @default(false)
existingTemplateName String? // Name des Templates, das aktualisiert wird
version Int? // Versionsnummer (bei Updates)
approvedBy String?
approvedAt DateTime?
rejectedBy String?
rejectedAt DateTime?
rejectionReason String?
templateId String? // Nach Approval: ID des aktiven Templates
}
Template-Updates
Wenn Content-Creator Änderungen in v0 macht:
Option 1: Neues Template hochladen (empfohlen)
- Content-Creator macht Änderungen in v0
- Neues ZIP herunterladen
- Im Dashboard: "Bestehendes Template aktualisieren" auswählen
- Template-Name eingeben (z.B.
template-modern) - Upload → Neue Version landet in
templates-pending/website/template-modern/ - Du prüfst die Änderungen
- Verschiebst neue Version nach
apps/center-website/components/templates/template-modern/ - Commit & Push
Option 2: Versionierung
- System erhöht automatisch Versionsnummer
- Alte Version bleibt als Backup
- Neue Version wird aktiv nach Approval
Hinweis: Direktes Bearbeiten im Code ist keine Option für die Content-Creator. Alle Änderungen erfolgen über v0 → Upload → Review.
Nächste Schritte
- Template Upload UI (mit Typ-Auswahl: Website/Kiosk)
- Update-Option für bestehende Templates
- Validierung API (unterschiedlich für Website/Kiosk)
- Speicherung in
templates-pending/[type]/ - Status-Seite für Content-Creator
- Optional: "Aktivieren"-Button im Dashboard (verschiebt Template an richtigen Ort)
Mit diesem System:
- Content-Creator: Typ wählen → Upload → Fertig (oder Update bestehendes Template)
- Du: Template in Cursor öffnen → Prüfen → Verschieben → Commit → Fertig
Template-Updates:
- Content-Creator lädt neue Version hoch → Du prüfst Änderungen → Verschiebst neue Version → Fertig
Kein komplexes Git-System nötig!
Zusammenfassung: Template-Typen & Updates
Template-Typen:
- Website Templates:
templates-pending/website/→apps/center-website/components/templates/ - Kiosk Templates:
templates-pending/kiosk/→apps/digital-signage/src/components/templates/
Updates:
- Neues Template: Neuer Name mit Timestamp
- Update bestehendes: Gleicher Name, neue Version
- Nach Prüfung: Verschieben an richtigen Ort basierend auf Typ
Nutzungsstatistik: Seitenaufrufe werden anonymisiert erfasst. Im Umami-Dashboard nach diesem Pfad filtern: /templates/upload