Compare commits
2 Commits
009b167cbd
...
682863ab26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
682863ab26 | ||
|
|
9ecdc645e8 |
@@ -8,11 +8,16 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Download, Upload, Database, AlertTriangle, Loader2, KeyRound,
|
||||
Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi, Sliders, ShieldAlert, FileArchive
|
||||
Download, Upload, Database, Loader2, KeyRound,
|
||||
Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi, Sliders, ShieldAlert, FileArchive,
|
||||
Building2, MapPin, Receipt, Palette, Save
|
||||
} from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
|
||||
import { getBrandingSettings, saveBrandingSettings } from '@/lib/actions/branding';
|
||||
import type { BrandingSettings } from '@/lib/constants/branding';
|
||||
import { ColorThemePicker } from '@/components/admin/ColorThemePicker';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const containerVariants = {
|
||||
@@ -56,6 +61,25 @@ export default function AdminSettings() {
|
||||
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [licMsg, setLicMsg] = useState('');
|
||||
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
|
||||
|
||||
// Branding State
|
||||
const [branding, setBranding] = useState<BrandingSettings>({
|
||||
companyName: '',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
});
|
||||
const [brandingSaving, setBrandingSaving] = useState(false);
|
||||
const [brandingMsg, setBrandingMsg] = useState('');
|
||||
const [brandingMsgType, setBrandingMsgType] = useState<'success' | 'error' | ''>('');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,10 +92,10 @@ export default function AdminSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parallele Abfrage von User-Rolle & LicServer Settings
|
||||
const [userRes, licRes] = await Promise.all([
|
||||
const [userRes, licRes, brandRes] = await Promise.all([
|
||||
supabase.from('users').select('role').eq('id', user.id).single(),
|
||||
supabase.from('settings').select('licserver_base_url, licserver_api_key').eq('id', 'licserver').maybeSingle()
|
||||
supabase.from('settings').select('licserver_base_url, licserver_api_key').eq('id', 'licserver').maybeSingle(),
|
||||
getBrandingSettings()
|
||||
]);
|
||||
|
||||
if (userRes.error || !userRes.data || userRes.data.role === 'verwaltung') {
|
||||
@@ -86,6 +110,10 @@ export default function AdminSettings() {
|
||||
setLicUrl(licRes.data.licserver_base_url || '');
|
||||
setLicKey(licRes.data.licserver_api_key || '');
|
||||
}
|
||||
|
||||
if (brandRes) {
|
||||
setBranding(brandRes);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fehler bei checkAccess in Einstellungen:", err);
|
||||
} finally {
|
||||
@@ -161,6 +189,29 @@ export default function AdminSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const { refreshBranding } = useTheme();
|
||||
|
||||
const handleSaveBranding = async () => {
|
||||
setBrandingSaving(true);
|
||||
setBrandingMsg('');
|
||||
try {
|
||||
const res = await saveBrandingSettings(branding);
|
||||
if (res.success) {
|
||||
await refreshBranding();
|
||||
setBrandingMsgType('success');
|
||||
setBrandingMsg('Firmendaten & Farbschema erfolgreich gespeichert.');
|
||||
} else {
|
||||
setBrandingMsgType('error');
|
||||
setBrandingMsg(res.error || 'Fehler beim Speichern.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setBrandingMsgType('error');
|
||||
setBrandingMsg(err.message || 'Fehler beim Speichern.');
|
||||
} finally {
|
||||
setBrandingSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
@@ -176,7 +227,7 @@ export default function AdminSettings() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Zentrales Bento-Dashboard für System-, Backup- und Lizenz-Konfigurationen.
|
||||
Zentrales Dashboard für Firmendaten, Branding, Backup & Lizenz-Konfigurationen.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -203,7 +254,7 @@ export default function AdminSettings() {
|
||||
animate="visible"
|
||||
className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-5"
|
||||
>
|
||||
{/* Bento Item 1: System Modus & Banner (1 Spalte) */}
|
||||
{/* Bento Item 1: System Modus & Banner */}
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
whileHover={{ y: -3 }}
|
||||
@@ -226,7 +277,7 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Item 2: Daten Export (1 Spalte) */}
|
||||
{/* Bento Item 2: Daten Export */}
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
whileHover={{ y: -3 }}
|
||||
@@ -239,7 +290,7 @@ export default function AdminSettings() {
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">DB Backup</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Lädt alle Produkte, Firmen, Lizenzen & Einstellungen als ZIP-Archiv herunter.
|
||||
Lädt alle Produkte, Firmen, Lizenzen & Einstellungen als ZIP-Archiv herunter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,7 +307,7 @@ export default function AdminSettings() {
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Item 3: Daten Import (2 Spalten / Span 2 auf Desktop) */}
|
||||
{/* Bento Item 3: Daten Import */}
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
whileHover={{ y: -3 }}
|
||||
@@ -309,7 +360,214 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Item 4: LicServer Konfiguration (Breites Bento: 4 Spalten auf Large) */}
|
||||
{/* Bento Item 4: Firmendaten & Anschriften (4 Spalten) */}
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
whileHover={{ y: -3 }}
|
||||
className="md:col-span-3 lg:col-span-4 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-5 hover:border-blue-500/30 transition-all duration-300 shadow-md"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-slate-800/80 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center">
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-white">Firmendaten & Rechnungsadresse</h3>
|
||||
<p className="text-xs text-slate-400">Verwaltung der eigenen Unternehmensanschrift für Rechnungen und Dokumente</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSaveBranding}
|
||||
disabled={brandingSaving}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white rounded-xl px-5 h-10 text-xs font-bold shrink-0"
|
||||
>
|
||||
{brandingSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
|
||||
Änderungen Speichern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{brandingMsg && (
|
||||
<div className={`p-3 rounded-xl text-xs font-medium border ${
|
||||
brandingMsgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{brandingMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-bold text-slate-300">Firmenname *</Label>
|
||||
<Input
|
||||
value={branding.companyName}
|
||||
onChange={(e) => setBranding({ ...branding, companyName: e.target.value })}
|
||||
placeholder="z. B. Meine Firma GmbH"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-bold text-slate-300">Logo-URL (Optional)</Label>
|
||||
<Input
|
||||
value={branding.logoUrl || ''}
|
||||
onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })}
|
||||
placeholder="https://domain.de/logo.png oder /assets/logo.png"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-bold text-slate-300">Entwickler / Subline Beschriftung (Footer & Header)</Label>
|
||||
<Input
|
||||
value={branding.developerFooter || ''}
|
||||
onChange={(e) => setBranding({ ...branding, developerFooter: e.target.value })}
|
||||
placeholder="B2B Shop made by hephex"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Anschrift */}
|
||||
<div className="grid md:grid-cols-2 gap-4 pt-2 border-t border-slate-800">
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Hauptanschrift
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-400">Straße & Nr.</Label>
|
||||
<Input
|
||||
value={branding.street}
|
||||
onChange={(e) => setBranding({ ...branding, street: e.target.value })}
|
||||
placeholder="Musterstraße 12"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">PLZ</Label>
|
||||
<Input
|
||||
value={branding.zip}
|
||||
onChange={(e) => setBranding({ ...branding, zip: e.target.value })}
|
||||
placeholder="12345"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs text-slate-400">Ort</Label>
|
||||
<Input
|
||||
value={branding.city}
|
||||
onChange={(e) => setBranding({ ...branding, city: e.target.value })}
|
||||
placeholder="Musterstadt"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechnungsadresse */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
|
||||
</span>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branding.sameBillingAddress}
|
||||
onChange={(e) => setBranding({ ...branding, sameBillingAddress: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-slate-700 bg-slate-950 text-blue-500"
|
||||
/>
|
||||
Gleiche wie Hauptanschrift
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!branding.sameBillingAddress ? (
|
||||
<div className="space-y-3 p-3 rounded-xl bg-slate-950/40 border border-slate-800">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-400">Rechnungsstraße & Nr.</Label>
|
||||
<Input
|
||||
value={branding.billingStreet}
|
||||
onChange={(e) => setBranding({ ...branding, billingStreet: e.target.value })}
|
||||
placeholder="Rechnungsstraße 45"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">PLZ</Label>
|
||||
<Input
|
||||
value={branding.billingZip}
|
||||
onChange={(e) => setBranding({ ...branding, billingZip: e.target.value })}
|
||||
placeholder="54321"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs text-slate-400">Ort</Label>
|
||||
<Input
|
||||
value={branding.billingCity}
|
||||
onChange={(e) => setBranding({ ...branding, billingCity: e.target.value })}
|
||||
placeholder="Rechnungsstadt"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 rounded-xl bg-slate-950/40 border border-slate-800/60 text-xs text-slate-500 italic">
|
||||
Verwendet automatisch die Hauptanschrift oben.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Item 5: Farbschema & Design-System (4 Spalten) */}
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
whileHover={{ y: -3 }}
|
||||
className="md:col-span-3 lg:col-span-4 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-5 hover:border-violet-500/30 transition-all duration-300 shadow-md"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-slate-800/80 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-violet-500/10 border border-violet-500/20 text-violet-400 flex items-center justify-center">
|
||||
<Palette className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-white">Farbschema & Webshop Styling</h3>
|
||||
<p className="text-xs text-slate-400">Wählen Sie das primäre Farbschema für Buttons, Akzente und Highlights</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSaveBranding}
|
||||
disabled={brandingSaving}
|
||||
className="bg-violet-600 hover:bg-violet-500 text-white rounded-xl px-5 h-10 text-xs font-bold shrink-0"
|
||||
>
|
||||
{brandingSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
|
||||
Farben Speichern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ColorThemePicker
|
||||
colorScheme={branding.colorScheme}
|
||||
primaryColor={branding.primaryColor}
|
||||
accentColor={branding.accentColor}
|
||||
companyName={branding.companyName}
|
||||
onChange={(scheme, primary, accent) =>
|
||||
setBranding({
|
||||
...branding,
|
||||
colorScheme: scheme,
|
||||
primaryColor: primary,
|
||||
accentColor: accent,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Item 6: LicServer Konfiguration */}
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
whileHover={{ y: -3 }}
|
||||
@@ -326,7 +584,6 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LicServer Status Pill */}
|
||||
{licStatus && (
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold border ${
|
||||
licStatus.ok
|
||||
@@ -348,7 +605,6 @@ export default function AdminSettings() {
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Base URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-url" className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
|
||||
@@ -362,7 +618,6 @@ export default function AdminSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-key" className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
|
||||
@@ -388,7 +643,6 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
id="lic-save-btn"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -19,6 +18,17 @@ html {
|
||||
--oklch-text-muted: 0.70 0.01 148;
|
||||
--oklch-border: 0.31 0.012 148;
|
||||
|
||||
/* Dynamische Branding Variablen */
|
||||
--primary-custom: #2563eb;
|
||||
--accent-custom: #38bdf8;
|
||||
--success-custom: #10b981;
|
||||
--warning-custom: #f59e0b;
|
||||
--destructive-custom: #ef4444;
|
||||
--bg-glow-1: #3b82f6;
|
||||
--bg-glow-2: #1d4ed8;
|
||||
--gradient-from: #2563eb;
|
||||
--gradient-to: #1e40af;
|
||||
|
||||
/* Classic HSL Mappings */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
@@ -42,6 +52,110 @@ html {
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
/* 100% Dynamische Farbbindung für das gesamte Webshop Design-System */
|
||||
.bg-blue-600, .bg-blue-500, .bg-violet-600, .bg-purple-600, .bg-sky-500, .bg-cyan-600, .bg-indigo-600 {
|
||||
background-color: var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
.hover\:bg-blue-500:hover, .hover\:bg-violet-500:hover, .hover\:bg-blue-600:hover {
|
||||
background-color: var(--primary-custom) !important;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.text-blue-500, .text-blue-400, .text-blue-600, .text-violet-400, .text-sky-400, .text-sky-300, .text-indigo-400 {
|
||||
color: var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
.border-blue-500, .border-blue-600, .border-violet-500, .border-sky-500 {
|
||||
border-color: var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Transparenz-Schichten & Badges mit color-mix */
|
||||
[class*="bg-blue-500/"], [class*="bg-violet-500/"], [class*="bg-purple-500/"], [class*="bg-sky-500/"] {
|
||||
background-color: color-mix(in srgb, var(--primary-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-blue-500/"], [class*="border-violet-500/"], [class*="border-purple-500/"], [class*="border-sky-500/"] {
|
||||
border-color: color-mix(in srgb, var(--primary-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Gradients */
|
||||
.from-blue-600, .from-blue-500, .from-violet-600 {
|
||||
--tw-gradient-from: var(--gradient-from, var(--primary-custom)) !important;
|
||||
--tw-gradient-to: rgb(255 255 255 / 0) !important;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
|
||||
}
|
||||
|
||||
.to-indigo-600, .to-indigo-500, .to-sky-500, .to-sky-400, .to-violet-500 {
|
||||
--tw-gradient-to: var(--gradient-to, var(--accent-custom)) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Signalfarben (Success, Warning, Destructive) */
|
||||
.bg-emerald-500, .bg-green-500, .bg-emerald-600, .bg-green-600 {
|
||||
background-color: var(--success-custom) !important;
|
||||
}
|
||||
|
||||
.text-emerald-400, .text-green-400, .text-emerald-500, .text-green-500, .text-green-600 {
|
||||
color: var(--success-custom) !important;
|
||||
}
|
||||
|
||||
.border-emerald-500, .border-green-500 {
|
||||
border-color: var(--success-custom) !important;
|
||||
}
|
||||
|
||||
[class*="bg-emerald-500/"], [class*="bg-green-500/"] {
|
||||
background-color: color-mix(in srgb, var(--success-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-emerald-500/"], [class*="border-green-500/"] {
|
||||
border-color: color-mix(in srgb, var(--success-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
.bg-amber-500, .bg-amber-600 {
|
||||
background-color: var(--warning-custom) !important;
|
||||
}
|
||||
|
||||
.text-amber-400, .text-amber-500, .text-amber-600 {
|
||||
color: var(--warning-custom) !important;
|
||||
}
|
||||
|
||||
.border-amber-500, .border-amber-600 {
|
||||
border-color: var(--warning-custom) !important;
|
||||
}
|
||||
|
||||
[class*="bg-amber-500/"] {
|
||||
background-color: color-mix(in srgb, var(--warning-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-amber-500/"] {
|
||||
border-color: color-mix(in srgb, var(--warning-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
.bg-red-500, .bg-red-600, .bg-rose-500, .bg-rose-600 {
|
||||
background-color: var(--destructive-custom) !important;
|
||||
}
|
||||
|
||||
.text-red-500, .text-red-400, .text-rose-400, .text-rose-500 {
|
||||
color: var(--destructive-custom) !important;
|
||||
}
|
||||
|
||||
.border-red-500, .border-rose-500 {
|
||||
border-color: var(--destructive-custom) !important;
|
||||
}
|
||||
|
||||
[class*="bg-red-500/"], [class*="bg-rose-500/"] {
|
||||
background-color: color-mix(in srgb, var(--destructive-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-red-500/"], [class*="border-rose-500/"] {
|
||||
border-color: color-mix(in srgb, var(--destructive-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Background Glowing Orbs */
|
||||
.blur-\[120px\] {
|
||||
background-color: var(--bg-glow-1) !important;
|
||||
}
|
||||
|
||||
input, select, textarea, option {
|
||||
background-color: oklch(var(--oklch-surface)) !important;
|
||||
color: oklch(var(--oklch-text-main)) !important;
|
||||
@@ -49,9 +163,9 @@ input, select, textarea, option {
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: oklch(var(--oklch-primary)) !important;
|
||||
border-color: var(--primary-custom) !important;
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 2px oklch(var(--oklch-primary) / 0.2) !important;
|
||||
box-shadow: 0 0 0 2px var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
option {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import { InactivityTracker } from "@/components/inactivity-tracker";
|
||||
import { HeaderWrapper } from "@/components/HeaderWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
@@ -2,29 +2,6 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
interface OKLCHColor {
|
||||
l: number
|
||||
c: number
|
||||
h: number
|
||||
}
|
||||
|
||||
// Monochromes Farbkonzept im OKLCH-Raum
|
||||
const BASE_BG = { l: 0.145, c: 0.01, h: 148 }
|
||||
const BASE_DIM = { l: 0.50, c: 0.01, h: 148 }
|
||||
const HIGHLIGHT_BRIGHT = { l: 0.95, c: 0.01, h: 148 }
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
|
||||
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
|
||||
return {
|
||||
l: lerp(c1.l, c2.l, t),
|
||||
c: lerp(c1.c, c2.c, t),
|
||||
h: lerp(c1.h, c2.h, t)
|
||||
}
|
||||
}
|
||||
|
||||
export function AsciiShaderBackground({ className = '' }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
@@ -47,6 +24,32 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
||||
let scrollProgress = 0
|
||||
let targetScrollProgress = 0
|
||||
|
||||
const getDynamicHue = (): number => {
|
||||
try {
|
||||
const hex = getComputedStyle(document.documentElement).getPropertyValue('--primary-custom').trim() || '#2563eb'
|
||||
let c = hex.replace('#', '')
|
||||
if (c.length === 3) c = c.split('').map(x => x + x).join('')
|
||||
const r = parseInt(c.substring(0, 2), 16) / 255
|
||||
const g = parseInt(c.substring(2, 4), 16) / 255
|
||||
const b = parseInt(c.substring(4, 6), 16) / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return Math.round(h * 360)
|
||||
} catch (e) {
|
||||
return 217
|
||||
}
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
@@ -99,54 +102,40 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
||||
return
|
||||
}
|
||||
|
||||
// Scroll-gesteuerter Dimmer
|
||||
const scrollDimFactor = (1 - scrollProgress * 0.4) * scrollFade
|
||||
const currentDimColor = {
|
||||
...BASE_DIM,
|
||||
l: BASE_DIM.l * scrollDimFactor
|
||||
}
|
||||
|
||||
ctx.font = `${fontSize}px monospace`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const timeSec = time * 0.001
|
||||
const activeHue = getDynamicHue()
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = c * fontSize + fontSize / 2
|
||||
const y = r * fontSize + fontSize / 2
|
||||
|
||||
// Abstandsvektor zur gedämpften Mausposition
|
||||
const dx = x - mouse.x
|
||||
const dy = y - mouse.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// Maus-Störung (Vektorfeld-Ablenkung): Phase & Amplitude lokal beeinflussen
|
||||
const mouseInfluence = Math.exp(-dist / 250)
|
||||
const mouseDistortion = (Math.atan2(dy, dx) + (dx + dy) * 0.003) * mouseInfluence * 3.0
|
||||
|
||||
// Diagonale 2D-Strömung / Wellen-Synthese aus Sinus & Kosinus
|
||||
const waveX = Math.sin(c * 0.08 + timeSec * 0.8 + mouseDistortion)
|
||||
const waveY = Math.cos(r * 0.08 + timeSec * 0.6 + mouseDistortion)
|
||||
const flowValue = Math.sin(waveX + waveY + (c + r) * 0.04 + timeSec * 0.4)
|
||||
|
||||
// Zeichenauswahl basierend auf Strömungsfeld
|
||||
const normalizedFlow = (flowValue + 1) * 0.5 // Range 0..1
|
||||
const normalizedFlow = (flowValue + 1) * 0.5
|
||||
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
|
||||
const char = chars[charIndex]
|
||||
|
||||
// Leerzeichen für ruhiges Raster überspringen
|
||||
if (char === ' ') continue
|
||||
|
||||
// Helligkeits-Highlighting durch Maus-Störung und Wellenkamm
|
||||
const blendFactor = Math.min(Math.max(mouseInfluence * 0.85 + normalizedFlow * 0.15, 0), 1)
|
||||
const currentColor = lerpOKLCH(currentDimColor, HIGHLIGHT_BRIGHT, blendFactor)
|
||||
const alpha = (0.35 + blendFactor * 0.6) * scrollFade
|
||||
const lightness = 40 + Math.round(blendFactor * 50)
|
||||
|
||||
// Stärkerer Kontrast: min 0.35, max 0.95 Deckkraft
|
||||
const alpha = (0.35 + blendFactor * 0.6) * scrollDimFactor
|
||||
|
||||
ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)} / ${alpha.toFixed(2)})`
|
||||
ctx.fillStyle = `hsl(${activeHue} 80% ${lightness}% / ${alpha.toFixed(2)})`
|
||||
ctx.fillText(char, x, y)
|
||||
}
|
||||
}
|
||||
@@ -166,7 +155,7 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-0 left-0 right-0 h-[500px] z-0 overflow-hidden bg-background pointer-events-none ${className}`}
|
||||
className={`fixed inset-0 pointer-events-none z-0 overflow-hidden ${className}`}
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)'
|
||||
@@ -181,8 +170,8 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
||||
style={{
|
||||
backgroundSize: '40px 40px',
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px)
|
||||
linear-gradient(to right, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px)
|
||||
`
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -35,12 +35,14 @@ import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
|
||||
import { ProcessSteps } from '@/components/ProcessSteps'
|
||||
import { ScrollIndicator } from '@/components/ScrollIndicator'
|
||||
import { WorkspaceZentrale } from '@/components/WorkspaceZentrale'
|
||||
import { useTheme } from '@/components/ThemeProvider'
|
||||
|
||||
interface HomeClientProps {
|
||||
initialUser: User | null
|
||||
}
|
||||
|
||||
export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
const { branding } = useTheme()
|
||||
const [user, setUser] = useState<User | null>(initialUser)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [calendarOpen, setCalendarOpen] = useState(false)
|
||||
@@ -265,7 +267,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
{/* Footer */}
|
||||
<footer className="w-full border-t border-slate-900 bg-slate-950 py-8 px-4 md:px-6 relative z-10">
|
||||
<div className="container max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{branding?.developerFooter || (branding?.companyName ? `© ${new Date().getFullYear()} ${branding.companyName}. Alle Rechte vorbehalten.` : `© ${new Date().getFullYear()} B2B Shop. Alle Rechte vorbehalten.`)}
|
||||
</p>
|
||||
<nav className="flex gap-6">
|
||||
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/impressum">Impressum</Link>
|
||||
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/datenschutz">Datenschutz</Link>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { signOut } from "@/lib/actions/auth";
|
||||
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||
import { DemoWrapper } from "./DemoWrapper";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
|
||||
interface NavbarClientProps {
|
||||
user: User | null;
|
||||
@@ -18,6 +19,7 @@ interface NavbarClientProps {
|
||||
|
||||
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
const router = useRouter();
|
||||
const { branding } = useTheme();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(user);
|
||||
const [userRole, setUserRole] = useState<string>(role);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
@@ -98,7 +100,15 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<>
|
||||
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
|
||||
<Link className="flex items-center justify-center gap-2 group" href="/">
|
||||
{branding?.logoUrl ? (
|
||||
<img src={branding.logoUrl} alt={branding.companyName || "Logo"} className="h-8 max-w-[180px] object-contain" />
|
||||
) : branding?.companyName ? (
|
||||
<span className="font-extrabold text-base tracking-tight text-white group-hover:text-primary transition">
|
||||
{branding.companyName}
|
||||
</span>
|
||||
) : (
|
||||
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
|
||||
)}
|
||||
<DemoWrapper>
|
||||
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
|
||||
Demo
|
||||
|
||||
@@ -13,8 +13,12 @@ export function ScrollIndicator() {
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400 origin-left z-50 pointer-events-none shadow-[0_0_12px_rgba(59,130,246,0.8)]"
|
||||
style={{ scaleX }}
|
||||
className="fixed top-0 left-0 right-0 h-[3px] origin-left z-50 pointer-events-none"
|
||||
style={{
|
||||
scaleX,
|
||||
background: 'linear-gradient(90deg, var(--primary-custom, #2563eb) 0%, var(--accent-custom, #38bdf8) 100%)',
|
||||
boxShadow: '0 0 12px var(--primary-custom, rgba(37,99,235,0.8))'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,30 +3,47 @@
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User } from 'lucide-react'
|
||||
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User, MapPin, Receipt, Palette, Send } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { completeSetup } from '@/lib/actions/setup'
|
||||
import { completeSetup, testSmtpConfig } from '@/lib/actions/setup'
|
||||
import { ColorThemePicker } from '@/components/admin/ColorThemePicker'
|
||||
|
||||
export function SetupWizard() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [testSmtpLoading, setTestSmtpLoading] = useState(false)
|
||||
const [testSmtpResult, setTestSmtpResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
// Step 2 Form: Admin account
|
||||
// Step 2: Admin Account
|
||||
const [adminForm, setAdminForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
companyName: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
})
|
||||
|
||||
// Step 3 Form: SMTP Config
|
||||
// Step 3: Firmendaten & Rechnungsadresse
|
||||
const [brandingForm, setBrandingForm] = useState({
|
||||
companyName: '',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
})
|
||||
|
||||
// Step 5: SMTP Config
|
||||
const [smtpForm, setSmtpForm] = useState({
|
||||
host: '',
|
||||
port: '587',
|
||||
@@ -35,36 +52,67 @@ export function SetupWizard() {
|
||||
pass: '',
|
||||
})
|
||||
|
||||
// Validation checks for Admin form
|
||||
// Validations
|
||||
const isAdminFormValid =
|
||||
adminForm.email.includes('@') &&
|
||||
adminForm.password.length >= 6 &&
|
||||
adminForm.password === adminForm.confirmPassword &&
|
||||
adminForm.companyName.trim().length > 0 &&
|
||||
adminForm.firstName.trim().length > 0 &&
|
||||
adminForm.lastName.trim().length > 0
|
||||
|
||||
// Validation checks for SMTP form
|
||||
const isSmtpFormValid =
|
||||
smtpForm.host.trim().length > 0 &&
|
||||
!isNaN(Number(smtpForm.port)) &&
|
||||
smtpForm.user.trim().length > 0
|
||||
const isCompanyFormValid =
|
||||
brandingForm.companyName.trim().length > 0 &&
|
||||
brandingForm.street.trim().length > 0 &&
|
||||
brandingForm.zip.trim().length > 0 &&
|
||||
brandingForm.city.trim().length > 0 &&
|
||||
(brandingForm.sameBillingAddress ||
|
||||
(brandingForm.billingStreet.trim().length > 0 &&
|
||||
brandingForm.billingZip.trim().length > 0 &&
|
||||
brandingForm.billingCity.trim().length > 0))
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
setTestSmtpLoading(true)
|
||||
setTestSmtpResult(null)
|
||||
const res = await testSmtpConfig(
|
||||
{
|
||||
host: smtpForm.host,
|
||||
port: Number(smtpForm.port),
|
||||
secure: smtpForm.secure,
|
||||
user: smtpForm.user,
|
||||
pass: smtpForm.pass,
|
||||
},
|
||||
adminForm.email
|
||||
)
|
||||
setTestSmtpResult(res)
|
||||
setTestSmtpLoading(false)
|
||||
}
|
||||
|
||||
const handleFinishSetup = async () => {
|
||||
if (!isAdminFormValid || !isSmtpFormValid) return
|
||||
if (!isAdminFormValid || !isCompanyFormValid) return
|
||||
setLoading(true)
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
// 1. Submit details via server action
|
||||
const res = await completeSetup(
|
||||
{
|
||||
email: adminForm.email,
|
||||
password: adminForm.password,
|
||||
companyName: adminForm.companyName,
|
||||
companyName: brandingForm.companyName,
|
||||
firstName: adminForm.firstName,
|
||||
lastName: adminForm.lastName,
|
||||
},
|
||||
{
|
||||
street: brandingForm.street,
|
||||
zip: brandingForm.zip,
|
||||
city: brandingForm.city,
|
||||
billingStreet: brandingForm.billingStreet,
|
||||
billingZip: brandingForm.billingZip,
|
||||
billingCity: brandingForm.billingCity,
|
||||
sameBillingAddress: brandingForm.sameBillingAddress,
|
||||
colorScheme: brandingForm.colorScheme,
|
||||
primaryColor: brandingForm.primaryColor,
|
||||
accentColor: brandingForm.accentColor,
|
||||
},
|
||||
{
|
||||
host: smtpForm.host,
|
||||
port: Number(smtpForm.port),
|
||||
@@ -80,20 +128,8 @@ export function SetupWizard() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Automatischer Login für flüssiges Erlebnis
|
||||
const supabase = createClient()
|
||||
const { error: loginError } = await supabase.auth.signInWithPassword({
|
||||
email: adminForm.email,
|
||||
password: adminForm.password,
|
||||
})
|
||||
|
||||
if (loginError) {
|
||||
console.error('Auto login failed:', loginError)
|
||||
// Redirect anyway since setup is done
|
||||
}
|
||||
|
||||
// 3. Weiterleitung
|
||||
router.push('/')
|
||||
// Setup erfolgreich – Weiterleitung zur Login-Seite
|
||||
router.push('/auth/login?setup=success')
|
||||
router.refresh()
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
|
||||
@@ -103,19 +139,19 @@ export function SetupWizard() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Dynamic Background Glow */}
|
||||
{/* Background Glow */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] rounded-full bg-blue-500/10 blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-500/10 blur-[120px]" />
|
||||
|
||||
<div className="w-full max-w-xl relative z-10">
|
||||
<div className="w-full max-w-2xl relative z-10 my-8">
|
||||
{/* Step Indicator Header */}
|
||||
<div className="flex items-center justify-between mb-8 px-2">
|
||||
<div className="flex items-center justify-between mb-6 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold tracking-tight text-blue-400">CASPOS</span>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">Initialisierung</span>
|
||||
<span className="text-xl font-bold tracking-tight text-blue-400">B2B Shop</span>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">made by hephex</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3].map((s) => (
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${s === step ? 'w-8 bg-blue-500' : 'w-2 bg-slate-800'
|
||||
@@ -126,13 +162,13 @@ export function SetupWizard() {
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{/* STEP 1: Willkommen */}
|
||||
{step === 1 && (
|
||||
<motion.div
|
||||
key="step1"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400 mx-auto shadow-inner">
|
||||
@@ -140,9 +176,9 @@ export function SetupWizard() {
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">Willkommen bei CASPOS!</h1>
|
||||
<p className="text-slate-400 leading-relaxed">
|
||||
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop in wenigen Schritten ein. Wir konfigurieren Ihr Administrator-Konto und die Mailverbindung.
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">B2B Shop made by hephex</h1>
|
||||
<p className="text-slate-400 leading-relaxed text-sm">
|
||||
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop ein. Wir konfigurieren Administrator-Zugang, Firmendaten, Rechnungsadresse und Ihr persönliches Farbschema.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -153,11 +189,19 @@ export function SetupWizard() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Automatische Schema-Updates auf dem neuesten Stand</span>
|
||||
<span>9 vordefinierte Farbpaletten + Custom Farbwähler</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>SMTP Mailversand für direkte Anfragebestätigungen</span>
|
||||
<span>Lizenzserver-Anbindung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Stammdatenverwaltung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Automatische Adress- & Rechnungsverwaltung</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,13 +215,13 @@ export function SetupWizard() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 2: Admin-Konto */}
|
||||
{step === 2 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
@@ -186,7 +230,7 @@ export function SetupWizard() {
|
||||
Admin-Konto anlegen
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Erstellen Sie den ersten Administrator-Benutzer. Dieser erhält vollen Zugriff auf das System.
|
||||
Erstellen Sie das erste Administrator-Konto für vollen Systemzugriff.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -218,19 +262,6 @@ export function SetupWizard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Unternehmen / Partnername *</Label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
value={adminForm.companyName}
|
||||
onChange={(e) => setAdminForm({ ...adminForm, companyName: e.target.value })}
|
||||
placeholder="Name Ihrer Firma"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">E-Mail-Adresse *</Label>
|
||||
<div className="relative">
|
||||
@@ -275,7 +306,7 @@ export function SetupWizard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(1)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
@@ -283,6 +314,203 @@ export function SetupWizard() {
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!isAdminFormValid}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zu Firmendaten
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 3: Firmendaten & Rechnungsadresse */}
|
||||
{step === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Building className="w-6 h-6 text-blue-400" />
|
||||
Firmendaten & Adressen
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Tragen Sie Ihren Firmennamen, die Anschrift und die Rechnungsadresse ein.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Firmenname *</Label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
value={brandingForm.companyName}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, companyName: e.target.value })}
|
||||
placeholder="z. B. CASPOS Software GmbH"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anschrift */}
|
||||
<div className="space-y-3 pt-1 border-t border-slate-800">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Firmenanschrift
|
||||
</span>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Straße & Hausnummer *</Label>
|
||||
<Input
|
||||
value={brandingForm.street}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, street: e.target.value })}
|
||||
placeholder="Musterstraße 12"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">PLZ *</Label>
|
||||
<Input
|
||||
value={brandingForm.zip}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, zip: e.target.value })}
|
||||
placeholder="12345"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Ort *</Label>
|
||||
<Input
|
||||
value={brandingForm.city}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, city: e.target.value })}
|
||||
placeholder="Musterstadt"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechnungsadresse */}
|
||||
<div className="space-y-3 pt-2 border-t border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
|
||||
</span>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandingForm.sameBillingAddress}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, sameBillingAddress: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500"
|
||||
/>
|
||||
Gleiche wie Anschrift
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!brandingForm.sameBillingAddress && (
|
||||
<div className="space-y-3 p-3 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Rechnungsstraße & Nr. *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingStreet}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingStreet: e.target.value })
|
||||
}
|
||||
placeholder="Rechnungsstraße 45"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">PLZ *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingZip}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingZip: e.target.value })
|
||||
}
|
||||
placeholder="54321"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Ort *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingCity}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingCity: e.target.value })
|
||||
}
|
||||
placeholder="Rechnungsstadt"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(2)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(4)}
|
||||
disabled={!isCompanyFormValid}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zum Farbschema
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 4: Farbschema & Live-Vorschau */}
|
||||
{step === 4 && (
|
||||
<motion.div
|
||||
key="step4"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Palette className="w-6 h-6 text-blue-400" />
|
||||
Farbschema & Webshop Styling
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Wählen Sie aus 9 vorgegebenen Farbpaletten oder erstellen Sie eine eigene Farbkombination mit Live-Vorschau.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ColorThemePicker
|
||||
colorScheme={brandingForm.colorScheme}
|
||||
primaryColor={brandingForm.primaryColor}
|
||||
accentColor={brandingForm.accentColor}
|
||||
companyName={brandingForm.companyName}
|
||||
onChange={(scheme, primary, accent) =>
|
||||
setBrandingForm({
|
||||
...brandingForm,
|
||||
colorScheme: scheme,
|
||||
primaryColor: primary,
|
||||
accentColor: accent,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(3)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(5)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zu SMTP
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
@@ -291,22 +519,27 @@ export function SetupWizard() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
{/* STEP 5: SMTP Mailserver */}
|
||||
{step === 5 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
key="step5"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Mail className="w-6 h-6 text-blue-400" />
|
||||
SMTP-Mailserver konfigurieren
|
||||
SMTP-Mailserver
|
||||
</h2>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider px-2.5 py-1 rounded-full bg-slate-800 text-amber-400 border border-amber-500/20">
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Tragen Sie Ihre Mailserver-Daten ein, um automatisierte E-Mails an Partner und Kunden senden zu können.
|
||||
Tragen Sie Ihre Mailserver-Daten ein oder überspringen Sie diesen Schritt. Sie können die Einstellungen jederzeit im Admin-Bereich anpassen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -319,7 +552,7 @@ export function SetupWizard() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">SMTP Host *</Label>
|
||||
<Label className="text-slate-300 text-xs">SMTP Host</Label>
|
||||
<Input
|
||||
value={smtpForm.host}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
|
||||
@@ -328,7 +561,7 @@ export function SetupWizard() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">SMTP Port *</Label>
|
||||
<Label className="text-slate-300 text-xs">SMTP Port</Label>
|
||||
<Input
|
||||
value={smtpForm.port}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
|
||||
@@ -339,7 +572,7 @@ export function SetupWizard() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Benutzername *</Label>
|
||||
<Label className="text-slate-300 text-xs">Benutzername</Label>
|
||||
<Input
|
||||
value={smtpForm.user}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
|
||||
@@ -359,7 +592,8 @@ export function SetupWizard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="smtp_secure"
|
||||
@@ -368,23 +602,63 @@ export function SetupWizard() {
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
|
||||
Sichere Verbindung (SSL/TLS anstelle STARTTLS)
|
||||
Sichere Verbindung (SSL/TLS)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testSmtpLoading || !smtpForm.host || !smtpForm.user}
|
||||
className="bg-white/10 hover:bg-white/20 text-white text-xs border border-white/10 flex items-center gap-1.5"
|
||||
>
|
||||
{testSmtpLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Testen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-3.5 h-3.5 text-blue-400" /> Test-E-Mail senden
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{testSmtpResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg border text-xs font-medium ${
|
||||
testSmtpResult.success
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
|
||||
: 'bg-red-500/10 border-red-500/30 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testSmtpResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setStep(2)}
|
||||
onClick={() => setStep(4)}
|
||||
disabled={loading}
|
||||
className="text-white hover:bg-white/5"
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFinishSetup}
|
||||
disabled={!isSmtpFormValid || loading}
|
||||
disabled={loading}
|
||||
className="border-slate-700 text-slate-300 hover:text-white"
|
||||
>
|
||||
Überspringen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinishSetup}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold"
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
111
shop/components/ThemeProvider.tsx
Normal file
111
shop/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, createContext, useContext } from 'react'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { getBrandingSettings } from '@/lib/actions/branding'
|
||||
import { PRESET_COLOR_SCHEMES, BrandingSettings } from '@/lib/constants/branding'
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
try {
|
||||
let c = hex.replace('#', '')
|
||||
if (c.length === 3) c = c.split('').map(x => x + x).join('')
|
||||
const r = parseInt(c.substring(0, 2), 16) / 255
|
||||
const g = parseInt(c.substring(2, 4), 16) / 255
|
||||
const b = parseInt(c.substring(4, 6), 16) / 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0, s = 0, l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`
|
||||
} catch (e) {
|
||||
return '217 91% 60%'
|
||||
}
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
branding: BrandingSettings | null
|
||||
refreshBranding: () => Promise<void>
|
||||
}>({
|
||||
branding: null,
|
||||
refreshBranding: async () => {},
|
||||
})
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
[key: string]: any
|
||||
}) {
|
||||
const [branding, setBranding] = useState<BrandingSettings | null>(null)
|
||||
|
||||
const loadTheme = async () => {
|
||||
try {
|
||||
const settings = await getBrandingSettings()
|
||||
setBranding(settings)
|
||||
|
||||
// Preset Ermittlung
|
||||
const preset = PRESET_COLOR_SCHEMES.find(p => p.id === settings.colorScheme) || PRESET_COLOR_SCHEMES[3]
|
||||
const isCustom = settings.colorScheme === 'custom'
|
||||
|
||||
const primary = isCustom ? (settings.primaryColor || '#2563eb') : preset.primary
|
||||
const accent = isCustom ? (settings.accentColor || '#38bdf8') : preset.accent
|
||||
|
||||
const primaryHsl = hexToHsl(primary)
|
||||
const accentHsl = hexToHsl(accent)
|
||||
|
||||
const root = document.documentElement
|
||||
|
||||
// 10 Color CSS Tokens applied globally onto :root
|
||||
root.style.setProperty('--primary', primaryHsl)
|
||||
root.style.setProperty('--ring', primaryHsl)
|
||||
root.style.setProperty('--accent', accentHsl)
|
||||
|
||||
root.style.setProperty('--primary-custom', primary)
|
||||
root.style.setProperty('--accent-custom', accent)
|
||||
|
||||
root.style.setProperty('--success-custom', settings.successColor || preset.success)
|
||||
root.style.setProperty('--warning-custom', settings.warningColor || preset.warning)
|
||||
root.style.setProperty('--destructive-custom', settings.destructiveColor || preset.destructive)
|
||||
|
||||
root.style.setProperty('--bg-glow-1', isCustom ? primary : preset.bgGlow1)
|
||||
root.style.setProperty('--bg-glow-2', isCustom ? accent : preset.bgGlow2)
|
||||
root.style.setProperty('--gradient-from', isCustom ? primary : preset.gradientFrom)
|
||||
root.style.setProperty('--gradient-to', isCustom ? accent : preset.gradientTo)
|
||||
root.style.setProperty('--card-border-glow', isCustom ? `${accent}40` : preset.cardBorder)
|
||||
root.style.setProperty('--text-highlight', isCustom ? accent : preset.textHighlight)
|
||||
root.style.setProperty('--button-bg', isCustom ? primary : preset.buttonBg)
|
||||
root.style.setProperty('--ring-color', isCustom ? primary : preset.ringColor)
|
||||
} catch (e) {
|
||||
console.error('Failed to load branding theme:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTheme()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NextThemesProvider {...props}>
|
||||
<ThemeContext.Provider value={{ branding, refreshBranding: loadTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
267
shop/components/admin/ColorThemePicker.tsx
Normal file
267
shop/components/admin/ColorThemePicker.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { PRESET_COLOR_SCHEMES, ColorPreset } from '@/lib/constants/branding'
|
||||
import { Check, Palette, Sparkles, Building2, ShoppingCart, Tag, Layers } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface ColorThemePickerProps {
|
||||
colorScheme: string
|
||||
primaryColor: string
|
||||
accentColor: string
|
||||
companyName?: string
|
||||
onChange: (scheme: string, primary: string, accent: string) => void
|
||||
}
|
||||
|
||||
export function ColorThemePicker({
|
||||
colorScheme,
|
||||
primaryColor,
|
||||
accentColor,
|
||||
companyName = 'CASPOS Shop',
|
||||
onChange,
|
||||
}: ColorThemePickerProps) {
|
||||
const [customPrimary, setCustomPrimary] = useState(primaryColor || '#2563eb')
|
||||
const [customAccent, setCustomAccent] = useState(accentColor || '#38bdf8')
|
||||
|
||||
const isCustom = colorScheme === 'custom'
|
||||
|
||||
const activePreset = PRESET_COLOR_SCHEMES.find(p => p.id === colorScheme) || PRESET_COLOR_SCHEMES[3]
|
||||
|
||||
const handleSelectPreset = (preset: ColorPreset) => {
|
||||
onChange(preset.id, preset.primary, preset.accent)
|
||||
}
|
||||
|
||||
const handleCustomPrimaryChange = (val: string) => {
|
||||
setCustomPrimary(val)
|
||||
onChange('custom', val, customAccent)
|
||||
}
|
||||
|
||||
const handleCustomAccentChange = (val: string) => {
|
||||
setCustomAccent(val)
|
||||
onChange('custom', customPrimary, val)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Grid of Presets */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label className="text-sm font-bold text-slate-200 flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-blue-400" />
|
||||
Vorgegebene Farbschemata (9 Stile mit je 10 Farbtokens)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{PRESET_COLOR_SCHEMES.map((preset) => {
|
||||
const isSelected = colorScheme === preset.id
|
||||
|
||||
const tokenSwatches = [
|
||||
preset.primary,
|
||||
preset.accent,
|
||||
preset.bgGlow1,
|
||||
preset.bgGlow2,
|
||||
preset.gradientFrom,
|
||||
preset.gradientTo,
|
||||
preset.textHighlight,
|
||||
preset.buttonBg,
|
||||
preset.ringColor,
|
||||
preset.cardBorder,
|
||||
]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
className={`relative p-3.5 rounded-2xl border text-left transition-all duration-200 group flex flex-col justify-between space-y-3 ${
|
||||
isSelected
|
||||
? 'bg-slate-900/90 border-blue-500 shadow-lg shadow-blue-500/10 ring-2 ring-blue-500/30'
|
||||
: 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-900/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-xs text-white truncate pr-2">
|
||||
{preset.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="w-5 h-5 rounded-full bg-blue-500 text-white flex items-center justify-center shrink-0">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400 line-clamp-1">
|
||||
{preset.description}
|
||||
</p>
|
||||
|
||||
{/* 10 Color Token Swatches Bar */}
|
||||
<div className="space-y-1 pt-1 border-t border-slate-800/60">
|
||||
<div className="flex items-center gap-1 justify-between">
|
||||
{tokenSwatches.map((color, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-3 h-3 rounded-full border border-white/10 shadow-sm shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
title={`Token ${i + 1}: ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Color Picker */}
|
||||
<div
|
||||
className={`p-4 rounded-2xl border transition-all duration-200 space-y-4 ${
|
||||
isCustom
|
||||
? 'bg-slate-900/90 border-blue-500 ring-2 ring-blue-500/30'
|
||||
: 'bg-slate-950/40 border-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('custom', customPrimary, customAccent)}
|
||||
className="flex items-center gap-2.5 font-bold text-xs text-white hover:text-blue-400 transition"
|
||||
>
|
||||
<span className={`w-4 h-4 rounded-full border flex items-center justify-center ${
|
||||
isCustom ? 'border-blue-500 bg-blue-500/20 text-blue-400' : 'border-slate-700'
|
||||
}`}>
|
||||
{isCustom && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span>Benutzerdefiniertes Farbschema (Custom Hex Picker)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustom && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2 border-t border-slate-800">
|
||||
{/* Primary Color Picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-300">Hauptfarbe (Primary)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customPrimary}
|
||||
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
|
||||
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customPrimary}
|
||||
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
|
||||
placeholder="#2563eb"
|
||||
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent Color Picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-300">Akzentfarbe (Accent)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customAccent}
|
||||
onChange={(e) => handleCustomAccentChange(e.target.value)}
|
||||
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customAccent}
|
||||
onChange={(e) => handleCustomAccentChange(e.target.value)}
|
||||
placeholder="#38bdf8"
|
||||
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Micro-Preview Card with Animated Background Orbs */}
|
||||
<div className="p-5 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 relative overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5 relative z-10">
|
||||
<span className="text-xs font-bold text-slate-400 flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
|
||||
Live Vorschau & Animierter Background Glow
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-500">
|
||||
{primaryColor} / {accentColor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800/80 space-y-4 relative overflow-hidden z-10 backdrop-blur-md">
|
||||
{/* Animated Background Orbs Preview */}
|
||||
<div
|
||||
className="absolute -top-10 -left-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
|
||||
style={{ backgroundColor: activePreset.bgGlow1 || primaryColor }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
|
||||
style={{ backgroundColor: activePreset.bgGlow2 || accentColor }}
|
||||
/>
|
||||
|
||||
{/* Header Preview */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-950/80 border border-slate-800 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-white text-xs font-bold shadow-md"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span className="font-bold text-xs text-white">
|
||||
{companyName || 'Ihr Unternehmen'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full text-slate-950 font-bold"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
Aktiv
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Buttons & Badges Preview */}
|
||||
<div className="flex flex-wrap items-center gap-2.5 relative z-10">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-lg text-xs font-bold text-white shadow-md flex items-center gap-1.5 transition-all"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${primaryColor} 0%, ${activePreset.gradientTo || primaryColor} 100%)`,
|
||||
}}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5" />
|
||||
In den Warenkorb
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="px-3.5 py-2 rounded-lg text-xs font-semibold bg-slate-800/90 text-slate-200 border"
|
||||
style={{ borderColor: accentColor }}
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="text-[10px] font-bold px-2.5 py-1 rounded-full border flex items-center gap-1"
|
||||
style={{
|
||||
borderColor: `${accentColor}40`,
|
||||
backgroundColor: `${accentColor}15`,
|
||||
color: activePreset.textHighlight || accentColor,
|
||||
}}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
Empfohlen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -137,8 +137,11 @@ export function LoginForm({
|
||||
}
|
||||
};
|
||||
|
||||
const isVerifyingRef = useRef(false);
|
||||
|
||||
const handleVerify2FA = async (code: string) => {
|
||||
if (!userId) return;
|
||||
if (!userId || isVerifyingRef.current) return;
|
||||
isVerifyingRef.current = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -160,6 +163,7 @@ export function LoginForm({
|
||||
inputRefs.current[0]?.focus();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isVerifyingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,47 +15,6 @@ export async function signIn(email: string, password: string, deviceHash?: strin
|
||||
password,
|
||||
})
|
||||
if (error) {
|
||||
// Track failed attempts
|
||||
const { data: usr, error: usrError } = await supabase
|
||||
.from('users')
|
||||
.select('failed_attempts, email')
|
||||
.eq('email', email)
|
||||
.single()
|
||||
|
||||
const attempts = (usr?.failed_attempts ?? 0) + 1
|
||||
|
||||
// Update attempts count
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ failed_attempts: attempts })
|
||||
.eq('email', email)
|
||||
|
||||
// Lock account on 5th failure
|
||||
if (attempts >= 5) {
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ role: 'gesperrt' })
|
||||
.eq('email', email)
|
||||
// Notify all admins
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const { data: admins } = await adminClient
|
||||
.from('users')
|
||||
.select('email')
|
||||
.eq('role', 'admin')
|
||||
|
||||
if (admins && admins.length > 0) {
|
||||
const adminEmails = admins.map(a => a.email).filter(Boolean)
|
||||
for (const adminEmail of adminEmails) {
|
||||
await sendLockoutEmail(adminEmail)
|
||||
}
|
||||
}
|
||||
} catch (mailError) {
|
||||
console.error('Failed to notify admins of lockout:', mailError)
|
||||
}
|
||||
return { success: false, error: 'Konto gesperrt nach 5 Fehlversuchen.' }
|
||||
}
|
||||
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
@@ -243,15 +202,12 @@ async function send2FACodeInternal(userId: string, deviceHash: string, email: st
|
||||
export async function resend2FACode(userId: string, deviceHash: string) {
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const { data: user } = await adminClient
|
||||
.from('users')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
const { data: authUser, error: authError } = await adminClient.auth.admin.getUserById(userId)
|
||||
const email = authUser?.user?.email
|
||||
|
||||
if (!user?.email) return { success: false, error: 'Benutzer nicht gefunden.' }
|
||||
if (authError || !email) return { success: false, error: 'Benutzer-E-Mail nicht gefunden.' }
|
||||
|
||||
await send2FACodeInternal(userId, deviceHash, user.email)
|
||||
await send2FACodeInternal(userId, deviceHash, email)
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
console.error('Resend 2FA error:', err)
|
||||
@@ -263,17 +219,36 @@ export async function resend2FACode(userId: string, deviceHash: string) {
|
||||
export async function verifyDevice2FA(userId: string, code: string, deviceHash: string) {
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const cleanCode = code.trim()
|
||||
|
||||
// Code prüfen
|
||||
const { data: codeEntry, error: codeError } = await adminClient
|
||||
// Code prüfen (unter Berücksichtigung von Leerzeichen & device_hash)
|
||||
const { data: codeEntries, error: codeError } = await adminClient
|
||||
.from('device_verification_codes')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('device_hash', deviceHash)
|
||||
.eq('code', code)
|
||||
.maybeSingle()
|
||||
.eq('code', cleanCode)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
const codeEntry = codeEntries && codeEntries.length > 0 ? codeEntries[0] : null
|
||||
|
||||
if (codeError || !codeEntry) {
|
||||
// Doppelklick- & Race-Condition Schutz: Prüfen ob das Gerät eben bereits verifiziert wurde
|
||||
const { data: knownDev } = await adminClient
|
||||
.from('known_devices')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.limit(1)
|
||||
|
||||
if (knownDev && knownDev.length > 0) {
|
||||
const { data: userData } = await adminClient
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
return { success: true, role: userData?.role || 'partner' }
|
||||
}
|
||||
|
||||
return { success: false, error: 'Ungültiger Sicherheitscode.' }
|
||||
}
|
||||
|
||||
@@ -291,22 +266,23 @@ export async function verifyDevice2FA(userId: string, code: string, deviceHash:
|
||||
const headersList = await headers()
|
||||
const userAgent = headersList.get('user-agent') || ''
|
||||
const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || headersList.get('x-real-ip') || ''
|
||||
const targetDeviceHash = deviceHash || codeEntry.device_hash || 'default-device'
|
||||
|
||||
await adminClient
|
||||
.from('known_devices')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
device_hash: deviceHash,
|
||||
device_hash: targetDeviceHash,
|
||||
user_agent: userAgent,
|
||||
ip_address: ip,
|
||||
verified_at: new Date().toISOString(),
|
||||
}, { onConflict: 'user_id,device_hash' })
|
||||
|
||||
// Verbrauchten Code löschen
|
||||
// Verbrauchte Codes für diesen User löschen
|
||||
await adminClient
|
||||
.from('device_verification_codes')
|
||||
.delete()
|
||||
.eq('id', codeEntry.id)
|
||||
.eq('user_id', userId)
|
||||
|
||||
// Andere Sessions beenden
|
||||
const supabase = await createClient()
|
||||
@@ -322,7 +298,7 @@ export async function verifyDevice2FA(userId: string, code: string, deviceHash:
|
||||
return { success: true, role: userData?.role || 'partner' }
|
||||
} catch (err: any) {
|
||||
console.error('Verify 2FA error:', err)
|
||||
return { success: false, error: 'Fehler bei der Verifizierung.' }
|
||||
return { success: false, error: 'Fehler bei der Code-Überprüfung.' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
122
shop/lib/actions/branding.ts
Normal file
122
shop/lib/actions/branding.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { BrandingSettings } from '@/lib/constants/branding'
|
||||
|
||||
export async function getBrandingSettings(): Promise<BrandingSettings> {
|
||||
try {
|
||||
const admin = createAdminClient()
|
||||
const { data } = await admin
|
||||
.from('settings')
|
||||
.select('*')
|
||||
.eq('id', 'branding')
|
||||
.maybeSingle()
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
companyName: '',
|
||||
logoUrl: '',
|
||||
developerFooter: 'B2B Shop made by hephex',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
companyName: data.company_name || '',
|
||||
logoUrl: data.logo_url || '',
|
||||
developerFooter: data.developer_footer || 'B2B Shop made by hephex',
|
||||
street: data.street || '',
|
||||
zip: data.zip || '',
|
||||
city: data.city || '',
|
||||
billingStreet: data.billing_street || '',
|
||||
billingZip: data.billing_zip || '',
|
||||
billingCity: data.billing_city || '',
|
||||
sameBillingAddress: data.same_billing_address !== false,
|
||||
colorScheme: data.color_scheme || 'modern_blue',
|
||||
primaryColor: data.primary_color || '#2563eb',
|
||||
accentColor: data.accent_color || '#38bdf8',
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load branding settings:', err)
|
||||
return {
|
||||
companyName: '',
|
||||
logoUrl: '',
|
||||
developerFooter: 'B2B Shop made by hephex',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBrandingSettings(
|
||||
settings: BrandingSettings
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (user) {
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (!userData || userData.role !== 'admin') {
|
||||
return { success: false, error: 'Keine Berechtigung (nur Admins)' }
|
||||
}
|
||||
}
|
||||
|
||||
const admin = createAdminClient()
|
||||
const { error } = await admin
|
||||
.from('settings')
|
||||
.upsert({
|
||||
id: 'branding',
|
||||
company_name: settings.companyName,
|
||||
logo_url: settings.logoUrl,
|
||||
developer_footer: settings.developerFooter,
|
||||
street: settings.street,
|
||||
zip: settings.zip,
|
||||
city: settings.city,
|
||||
billing_street: settings.sameBillingAddress ? settings.street : settings.billingStreet,
|
||||
billing_zip: settings.sameBillingAddress ? settings.zip : settings.billingZip,
|
||||
billing_city: settings.sameBillingAddress ? settings.city : settings.billingCity,
|
||||
same_billing_address: settings.sameBillingAddress,
|
||||
color_scheme: settings.colorScheme,
|
||||
primary_color: settings.primaryColor,
|
||||
accent_color: settings.accentColor,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to save branding settings:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/admin/einstellungen')
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
console.error('Exception in saveBrandingSettings:', err)
|
||||
return { success: false, error: err.message || 'Unerwarteter Fehler' }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
export type AdminSetupData = {
|
||||
email: string
|
||||
@@ -11,6 +12,19 @@ export type AdminSetupData = {
|
||||
lastName: string
|
||||
}
|
||||
|
||||
export type BrandingSetupData = {
|
||||
street: string
|
||||
zip: string
|
||||
city: string
|
||||
billingStreet: string
|
||||
billingZip: string
|
||||
billingCity: string
|
||||
sameBillingAddress: boolean
|
||||
colorScheme: string
|
||||
primaryColor: string
|
||||
accentColor: string
|
||||
}
|
||||
|
||||
export type SmtpSetupData = {
|
||||
host: string
|
||||
port: number
|
||||
@@ -32,26 +46,21 @@ export async function isSetupNeeded(): Promise<boolean> {
|
||||
perPage: 1
|
||||
})
|
||||
|
||||
if (authError) {
|
||||
console.error('Error checking auth users list:', authError)
|
||||
if (!authError && authData && authData.users && authData.users.length > 0) {
|
||||
// Mindestens ein Auth-Benutzer vorhanden -> Setup ist beendet!
|
||||
return false
|
||||
}
|
||||
|
||||
if (authData.users.length > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Check if any user exists in public.users table
|
||||
// 2. Zusatzprüfung public.users Tabelle
|
||||
const { count, error: dbError } = await admin
|
||||
.from('users')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.select('id', { count: 'exact' })
|
||||
|
||||
if (dbError) {
|
||||
console.error('Error checking users table status:', dbError)
|
||||
if (!dbError && typeof count === 'number' && count > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return count === 0
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Exception checking setup status:', e)
|
||||
return false
|
||||
@@ -60,14 +69,14 @@ export async function isSetupNeeded(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Completes the initial setup by creating the first admin user,
|
||||
* updating their role and profile, and storing SMTP settings.
|
||||
* updating their role and profile, and storing branding & SMTP settings.
|
||||
*/
|
||||
export async function completeSetup(
|
||||
adminData: AdminSetupData,
|
||||
brandingData: BrandingSetupData,
|
||||
smtpData: SmtpSetupData
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// 1. Double check if setup is actually needed to prevent double runs
|
||||
const needed = await isSetupNeeded()
|
||||
if (!needed) {
|
||||
return { success: false, error: 'Setup bereits abgeschlossen.' }
|
||||
@@ -75,7 +84,7 @@ export async function completeSetup(
|
||||
|
||||
const admin = createAdminClient()
|
||||
|
||||
// 2. Create user in Supabase Auth
|
||||
// 1. Create user in Supabase Auth
|
||||
const { data: authData, error: authError } = await admin.auth.admin.createUser({
|
||||
email: adminData.email,
|
||||
password: adminData.password,
|
||||
@@ -88,17 +97,20 @@ export async function completeSetup(
|
||||
|
||||
const userId = authData.user.id
|
||||
|
||||
// 3. Update user role to admin in public.users
|
||||
// 2. Set user role to admin in public.users using Service Role Client
|
||||
const { error: roleError } = await admin
|
||||
.from('users')
|
||||
.update({ role: 'admin' })
|
||||
.eq('id', userId)
|
||||
.upsert({
|
||||
id: userId,
|
||||
role: 'admin',
|
||||
}, { onConflict: 'id' })
|
||||
|
||||
if (roleError) {
|
||||
console.error('Error setting user role to admin:', roleError)
|
||||
return { success: false, error: `Fehler beim Zuweisen der Admin-Rolle: ${roleError.message}` }
|
||||
}
|
||||
|
||||
// 4. Update company and name details in public.profiles
|
||||
// 3. Update company and name details in public.profiles
|
||||
const { error: profileError } = await admin
|
||||
.from('profiles')
|
||||
.update({
|
||||
@@ -113,7 +125,31 @@ export async function completeSetup(
|
||||
console.error('Error updating admin profile:', profileError)
|
||||
}
|
||||
|
||||
// 5. Store SMTP configuration in public.settings
|
||||
// 4. Store Branding settings in public.settings (id = 'branding')
|
||||
const { error: brandingError } = await admin
|
||||
.from('settings')
|
||||
.upsert({
|
||||
id: 'branding',
|
||||
company_name: adminData.companyName,
|
||||
street: brandingData.street,
|
||||
zip: brandingData.zip,
|
||||
city: brandingData.city,
|
||||
billing_street: brandingData.sameBillingAddress ? brandingData.street : brandingData.billingStreet,
|
||||
billing_zip: brandingData.sameBillingAddress ? brandingData.zip : brandingData.billingZip,
|
||||
billing_city: brandingData.sameBillingAddress ? brandingData.city : brandingData.billingCity,
|
||||
same_billing_address: brandingData.sameBillingAddress,
|
||||
color_scheme: brandingData.colorScheme,
|
||||
primary_color: brandingData.primaryColor,
|
||||
accent_color: brandingData.accentColor,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (brandingError) {
|
||||
console.error('Error saving branding settings:', brandingError)
|
||||
}
|
||||
|
||||
// 5. Store SMTP configuration in public.settings (id = 'smtp')
|
||||
if (smtpData.host && smtpData.host.trim().length > 0) {
|
||||
const { error: smtpError } = await admin
|
||||
.from('settings')
|
||||
.upsert({
|
||||
@@ -128,7 +164,7 @@ export async function completeSetup(
|
||||
|
||||
if (smtpError) {
|
||||
console.error('Error saving SMTP settings:', smtpError)
|
||||
return { success: false, error: `Fehler beim Speichern der SMTP-Einstellungen: ${smtpError.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
@@ -138,3 +174,46 @@ export async function completeSetup(
|
||||
return { success: false, error: e.message || 'Unerwarteter Fehler beim Setup.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSmtpConfig(
|
||||
smtpData: SmtpSetupData,
|
||||
recipient: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (!smtpData.host || !smtpData.user) {
|
||||
return { success: false, message: 'Bitte Host und Benutzername ausfüllen.' }
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpData.host,
|
||||
port: Number(smtpData.port) || 587,
|
||||
secure: smtpData.secure,
|
||||
auth: {
|
||||
user: smtpData.user,
|
||||
pass: smtpData.pass || '',
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
})
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: smtpData.user,
|
||||
to: recipient || smtpData.user,
|
||||
subject: 'CASPOS Setup Test-E-Mail',
|
||||
text: 'Diese Test-E-Mail wurde erfolgreich während des Webshop-Setups versendet.',
|
||||
html: '<b>Diese Test-E-Mail wurde erfolgreich während des Webshop-Setups versendet.</b>',
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Test-E-Mail erfolgreich gesendet an ${recipient || smtpData.user}! (ID: ${info.messageId})`,
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('SMTP test error:', err)
|
||||
return {
|
||||
success: false,
|
||||
message: `SMTP-Fehler: ${err.message || err}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
210
shop/lib/constants/branding.ts
Normal file
210
shop/lib/constants/branding.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
export interface BrandingSettings {
|
||||
companyName: string
|
||||
logoUrl?: string
|
||||
developerFooter?: string
|
||||
street: string
|
||||
zip: string
|
||||
city: string
|
||||
billingStreet: string
|
||||
billingZip: string
|
||||
billingCity: string
|
||||
sameBillingAddress: boolean
|
||||
colorScheme: string
|
||||
primaryColor: string
|
||||
accentColor: string
|
||||
successColor?: string
|
||||
warningColor?: string
|
||||
destructiveColor?: string
|
||||
bgGlow1?: string
|
||||
bgGlow2?: string
|
||||
gradientFrom?: string
|
||||
gradientTo?: string
|
||||
cardBorder?: string
|
||||
textHighlight?: string
|
||||
buttonBg?: string
|
||||
ringColor?: string
|
||||
}
|
||||
|
||||
export interface ColorPreset {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
primary: string
|
||||
accent: string
|
||||
success: string
|
||||
warning: string
|
||||
destructive: string
|
||||
bgGlow1: string
|
||||
bgGlow2: string
|
||||
gradientFrom: string
|
||||
gradientTo: string
|
||||
cardBorder: string
|
||||
textHighlight: string
|
||||
buttonBg: string
|
||||
ringColor: string
|
||||
}
|
||||
|
||||
export const PRESET_COLOR_SCHEMES: ColorPreset[] = [
|
||||
{
|
||||
id: 'alabaster_racing_red',
|
||||
name: 'Alabaster & Racing Red',
|
||||
description: 'Sportlich, Dynamisch & High-End',
|
||||
primary: '#dc2626',
|
||||
accent: '#f8fafc',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#b91c1c',
|
||||
bgGlow1: '#ef4444',
|
||||
bgGlow2: '#7f1d1d',
|
||||
gradientFrom: '#dc2626',
|
||||
gradientTo: '#991b1b',
|
||||
cardBorder: 'rgba(220, 38, 38, 0.35)',
|
||||
textHighlight: '#fca5a5',
|
||||
buttonBg: '#dc2626',
|
||||
ringColor: '#ef4444',
|
||||
},
|
||||
{
|
||||
id: 'black_cherry_gold',
|
||||
name: 'Black Cherry & Gold',
|
||||
description: 'Luxuriös, Exklusiv & Tief',
|
||||
primary: '#881337',
|
||||
accent: '#f59e0b',
|
||||
success: '#10b981',
|
||||
warning: '#fbbf24',
|
||||
destructive: '#be123c',
|
||||
bgGlow1: '#9f1239',
|
||||
bgGlow2: '#d97706',
|
||||
gradientFrom: '#881337',
|
||||
gradientTo: '#d97706',
|
||||
cardBorder: 'rgba(245, 158, 11, 0.35)',
|
||||
textHighlight: '#fde68a',
|
||||
buttonBg: '#881337',
|
||||
ringColor: '#f59e0b',
|
||||
},
|
||||
{
|
||||
id: 'coffee_bean_cream',
|
||||
name: 'Coffee Bean & Cream',
|
||||
description: 'Warm, Organisch & Elegant',
|
||||
primary: '#78350f',
|
||||
accent: '#fde68a',
|
||||
success: '#059669',
|
||||
warning: '#d97706',
|
||||
destructive: '#991b1b',
|
||||
bgGlow1: '#92400e',
|
||||
bgGlow2: '#b45309',
|
||||
gradientFrom: '#78350f',
|
||||
gradientTo: '#b45309',
|
||||
cardBorder: 'rgba(253, 230, 138, 0.35)',
|
||||
textHighlight: '#fef3c7',
|
||||
buttonBg: '#78350f',
|
||||
ringColor: '#d97706',
|
||||
},
|
||||
{
|
||||
id: 'modern_blue',
|
||||
name: 'Modern Blue',
|
||||
description: 'Klassisch, Vertrauensvoll & Seriös',
|
||||
primary: '#2563eb',
|
||||
accent: '#38bdf8',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#ef4444',
|
||||
bgGlow1: '#3b82f6',
|
||||
bgGlow2: '#1d4ed8',
|
||||
gradientFrom: '#2563eb',
|
||||
gradientTo: '#1e40af',
|
||||
cardBorder: 'rgba(56, 189, 248, 0.35)',
|
||||
textHighlight: '#93c5fd',
|
||||
buttonBg: '#2563eb',
|
||||
ringColor: '#38bdf8',
|
||||
},
|
||||
{
|
||||
id: 'emerald_green',
|
||||
name: 'Emerald Green',
|
||||
description: 'Frisch, Nachhaltig & Vital',
|
||||
primary: '#059669',
|
||||
accent: '#34d399',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#e11d48',
|
||||
bgGlow1: '#10b981',
|
||||
bgGlow2: '#047857',
|
||||
gradientFrom: '#059669',
|
||||
gradientTo: '#065f46',
|
||||
cardBorder: 'rgba(52, 211, 153, 0.35)',
|
||||
textHighlight: '#a7f3d0',
|
||||
buttonBg: '#059669',
|
||||
ringColor: '#34d399',
|
||||
},
|
||||
{
|
||||
id: 'violet_glow',
|
||||
name: 'Violet Glow',
|
||||
description: 'Kreativ, Modern & Futuristisch',
|
||||
primary: '#7c3aed',
|
||||
accent: '#c084fc',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#be123c',
|
||||
bgGlow1: '#8b5cf6',
|
||||
bgGlow2: '#5b21b6',
|
||||
gradientFrom: '#7c3aed',
|
||||
gradientTo: '#4c1d95',
|
||||
cardBorder: 'rgba(192, 132, 252, 0.35)',
|
||||
textHighlight: '#ddd6fe',
|
||||
buttonBg: '#7c3aed',
|
||||
ringColor: '#c084fc',
|
||||
},
|
||||
{
|
||||
id: 'sunset_orange',
|
||||
name: 'Sunset Orange',
|
||||
description: 'Dynamisch, Aktiv & Energetisch',
|
||||
primary: '#ea580c',
|
||||
accent: '#fb923c',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#dc2626',
|
||||
bgGlow1: '#f97316',
|
||||
bgGlow2: '#9a3412',
|
||||
gradientFrom: '#ea580c',
|
||||
gradientTo: '#9a3412',
|
||||
cardBorder: 'rgba(251, 146, 60, 0.35)',
|
||||
textHighlight: '#ffedd5',
|
||||
buttonBg: '#ea580c',
|
||||
ringColor: '#fb923c',
|
||||
},
|
||||
{
|
||||
id: 'cyan_neon',
|
||||
name: 'Cyan Neon',
|
||||
description: 'Futuristisch, High-Tech & Klar',
|
||||
primary: '#0891b2',
|
||||
accent: '#22d3ee',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#f43f5e',
|
||||
bgGlow1: '#06b6d4',
|
||||
bgGlow2: '#155e75',
|
||||
gradientFrom: '#0891b2',
|
||||
gradientTo: '#155e75',
|
||||
cardBorder: 'rgba(34, 211, 238, 0.35)',
|
||||
textHighlight: '#cffafe',
|
||||
buttonBg: '#0891b2',
|
||||
ringColor: '#22d3ee',
|
||||
},
|
||||
{
|
||||
id: 'midnight_slate',
|
||||
name: 'Midnight Slate',
|
||||
description: 'Minimalistisch, Dunkel & Puristisch',
|
||||
primary: '#475569',
|
||||
accent: '#cbd5e1',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#ef4444',
|
||||
bgGlow1: '#64748b',
|
||||
bgGlow2: '#1e293b',
|
||||
gradientFrom: '#475569',
|
||||
gradientTo: '#0f172a',
|
||||
cardBorder: 'rgba(203, 213, 225, 0.35)',
|
||||
textHighlight: '#f1f5f9',
|
||||
buttonBg: '#475569',
|
||||
ringColor: '#cbd5e1',
|
||||
},
|
||||
]
|
||||
@@ -19,7 +19,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.supabase.net wss://*.supabase.net; frame-ancestors 'self'; form-action 'self';",
|
||||
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src 'self' http://127.0.0.1:54321 ws://127.0.0.1:54321 http://localhost:54321 ws://localhost:54321 https://*.supabase.co wss://*.supabase.co https://*.supabase.net wss://*.supabase.net; frame-ancestors 'self'; form-action 'self';",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
-- Migration: Update user creation trigger function to not assign admin role to info@hephex.de automatically
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
|
||||
@@ -1,30 +1,63 @@
|
||||
-- Migration: Secure User Roles from Self-Escalation
|
||||
-- Purpose: Prevent users from updating their own roles to 'admin' using RLS / Triggers.
|
||||
-- Purpose: Prevent users from updating their own roles to 'admin' using RLS / Triggers, while granting full access to service_role.
|
||||
|
||||
-- Create helper function to check admin role bypassing RLS (SECURITY DEFINER)
|
||||
CREATE OR REPLACE FUNCTION public.is_admin(user_id UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM public.users
|
||||
WHERE id = user_id AND role = 'admin'
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Ensure RLS is enabled on users
|
||||
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Grant privileges to PostgREST roles
|
||||
GRANT ALL ON TABLE public.users TO service_role;
|
||||
GRANT ALL ON TABLE public.users TO authenticated;
|
||||
GRANT SELECT ON TABLE public.users TO anon;
|
||||
GRANT ALL ON TABLE public.users TO postgres;
|
||||
|
||||
-- Service role policy
|
||||
DROP POLICY IF EXISTS "service_role_all_users" ON public.users;
|
||||
CREATE POLICY "service_role_all_users" ON public.users
|
||||
FOR ALL
|
||||
TO service_role
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Policy to allow users to view their own records
|
||||
DROP POLICY IF EXISTS select_own_user ON public.users;
|
||||
CREATE POLICY select_own_user ON public.users
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = id);
|
||||
|
||||
-- Policy to allow admins to view all users
|
||||
DROP POLICY IF EXISTS select_all_users_for_admin ON public.users;
|
||||
CREATE POLICY select_all_users_for_admin ON public.users
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
|
||||
);
|
||||
USING (public.is_admin(auth.uid()));
|
||||
|
||||
-- Policy to allow admins to update users
|
||||
DROP POLICY IF EXISTS update_users_for_admin ON public.users;
|
||||
CREATE POLICY update_users_for_admin ON public.users
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (public.is_admin(auth.uid()))
|
||||
WITH CHECK (public.is_admin(auth.uid()));
|
||||
|
||||
-- Trigger to prevent any role updates to 'admin' from unauthorized users
|
||||
CREATE OR REPLACE FUNCTION check_user_role_escalation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Only allow changes to the role column if executed by the service_role
|
||||
-- Allow changes to the role column if executed by administrative DB roles or service_role JWT
|
||||
IF (TG_OP = 'UPDATE' AND OLD.role IS DISTINCT FROM NEW.role) OR (TG_OP = 'INSERT') THEN
|
||||
IF current_setting('role', true) <> 'service_role' THEN
|
||||
IF current_setting('request.jwt.claim.role', true) <> 'service_role'
|
||||
AND current_setting('role', true) NOT IN ('service_role', 'supabase_admin', 'postgres') THEN
|
||||
-- Partners cannot upgrade themselves or others to admin
|
||||
IF NEW.role = 'admin' THEN
|
||||
RAISE EXCEPTION 'Unberechtigtes Rollen-Upgrade verweigert.';
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
-- Migration: Restrict settings table write access to admins only
|
||||
-- Previously any authenticated user could write to settings (including licserver_api_key).
|
||||
-- This fixes the RLS policy to only allow admins to write.
|
||||
-- Migration: Restrict settings table write access to admins only and allow service_role full access
|
||||
|
||||
ALTER TABLE public.settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Grant privileges to PostgREST roles
|
||||
GRANT ALL ON TABLE public.settings TO service_role;
|
||||
GRANT ALL ON TABLE public.settings TO authenticated;
|
||||
GRANT SELECT ON TABLE public.settings TO anon;
|
||||
GRANT ALL ON TABLE public.settings TO postgres;
|
||||
|
||||
-- Service role policy
|
||||
DROP POLICY IF EXISTS "service_role_all_settings" ON public.settings;
|
||||
CREATE POLICY "service_role_all_settings" ON public.settings
|
||||
FOR ALL
|
||||
TO service_role
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow authenticated write on settings" ON public.settings;
|
||||
DROP POLICY IF EXISTS "Only admins can write settings" ON public.settings;
|
||||
|
||||
-- Admins can write all settings
|
||||
CREATE POLICY "Only admins can write settings" ON public.settings
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.users
|
||||
WHERE id = auth.uid() AND role = 'admin'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.users
|
||||
WHERE id = auth.uid() AND role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- All authenticated users can still READ settings (needed for proxy routes to load licserver config)
|
||||
-- The READ policy remains: "Allow authenticated read on settings"
|
||||
TO authenticated
|
||||
USING (public.is_admin(auth.uid()))
|
||||
WITH CHECK (public.is_admin(auth.uid()));
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Migration: Add Branding & Company Settings to public.settings
|
||||
-- Stores company details, addresses, and chosen color scheme.
|
||||
|
||||
ALTER TABLE public.settings
|
||||
ADD COLUMN IF NOT EXISTS company_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS street TEXT,
|
||||
ADD COLUMN IF NOT EXISTS zip TEXT,
|
||||
ADD COLUMN IF NOT EXISTS city TEXT,
|
||||
ADD COLUMN IF NOT EXISTS billing_street TEXT,
|
||||
ADD COLUMN IF NOT EXISTS billing_zip TEXT,
|
||||
ADD COLUMN IF NOT EXISTS billing_city TEXT,
|
||||
ADD COLUMN IF NOT EXISTS same_billing_address BOOLEAN DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS logo_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS developer_footer TEXT DEFAULT 'B2B Shop made by hephex',
|
||||
ADD COLUMN IF NOT EXISTS color_scheme TEXT DEFAULT 'modern_blue',
|
||||
ADD COLUMN IF NOT EXISTS primary_color TEXT DEFAULT '#2563eb',
|
||||
ADD COLUMN IF NOT EXISTS accent_color TEXT DEFAULT '#38bdf8';
|
||||
@@ -2,15 +2,15 @@
|
||||
INSERT INTO public.products (id, name, description, base_price, tax_rate, billing_interval, show_in_abo, show_in_kauf)
|
||||
VALUES
|
||||
('d1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'CASPOS Cloud', 'Die modulare Cloud-Lösung für Ihren Einzelhandel.', 49.00, 19.00, 'monthly', true, true),
|
||||
('d2a2a2a2-a2a2-a2a2-a2a2-a2a2-a2a2-a2a2a2a2', 'CASPOS Gastro', 'Spezialisiert auf Gastronomie mit Tischplan und Funkbonieren.', 79.00, 19.00, 'monthly', true, true),
|
||||
('prod-poscloud-fee', 'POS Cloud Grundgebühr', 'Monatliche Grundgebühr für die POS Cloud Nutzung.', 19.00, 19.00, 'monthly', false, false);
|
||||
('d2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'CASPOS Gastro', 'Spezialisiert auf Gastronomie mit Tischplan und Funkbonieren.', 79.00, 19.00, 'monthly', true, true),
|
||||
('d3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3', 'POS Cloud Grundgebühr', 'Monatliche Grundgebühr für die POS Cloud Nutzung.', 19.00, 19.00, 'monthly', false, false);
|
||||
|
||||
-- Seed Modules for CASPOS Cloud
|
||||
INSERT INTO public.product_modules (id, product_id, name, description, price, requirements, exclusions)
|
||||
VALUES
|
||||
('m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Bestandsführung Pro', 'Erweiterte Lagerverwaltung.', 15.00, '{}', '{}'),
|
||||
('m2a2a2a2-a2a2-a2a2-a2a2-a2a2-a2a2-a2a2a2a2', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'DATEV Export', 'Direkte Schnittstelle zum Steuerberater.', 10.00, '{}', '{}'),
|
||||
('m3a3a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Filialverwaltung', 'Zentrale Steuerung mehrerer Standorte.', 25.00, '{"m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1"}', '{}'),
|
||||
('m4a4a4a4-a4a4-a4a4-a4a4-a4a4-a4a4-a4a4-a4a4', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Small Business Modus', 'Reduzierter Funktionsumfang für Kleinunternehmer.', 0.00, '{}', '{"m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1", "m3a3a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3"}'),
|
||||
('m-poscloud-cloud', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}'),
|
||||
('m-poscloud-gastro', 'd2a2a2a2-a2a2-a2a2-a2a2-a2a2-a2a2-a2a2a2a2', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}');
|
||||
('e1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Bestandsführung Pro', 'Erweiterte Lagerverwaltung.', 15.00, '{}', '{}'),
|
||||
('e2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'DATEV Export', 'Direkte Schnittstelle zum Steuerberater.', 10.00, '{}', '{}'),
|
||||
('e3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Filialverwaltung', 'Zentrale Steuerung mehrerer Standorte.', 25.00, '{"e1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1"}', '{}'),
|
||||
('e4a4a4a4-a4a4-a4a4-a4a4-a4a4a4a4a4a4', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Small Business Modus', 'Reduzierter Funktionsumfang für Kleinunternehmer.', 0.00, '{}', '{"e1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1", "e3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3"}'),
|
||||
('e5a5a5a5-a5a5-a5a5-a5a5-a5a5a5a5a5a5', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}'),
|
||||
('e6a6a6a6-a6a6-a6a6-a6a6-a6a6a6a6a6a6', 'd2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createAdminClient } from '@/lib/supabase/admin';
|
||||
|
||||
/**
|
||||
* Send an email using database-configured SMTP settings (or environment variable fallback).
|
||||
* If SMTP is not configured, logs a warning and gracefully skips sending instead of throwing.
|
||||
* @param to Recipient address
|
||||
* @param subject Subject line
|
||||
* @param text Plain‑text body
|
||||
@@ -26,6 +27,7 @@ export async function sendMail({
|
||||
contentType?: string;
|
||||
}[];
|
||||
}) {
|
||||
try {
|
||||
// Query custom SMTP settings from database
|
||||
const supabase = createAdminClient();
|
||||
const { data: dbSettings } = await supabase
|
||||
@@ -36,15 +38,22 @@ export async function sendMail({
|
||||
|
||||
// Resolve config from DB or environment variables
|
||||
const host = dbSettings?.host || process.env.SMTP_HOST;
|
||||
const port = dbSettings?.port ? Number(dbSettings.port) : Number(process.env.SMTP_PORT);
|
||||
const port = dbSettings?.port
|
||||
? Number(dbSettings.port)
|
||||
: process.env.SMTP_PORT
|
||||
? Number(process.env.SMTP_PORT)
|
||||
: 587;
|
||||
const secure = dbSettings
|
||||
? !!dbSettings.secure
|
||||
: (process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1');
|
||||
: process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1';
|
||||
const user = dbSettings?.user || process.env.SMTP_USER;
|
||||
const pass = dbSettings?.pass || process.env.SMTP_PASS;
|
||||
|
||||
if (!host || !user) {
|
||||
throw new Error('SMTP host and user must be configured (either in settings database table or environment variables).');
|
||||
console.warn(
|
||||
`SMTP is not configured (missing host/user). E-Mail to "${to}" skipped.`
|
||||
);
|
||||
return { messageId: 'skipped-no-smtp-config', skipped: true };
|
||||
}
|
||||
|
||||
// Create transporter dynamically on send request
|
||||
@@ -54,12 +63,17 @@ export async function sendMail({
|
||||
secure,
|
||||
auth: {
|
||||
user,
|
||||
pass,
|
||||
pass: pass || '',
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
|
||||
const fromAddress = process.env.SMTP_FROM || user;
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: user,
|
||||
from: fromAddress,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
@@ -68,5 +82,8 @@ export async function sendMail({
|
||||
});
|
||||
|
||||
return info;
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to send email to "${to}":`, err.message || err);
|
||||
return { messageId: 'failed-smtp-error', error: err.message || err };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user