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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Download, Upload, Database, AlertTriangle, Loader2, KeyRound,
|
Download, Upload, Database, Loader2, KeyRound,
|
||||||
Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi, Sliders, ShieldAlert, FileArchive
|
Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi, Sliders, ShieldAlert, FileArchive,
|
||||||
|
Building2, MapPin, Receipt, Palette, Save
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
|
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';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
@@ -56,6 +61,25 @@ export default function AdminSettings() {
|
|||||||
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
|
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
const [licMsg, setLicMsg] = useState('');
|
const [licMsg, setLicMsg] = useState('');
|
||||||
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
|
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();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,10 +92,10 @@ export default function AdminSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parallele Abfrage von User-Rolle & LicServer Settings
|
const [userRes, licRes, brandRes] = await Promise.all([
|
||||||
const [userRes, licRes] = await Promise.all([
|
|
||||||
supabase.from('users').select('role').eq('id', user.id).single(),
|
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') {
|
if (userRes.error || !userRes.data || userRes.data.role === 'verwaltung') {
|
||||||
@@ -86,6 +110,10 @@ export default function AdminSettings() {
|
|||||||
setLicUrl(licRes.data.licserver_base_url || '');
|
setLicUrl(licRes.data.licserver_base_url || '');
|
||||||
setLicKey(licRes.data.licserver_api_key || '');
|
setLicKey(licRes.data.licserver_api_key || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (brandRes) {
|
||||||
|
setBranding(brandRes);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Fehler bei checkAccess in Einstellungen:", err);
|
console.error("Fehler bei checkAccess in Einstellungen:", err);
|
||||||
} finally {
|
} 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) {
|
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>;
|
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>
|
<div>
|
||||||
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
|
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
|
||||||
<p className="text-slate-400 text-xs mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -203,7 +254,7 @@ export default function AdminSettings() {
|
|||||||
animate="visible"
|
animate="visible"
|
||||||
className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-5"
|
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
|
<motion.div
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
whileHover={{ y: -3 }}
|
whileHover={{ y: -3 }}
|
||||||
@@ -226,7 +277,7 @@ export default function AdminSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Bento Item 2: Daten Export (1 Spalte) */}
|
{/* Bento Item 2: Daten Export */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
whileHover={{ y: -3 }}
|
whileHover={{ y: -3 }}
|
||||||
@@ -239,7 +290,7 @@ export default function AdminSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-base text-white">DB Backup</h3>
|
<h3 className="font-bold text-base text-white">DB Backup</h3>
|
||||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,7 +307,7 @@ export default function AdminSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Bento Item 3: Daten Import (2 Spalten / Span 2 auf Desktop) */}
|
{/* Bento Item 3: Daten Import */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
whileHover={{ y: -3 }}
|
whileHover={{ y: -3 }}
|
||||||
@@ -309,7 +360,214 @@ export default function AdminSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.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
|
<motion.div
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
whileHover={{ y: -3 }}
|
whileHover={{ y: -3 }}
|
||||||
@@ -326,7 +584,6 @@ export default function AdminSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* LicServer Status Pill */}
|
|
||||||
{licStatus && (
|
{licStatus && (
|
||||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold border ${
|
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold border ${
|
||||||
licStatus.ok
|
licStatus.ok
|
||||||
@@ -348,7 +605,6 @@ export default function AdminSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
{/* Base URL */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="lic-url" className="text-xs font-bold text-slate-300 flex items-center gap-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
|
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
|
||||||
@@ -362,7 +618,6 @@ export default function AdminSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="lic-key" className="text-xs font-bold text-slate-300 flex items-center gap-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
|
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
|
||||||
@@ -388,7 +643,6 @@ export default function AdminSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-2">
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
id="lic-save-btn"
|
id="lic-save-btn"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
html {
|
html {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
@@ -19,6 +18,17 @@ html {
|
|||||||
--oklch-text-muted: 0.70 0.01 148;
|
--oklch-text-muted: 0.70 0.01 148;
|
||||||
--oklch-border: 0.31 0.012 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 */
|
/* Classic HSL Mappings */
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
@@ -42,6 +52,110 @@ html {
|
|||||||
--radius: 0.75rem;
|
--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 {
|
input, select, textarea, option {
|
||||||
background-color: oklch(var(--oklch-surface)) !important;
|
background-color: oklch(var(--oklch-surface)) !important;
|
||||||
color: oklch(var(--oklch-text-main)) !important;
|
color: oklch(var(--oklch-text-main)) !important;
|
||||||
@@ -49,9 +163,9 @@ input, select, textarea, option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:focus, select:focus, textarea:focus {
|
input:focus, select:focus, textarea:focus {
|
||||||
border-color: oklch(var(--oklch-primary)) !important;
|
border-color: var(--primary-custom) !important;
|
||||||
outline: none !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 {
|
option {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
import { InactivityTracker } from "@/components/inactivity-tracker";
|
import { InactivityTracker } from "@/components/inactivity-tracker";
|
||||||
import { HeaderWrapper } from "@/components/HeaderWrapper";
|
import { HeaderWrapper } from "@/components/HeaderWrapper";
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
|||||||
@@ -2,29 +2,6 @@
|
|||||||
|
|
||||||
import React, { useEffect, useRef } from 'react'
|
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 }) {
|
export function AsciiShaderBackground({ className = '' }: { className?: string }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
@@ -47,6 +24,32 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
|||||||
let scrollProgress = 0
|
let scrollProgress = 0
|
||||||
let targetScrollProgress = 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 resize = () => {
|
||||||
const width = window.innerWidth
|
const width = window.innerWidth
|
||||||
const height = window.innerHeight
|
const height = window.innerHeight
|
||||||
@@ -99,54 +102,40 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
|||||||
return
|
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.font = `${fontSize}px monospace`
|
||||||
ctx.textAlign = 'center'
|
ctx.textAlign = 'center'
|
||||||
ctx.textBaseline = 'middle'
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
const timeSec = time * 0.001
|
const timeSec = time * 0.001
|
||||||
|
const activeHue = getDynamicHue()
|
||||||
|
|
||||||
for (let r = 0; r < rows; r++) {
|
for (let r = 0; r < rows; r++) {
|
||||||
for (let c = 0; c < cols; c++) {
|
for (let c = 0; c < cols; c++) {
|
||||||
const x = c * fontSize + fontSize / 2
|
const x = c * fontSize + fontSize / 2
|
||||||
const y = r * fontSize + fontSize / 2
|
const y = r * fontSize + fontSize / 2
|
||||||
|
|
||||||
// Abstandsvektor zur gedämpften Mausposition
|
|
||||||
const dx = x - mouse.x
|
const dx = x - mouse.x
|
||||||
const dy = y - mouse.y
|
const dy = y - mouse.y
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
// Maus-Störung (Vektorfeld-Ablenkung): Phase & Amplitude lokal beeinflussen
|
|
||||||
const mouseInfluence = Math.exp(-dist / 250)
|
const mouseInfluence = Math.exp(-dist / 250)
|
||||||
const mouseDistortion = (Math.atan2(dy, dx) + (dx + dy) * 0.003) * mouseInfluence * 3.0
|
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 waveX = Math.sin(c * 0.08 + timeSec * 0.8 + mouseDistortion)
|
||||||
const waveY = Math.cos(r * 0.08 + timeSec * 0.6 + 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)
|
const flowValue = Math.sin(waveX + waveY + (c + r) * 0.04 + timeSec * 0.4)
|
||||||
|
|
||||||
// Zeichenauswahl basierend auf Strömungsfeld
|
const normalizedFlow = (flowValue + 1) * 0.5
|
||||||
const normalizedFlow = (flowValue + 1) * 0.5 // Range 0..1
|
|
||||||
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
|
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
|
||||||
const char = chars[charIndex]
|
const char = chars[charIndex]
|
||||||
|
|
||||||
// Leerzeichen für ruhiges Raster überspringen
|
|
||||||
if (char === ' ') continue
|
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 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
|
ctx.fillStyle = `hsl(${activeHue} 80% ${lightness}% / ${alpha.toFixed(2)})`
|
||||||
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.fillText(char, x, y)
|
ctx.fillText(char, x, y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +155,7 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)',
|
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)'
|
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={{
|
style={{
|
||||||
backgroundSize: '40px 40px',
|
backgroundSize: '40px 40px',
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
linear-gradient(to right, 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, oklch(0.31 0.012 148 / 0.4) 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 { ProcessSteps } from '@/components/ProcessSteps'
|
||||||
import { ScrollIndicator } from '@/components/ScrollIndicator'
|
import { ScrollIndicator } from '@/components/ScrollIndicator'
|
||||||
import { WorkspaceZentrale } from '@/components/WorkspaceZentrale'
|
import { WorkspaceZentrale } from '@/components/WorkspaceZentrale'
|
||||||
|
import { useTheme } from '@/components/ThemeProvider'
|
||||||
|
|
||||||
interface HomeClientProps {
|
interface HomeClientProps {
|
||||||
initialUser: User | null
|
initialUser: User | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeClient({ initialUser }: HomeClientProps) {
|
export function HomeClient({ initialUser }: HomeClientProps) {
|
||||||
|
const { branding } = useTheme()
|
||||||
const [user, setUser] = useState<User | null>(initialUser)
|
const [user, setUser] = useState<User | null>(initialUser)
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const [calendarOpen, setCalendarOpen] = useState(false)
|
const [calendarOpen, setCalendarOpen] = useState(false)
|
||||||
@@ -265,7 +267,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="w-full border-t border-slate-900 bg-slate-950 py-8 px-4 md:px-6 relative z-10">
|
<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">
|
<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">
|
<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="/impressum">Impressum</Link>
|
||||||
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/datenschutz">Datenschutz</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 { signOut } from "@/lib/actions/auth";
|
||||||
import { resolveSupabaseUrl } from "@/lib/utils";
|
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||||
import { DemoWrapper } from "./DemoWrapper";
|
import { DemoWrapper } from "./DemoWrapper";
|
||||||
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
|
|
||||||
interface NavbarClientProps {
|
interface NavbarClientProps {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -18,6 +19,7 @@ interface NavbarClientProps {
|
|||||||
|
|
||||||
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { branding } = useTheme();
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(user);
|
const [currentUser, setCurrentUser] = useState<User | null>(user);
|
||||||
const [userRole, setUserRole] = useState<string>(role);
|
const [userRole, setUserRole] = useState<string>(role);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
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">
|
<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="/">
|
<Link className="flex items-center justify-center gap-2 group" href="/">
|
||||||
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
|
{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>
|
<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">
|
<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
|
Demo
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ export function ScrollIndicator() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<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)]"
|
className="fixed top-0 left-0 right-0 h-[3px] origin-left z-50 pointer-events-none"
|
||||||
style={{ scaleX }}
|
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 { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
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() {
|
export function SetupWizard() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [step, setStep] = useState(1)
|
const [step, setStep] = useState(1)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
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({
|
const [adminForm, setAdminForm] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
companyName: '',
|
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
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({
|
const [smtpForm, setSmtpForm] = useState({
|
||||||
host: '',
|
host: '',
|
||||||
port: '587',
|
port: '587',
|
||||||
@@ -35,36 +52,67 @@ export function SetupWizard() {
|
|||||||
pass: '',
|
pass: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validation checks for Admin form
|
// Validations
|
||||||
const isAdminFormValid =
|
const isAdminFormValid =
|
||||||
adminForm.email.includes('@') &&
|
adminForm.email.includes('@') &&
|
||||||
adminForm.password.length >= 6 &&
|
adminForm.password.length >= 6 &&
|
||||||
adminForm.password === adminForm.confirmPassword &&
|
adminForm.password === adminForm.confirmPassword &&
|
||||||
adminForm.companyName.trim().length > 0 &&
|
|
||||||
adminForm.firstName.trim().length > 0 &&
|
adminForm.firstName.trim().length > 0 &&
|
||||||
adminForm.lastName.trim().length > 0
|
adminForm.lastName.trim().length > 0
|
||||||
|
|
||||||
// Validation checks for SMTP form
|
const isCompanyFormValid =
|
||||||
const isSmtpFormValid =
|
brandingForm.companyName.trim().length > 0 &&
|
||||||
smtpForm.host.trim().length > 0 &&
|
brandingForm.street.trim().length > 0 &&
|
||||||
!isNaN(Number(smtpForm.port)) &&
|
brandingForm.zip.trim().length > 0 &&
|
||||||
smtpForm.user.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 () => {
|
const handleFinishSetup = async () => {
|
||||||
if (!isAdminFormValid || !isSmtpFormValid) return
|
if (!isAdminFormValid || !isCompanyFormValid) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Submit details via server action
|
|
||||||
const res = await completeSetup(
|
const res = await completeSetup(
|
||||||
{
|
{
|
||||||
email: adminForm.email,
|
email: adminForm.email,
|
||||||
password: adminForm.password,
|
password: adminForm.password,
|
||||||
companyName: adminForm.companyName,
|
companyName: brandingForm.companyName,
|
||||||
firstName: adminForm.firstName,
|
firstName: adminForm.firstName,
|
||||||
lastName: adminForm.lastName,
|
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,
|
host: smtpForm.host,
|
||||||
port: Number(smtpForm.port),
|
port: Number(smtpForm.port),
|
||||||
@@ -80,20 +128,8 @@ export function SetupWizard() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Automatischer Login für flüssiges Erlebnis
|
// Setup erfolgreich – Weiterleitung zur Login-Seite
|
||||||
const supabase = createClient()
|
router.push('/auth/login?setup=success')
|
||||||
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('/')
|
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
|
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
|
||||||
@@ -103,19 +139,19 @@ export function SetupWizard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#020617] text-white flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
<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 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="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 */}
|
{/* 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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xl font-bold tracking-tight text-blue-400">CASPOS</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">Initialisierung</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>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{[1, 2, 3].map((s) => (
|
{[1, 2, 3, 4, 5].map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s}
|
key={s}
|
||||||
className={`h-1.5 rounded-full transition-all duration-300 ${s === step ? 'w-8 bg-blue-500' : 'w-2 bg-slate-800'
|
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>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
{/* STEP 1: Willkommen */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="step1"
|
key="step1"
|
||||||
initial={{ opacity: 0, y: 15 }}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -15 }}
|
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"
|
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">
|
<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>
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h1 className="text-3xl font-extrabold tracking-tight">Willkommen bei CASPOS!</h1>
|
<h1 className="text-3xl font-extrabold tracking-tight">B2B Shop made by hephex</h1>
|
||||||
<p className="text-slate-400 leading-relaxed">
|
<p className="text-slate-400 leading-relaxed text-sm">
|
||||||
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop in wenigen Schritten ein. Wir konfigurieren Ihr Administrator-Konto und die Mailverbindung.
|
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop ein. Wir konfigurieren Administrator-Zugang, Firmendaten, Rechnungsadresse und Ihr persönliches Farbschema.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,11 +189,19 @@ export function SetupWizard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,13 +215,13 @@ export function SetupWizard() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* STEP 2: Admin-Konto */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="step2"
|
key="step2"
|
||||||
initial={{ opacity: 0, y: 15 }}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -15 }}
|
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"
|
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -186,7 +230,7 @@ export function SetupWizard() {
|
|||||||
Admin-Konto anlegen
|
Admin-Konto anlegen
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-400 text-sm">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,19 +262,6 @@ export function SetupWizard() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-slate-300 text-xs">E-Mail-Adresse *</Label>
|
<Label className="text-slate-300 text-xs">E-Mail-Adresse *</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -275,7 +306,7 @@ export function SetupWizard() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<Button variant="ghost" onClick={() => setStep(1)} className="text-white hover:bg-white/5">
|
||||||
Zurück
|
Zurück
|
||||||
</Button>
|
</Button>
|
||||||
@@ -283,6 +314,203 @@ export function SetupWizard() {
|
|||||||
onClick={() => setStep(3)}
|
onClick={() => setStep(3)}
|
||||||
disabled={!isAdminFormValid}
|
disabled={!isAdminFormValid}
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
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
|
Weiter zu SMTP
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
@@ -291,22 +519,27 @@ export function SetupWizard() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 3 && (
|
{/* STEP 5: SMTP Mailserver */}
|
||||||
|
{step === 5 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="step3"
|
key="step5"
|
||||||
initial={{ opacity: 0, y: 15 }}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -15 }}
|
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"
|
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<Mail className="w-6 h-6 text-blue-400" />
|
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||||
SMTP-Mailserver konfigurieren
|
<Mail className="w-6 h-6 text-blue-400" />
|
||||||
</h2>
|
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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -319,7 +552,7 @@ export function SetupWizard() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="col-span-2 space-y-1.5">
|
<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
|
<Input
|
||||||
value={smtpForm.host}
|
value={smtpForm.host}
|
||||||
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
|
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
|
||||||
@@ -328,7 +561,7 @@ export function SetupWizard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
value={smtpForm.port}
|
value={smtpForm.port}
|
||||||
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
|
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
|
||||||
@@ -339,7 +572,7 @@ export function SetupWizard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
value={smtpForm.user}
|
value={smtpForm.user}
|
||||||
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
|
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
|
||||||
@@ -359,32 +592,73 @@ export function SetupWizard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<input
|
<div className="flex items-center gap-2">
|
||||||
type="checkbox"
|
<input
|
||||||
id="smtp_secure"
|
type="checkbox"
|
||||||
checked={smtpForm.secure}
|
id="smtp_secure"
|
||||||
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
|
checked={smtpForm.secure}
|
||||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
|
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
|
||||||
/>
|
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)
|
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
|
||||||
</Label>
|
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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setStep(2)}
|
onClick={() => setStep(4)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="text-white hover:bg-white/5"
|
className="text-white hover:bg-white/5"
|
||||||
>
|
>
|
||||||
Zurück
|
Zurück
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={handleFinishSetup}
|
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"
|
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 ? (
|
{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) => {
|
const handleVerify2FA = async (code: string) => {
|
||||||
if (!userId) return;
|
if (!userId || isVerifyingRef.current) return;
|
||||||
|
isVerifyingRef.current = true;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -160,6 +163,7 @@ export function LoginForm({
|
|||||||
inputRefs.current[0]?.focus();
|
inputRefs.current[0]?.focus();
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
isVerifyingRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,47 +15,6 @@ export async function signIn(email: string, password: string, deviceHash?: strin
|
|||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
if (error) {
|
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 }
|
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) {
|
export async function resend2FACode(userId: string, deviceHash: string) {
|
||||||
try {
|
try {
|
||||||
const adminClient = createAdminClient()
|
const adminClient = createAdminClient()
|
||||||
const { data: user } = await adminClient
|
const { data: authUser, error: authError } = await adminClient.auth.admin.getUserById(userId)
|
||||||
.from('users')
|
const email = authUser?.user?.email
|
||||||
.select('email')
|
|
||||||
.eq('id', userId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
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 }
|
return { success: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Resend 2FA error:', err)
|
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) {
|
export async function verifyDevice2FA(userId: string, code: string, deviceHash: string) {
|
||||||
try {
|
try {
|
||||||
const adminClient = createAdminClient()
|
const adminClient = createAdminClient()
|
||||||
|
const cleanCode = code.trim()
|
||||||
|
|
||||||
// Code prüfen
|
// Code prüfen (unter Berücksichtigung von Leerzeichen & device_hash)
|
||||||
const { data: codeEntry, error: codeError } = await adminClient
|
const { data: codeEntries, error: codeError } = await adminClient
|
||||||
.from('device_verification_codes')
|
.from('device_verification_codes')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.eq('device_hash', deviceHash)
|
.eq('code', cleanCode)
|
||||||
.eq('code', code)
|
.order('created_at', { ascending: false })
|
||||||
.maybeSingle()
|
.limit(1)
|
||||||
|
|
||||||
|
const codeEntry = codeEntries && codeEntries.length > 0 ? codeEntries[0] : null
|
||||||
|
|
||||||
if (codeError || !codeEntry) {
|
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.' }
|
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 headersList = await headers()
|
||||||
const userAgent = headersList.get('user-agent') || ''
|
const userAgent = headersList.get('user-agent') || ''
|
||||||
const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || headersList.get('x-real-ip') || ''
|
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
|
await adminClient
|
||||||
.from('known_devices')
|
.from('known_devices')
|
||||||
.upsert({
|
.upsert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
device_hash: deviceHash,
|
device_hash: targetDeviceHash,
|
||||||
user_agent: userAgent,
|
user_agent: userAgent,
|
||||||
ip_address: ip,
|
ip_address: ip,
|
||||||
verified_at: new Date().toISOString(),
|
verified_at: new Date().toISOString(),
|
||||||
}, { onConflict: 'user_id,device_hash' })
|
}, { onConflict: 'user_id,device_hash' })
|
||||||
|
|
||||||
// Verbrauchten Code löschen
|
// Verbrauchte Codes für diesen User löschen
|
||||||
await adminClient
|
await adminClient
|
||||||
.from('device_verification_codes')
|
.from('device_verification_codes')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', codeEntry.id)
|
.eq('user_id', userId)
|
||||||
|
|
||||||
// Andere Sessions beenden
|
// Andere Sessions beenden
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -322,7 +298,7 @@ export async function verifyDevice2FA(userId: string, code: string, deviceHash:
|
|||||||
return { success: true, role: userData?.role || 'partner' }
|
return { success: true, role: userData?.role || 'partner' }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Verify 2FA error:', err)
|
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 { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
export type AdminSetupData = {
|
export type AdminSetupData = {
|
||||||
email: string
|
email: string
|
||||||
@@ -11,6 +12,19 @@ export type AdminSetupData = {
|
|||||||
lastName: string
|
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 = {
|
export type SmtpSetupData = {
|
||||||
host: string
|
host: string
|
||||||
port: number
|
port: number
|
||||||
@@ -32,26 +46,21 @@ export async function isSetupNeeded(): Promise<boolean> {
|
|||||||
perPage: 1
|
perPage: 1
|
||||||
})
|
})
|
||||||
|
|
||||||
if (authError) {
|
if (!authError && authData && authData.users && authData.users.length > 0) {
|
||||||
console.error('Error checking auth users list:', authError)
|
// Mindestens ein Auth-Benutzer vorhanden -> Setup ist beendet!
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authData.users.length > 0) {
|
// 2. Zusatzprüfung public.users Tabelle
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if any user exists in public.users table
|
|
||||||
const { count, error: dbError } = await admin
|
const { count, error: dbError } = await admin
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('id', { count: 'exact' })
|
||||||
|
|
||||||
if (dbError) {
|
if (!dbError && typeof count === 'number' && count > 0) {
|
||||||
console.error('Error checking users table status:', dbError)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return count === 0
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Exception checking setup status:', e)
|
console.error('Exception checking setup status:', e)
|
||||||
return false
|
return false
|
||||||
@@ -60,14 +69,14 @@ export async function isSetupNeeded(): Promise<boolean> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Completes the initial setup by creating the first admin user,
|
* 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(
|
export async function completeSetup(
|
||||||
adminData: AdminSetupData,
|
adminData: AdminSetupData,
|
||||||
|
brandingData: BrandingSetupData,
|
||||||
smtpData: SmtpSetupData
|
smtpData: SmtpSetupData
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// 1. Double check if setup is actually needed to prevent double runs
|
|
||||||
const needed = await isSetupNeeded()
|
const needed = await isSetupNeeded()
|
||||||
if (!needed) {
|
if (!needed) {
|
||||||
return { success: false, error: 'Setup bereits abgeschlossen.' }
|
return { success: false, error: 'Setup bereits abgeschlossen.' }
|
||||||
@@ -75,7 +84,7 @@ export async function completeSetup(
|
|||||||
|
|
||||||
const admin = createAdminClient()
|
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({
|
const { data: authData, error: authError } = await admin.auth.admin.createUser({
|
||||||
email: adminData.email,
|
email: adminData.email,
|
||||||
password: adminData.password,
|
password: adminData.password,
|
||||||
@@ -88,17 +97,20 @@ export async function completeSetup(
|
|||||||
|
|
||||||
const userId = authData.user.id
|
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
|
const { error: roleError } = await admin
|
||||||
.from('users')
|
.from('users')
|
||||||
.update({ role: 'admin' })
|
.upsert({
|
||||||
.eq('id', userId)
|
id: userId,
|
||||||
|
role: 'admin',
|
||||||
|
}, { onConflict: 'id' })
|
||||||
|
|
||||||
if (roleError) {
|
if (roleError) {
|
||||||
console.error('Error setting user role to admin:', 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
|
const { error: profileError } = await admin
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update({
|
||||||
@@ -113,22 +125,46 @@ export async function completeSetup(
|
|||||||
console.error('Error updating admin profile:', profileError)
|
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: smtpError } = await admin
|
const { error: brandingError } = await admin
|
||||||
.from('settings')
|
.from('settings')
|
||||||
.upsert({
|
.upsert({
|
||||||
id: 'smtp',
|
id: 'branding',
|
||||||
host: smtpData.host,
|
company_name: adminData.companyName,
|
||||||
port: smtpData.port,
|
street: brandingData.street,
|
||||||
secure: smtpData.secure,
|
zip: brandingData.zip,
|
||||||
user: smtpData.user,
|
city: brandingData.city,
|
||||||
pass: smtpData.pass,
|
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(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (smtpError) {
|
if (brandingError) {
|
||||||
console.error('Error saving SMTP settings:', smtpError)
|
console.error('Error saving branding settings:', brandingError)
|
||||||
return { success: false, error: `Fehler beim Speichern der SMTP-Einstellungen: ${smtpError.message}` }
|
}
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
id: 'smtp',
|
||||||
|
host: smtpData.host,
|
||||||
|
port: smtpData.port,
|
||||||
|
secure: smtpData.secure,
|
||||||
|
user: smtpData.user,
|
||||||
|
pass: smtpData.pass,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (smtpError) {
|
||||||
|
console.error('Error saving SMTP settings:', smtpError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
@@ -138,3 +174,46 @@ export async function completeSetup(
|
|||||||
return { success: false, error: e.message || 'Unerwarteter Fehler beim Setup.' }
|
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',
|
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()
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|||||||
@@ -1,30 +1,63 @@
|
|||||||
-- Migration: Secure User Roles from Self-Escalation
|
-- 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;
|
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
|
-- 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
|
CREATE POLICY select_own_user ON public.users
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (auth.uid() = id);
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
-- Policy to allow admins to view all users
|
-- 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
|
CREATE POLICY select_all_users_for_admin ON public.users
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (public.is_admin(auth.uid()));
|
||||||
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
|
|
||||||
);
|
-- 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
|
-- Trigger to prevent any role updates to 'admin' from unauthorized users
|
||||||
CREATE OR REPLACE FUNCTION check_user_role_escalation()
|
CREATE OR REPLACE FUNCTION check_user_role_escalation()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
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 (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
|
-- Partners cannot upgrade themselves or others to admin
|
||||||
IF NEW.role = 'admin' THEN
|
IF NEW.role = 'admin' THEN
|
||||||
RAISE EXCEPTION 'Unberechtigtes Rollen-Upgrade verweigert.';
|
RAISE EXCEPTION 'Unberechtigtes Rollen-Upgrade verweigert.';
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
-- Migration: Restrict settings table write access to admins only
|
-- Migration: Restrict settings table write access to admins only and allow service_role full access
|
||||||
-- Previously any authenticated user could write to settings (including licserver_api_key).
|
|
||||||
-- This fixes the RLS policy to only allow admins to write.
|
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 "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
|
CREATE POLICY "Only admins can write settings" ON public.settings
|
||||||
FOR ALL
|
FOR ALL
|
||||||
USING (
|
TO authenticated
|
||||||
EXISTS (
|
USING (public.is_admin(auth.uid()))
|
||||||
SELECT 1 FROM public.users
|
WITH CHECK (public.is_admin(auth.uid()));
|
||||||
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"
|
|
||||||
|
|
||||||
NOTIFY pgrst, 'reload schema';
|
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)
|
INSERT INTO public.products (id, name, description, base_price, tax_rate, billing_interval, show_in_abo, show_in_kauf)
|
||||||
VALUES
|
VALUES
|
||||||
('d1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'CASPOS Cloud', 'Die modulare Cloud-Lösung für Ihren Einzelhandel.', 49.00, 19.00, 'monthly', true, true),
|
('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),
|
('d2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', '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);
|
('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
|
-- Seed Modules for CASPOS Cloud
|
||||||
INSERT INTO public.product_modules (id, product_id, name, description, price, requirements, exclusions)
|
INSERT INTO public.product_modules (id, product_id, name, description, price, requirements, exclusions)
|
||||||
VALUES
|
VALUES
|
||||||
('m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Bestandsführung Pro', 'Erweiterte Lagerverwaltung.', 15.00, '{}', '{}'),
|
('e1a1a1a1-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, '{}', '{}'),
|
('e2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', '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"}', '{}'),
|
('e3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Filialverwaltung', 'Zentrale Steuerung mehrerer Standorte.', 25.00, '{"e1a1a1a1-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"}'),
|
('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"}'),
|
||||||
('m-poscloud-cloud', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}'),
|
('e5a5a5a5-a5a5-a5a5-a5a5-a5a5a5a5a5a5', '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, '{}', '{}');
|
('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).
|
* 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 to Recipient address
|
||||||
* @param subject Subject line
|
* @param subject Subject line
|
||||||
* @param text Plain‑text body
|
* @param text Plain‑text body
|
||||||
@@ -26,47 +27,63 @@ export async function sendMail({
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
}[];
|
}[];
|
||||||
}) {
|
}) {
|
||||||
// Query custom SMTP settings from database
|
try {
|
||||||
const supabase = createAdminClient();
|
// Query custom SMTP settings from database
|
||||||
const { data: dbSettings } = await supabase
|
const supabase = createAdminClient();
|
||||||
.from('settings')
|
const { data: dbSettings } = await supabase
|
||||||
.select('*')
|
.from('settings')
|
||||||
.eq('id', 'smtp')
|
.select('*')
|
||||||
.maybeSingle();
|
.eq('id', 'smtp')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
// Resolve config from DB or environment variables
|
// Resolve config from DB or environment variables
|
||||||
const host = dbSettings?.host || process.env.SMTP_HOST;
|
const host = dbSettings?.host || process.env.SMTP_HOST;
|
||||||
const port = dbSettings?.port ? Number(dbSettings.port) : Number(process.env.SMTP_PORT);
|
const port = dbSettings?.port
|
||||||
const secure = dbSettings
|
? Number(dbSettings.port)
|
||||||
? !!dbSettings.secure
|
: process.env.SMTP_PORT
|
||||||
: (process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1');
|
? Number(process.env.SMTP_PORT)
|
||||||
const user = dbSettings?.user || process.env.SMTP_USER;
|
: 587;
|
||||||
const pass = dbSettings?.pass || process.env.SMTP_PASS;
|
const secure = dbSettings
|
||||||
|
? !!dbSettings.secure
|
||||||
|
: 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) {
|
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
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
auth: {
|
||||||
|
user,
|
||||||
|
pass: pass || '',
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromAddress = process.env.SMTP_FROM || user;
|
||||||
|
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: fromAddress,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create transporter dynamically on send request
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
secure,
|
|
||||||
auth: {
|
|
||||||
user,
|
|
||||||
pass,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const info = await transporter.sendMail({
|
|
||||||
from: user,
|
|
||||||
to,
|
|
||||||
subject,
|
|
||||||
text,
|
|
||||||
html,
|
|
||||||
attachments,
|
|
||||||
});
|
|
||||||
|
|
||||||
return info;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user