481 lines
20 KiB
TypeScript
481 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useTransition, useRef } from 'react'
|
||
import { motion, AnimatePresence } from 'framer-motion'
|
||
import {
|
||
KeyRound,
|
||
Search,
|
||
Loader2,
|
||
CheckCircle2,
|
||
AlertTriangle,
|
||
XCircle,
|
||
Building2,
|
||
User,
|
||
CalendarDays,
|
||
Package,
|
||
ShieldCheck,
|
||
Hash,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Fingerprint,
|
||
} from 'lucide-react'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { lookupLicenseFromLicServer } from '@/lib/actions/licserver-config'
|
||
|
||
// ─── MOCK DATA (until real LicServer API is wired up) ───────────────────────
|
||
const MOCK_LICENSES: Record<string, LicenseInfo> = {
|
||
'CAS-2023-PRO-00123': {
|
||
licenseKey: 'CAS-2023-PRO-00123',
|
||
status: 'active',
|
||
product: 'CAS genesisWorld Professional',
|
||
edition: 'Professional',
|
||
version: '14.1.2',
|
||
seats: 10,
|
||
customer: 'Mustermann GmbH',
|
||
contact: 'Max Mustermann',
|
||
issuedAt: '2023-04-01',
|
||
expiresAt: '2026-03-31',
|
||
maintenanceUntil: '2025-03-31',
|
||
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client'],
|
||
},
|
||
'CAS-2020-STD-00456': {
|
||
licenseKey: 'CAS-2020-STD-00456',
|
||
status: 'expired',
|
||
product: 'CAS genesisWorld Standard',
|
||
edition: 'Standard',
|
||
version: '12.0.0',
|
||
seats: 5,
|
||
customer: 'Beispiel AG',
|
||
contact: 'Erika Muster',
|
||
issuedAt: '2020-01-15',
|
||
expiresAt: '2023-01-14',
|
||
maintenanceUntil: '2022-01-14',
|
||
modules: ['CRM'],
|
||
},
|
||
'CAS-2024-ENT-00789': {
|
||
licenseKey: 'CAS-2024-ENT-00789',
|
||
status: 'active',
|
||
product: 'CAS genesisWorld Enterprise',
|
||
edition: 'Enterprise',
|
||
version: '15.0.0',
|
||
seats: 50,
|
||
customer: 'Tech Solutions GmbH & Co. KG',
|
||
contact: 'Julia Schneider',
|
||
issuedAt: '2024-01-01',
|
||
expiresAt: '2026-12-31',
|
||
maintenanceUntil: '2026-12-31',
|
||
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client', 'AI Assistant', 'Analytics Pro'],
|
||
},
|
||
}
|
||
|
||
// ─── TYPES ───────────────────────────────────────────────────────────────────
|
||
export type LicenseStatus = 'active' | 'expired' | 'invalid' | 'suspended'
|
||
|
||
export interface LicenseInfo {
|
||
licenseKey: string
|
||
status: LicenseStatus
|
||
product: string
|
||
edition: string
|
||
version: string
|
||
seats: number
|
||
customer: string
|
||
contact: string
|
||
issuedAt: string
|
||
expiresAt: string
|
||
maintenanceUntil: string
|
||
modules: string[]
|
||
}
|
||
|
||
interface LicenseLookupPanelProps {
|
||
/** Called when a valid license is found – so the wizard can prefill fields */
|
||
onLicenseResolved?: (info: LicenseInfo) => void
|
||
}
|
||
|
||
// ─── HELPERS ─────────────────────────────────────────────────────────────────
|
||
function statusConfig(status: LicenseStatus) {
|
||
switch (status) {
|
||
case 'active':
|
||
return {
|
||
label: 'Aktiv',
|
||
icon: <CheckCircle2 className="w-3.5 h-3.5" />,
|
||
cls: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
|
||
ring: 'border-emerald-500/30',
|
||
glow: 'shadow-emerald-500/10',
|
||
}
|
||
case 'expired':
|
||
return {
|
||
label: 'Abgelaufen',
|
||
icon: <AlertTriangle className="w-3.5 h-3.5" />,
|
||
cls: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
|
||
ring: 'border-amber-500/30',
|
||
glow: 'shadow-amber-500/10',
|
||
}
|
||
case 'suspended':
|
||
return {
|
||
label: 'Gesperrt',
|
||
icon: <XCircle className="w-3.5 h-3.5" />,
|
||
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
|
||
ring: 'border-red-500/30',
|
||
glow: 'shadow-red-500/10',
|
||
}
|
||
default:
|
||
return {
|
||
label: 'Ungültig',
|
||
icon: <XCircle className="w-3.5 h-3.5" />,
|
||
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
|
||
ring: 'border-red-500/30',
|
||
glow: 'shadow-red-500/10',
|
||
}
|
||
}
|
||
}
|
||
|
||
function fmt(dateStr: string) {
|
||
if (!dateStr) return '—'
|
||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||
})
|
||
}
|
||
|
||
// ─── LOOKUP (tries real LicServer API call, falls back to MOCK in dev) ──────────────────────
|
||
async function lookupLicense(key: string): Promise<LicenseInfo | null> {
|
||
try {
|
||
return await lookupLicenseFromLicServer(key)
|
||
} catch (err) {
|
||
console.error('Lookup failed, falling back to mock details:', err)
|
||
return MOCK_LICENSES[key.trim().toUpperCase()] ?? null
|
||
}
|
||
}
|
||
|
||
// ─── MAIN COMPONENT ───────────────────────────────────────────────────────────
|
||
export function LicenseLookupPanel({ onLicenseResolved }: LicenseLookupPanelProps) {
|
||
const [licenseKey, setLicenseKey] = useState('')
|
||
const [result, setResult] = useState<LicenseInfo | null>(null)
|
||
const [notFound, setNotFound] = useState(false)
|
||
const [isPending, startTransition] = useTransition()
|
||
const [expanded, setExpanded] = useState(true)
|
||
const [modulesOpen, setModulesOpen] = useState(false)
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const handleSearch = () => {
|
||
if (!licenseKey.trim()) return
|
||
setResult(null)
|
||
setNotFound(false)
|
||
startTransition(async () => {
|
||
const info = await lookupLicense(licenseKey)
|
||
if (info) {
|
||
setResult(info)
|
||
onLicenseResolved?.(info)
|
||
} else {
|
||
setNotFound(true)
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === 'Enter') handleSearch()
|
||
}
|
||
|
||
const handleClear = () => {
|
||
setLicenseKey('')
|
||
setResult(null)
|
||
setNotFound(false)
|
||
inputRef.current?.focus()
|
||
}
|
||
|
||
const sc = result ? statusConfig(result.status) : null
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-white/10 bg-slate-900/60 backdrop-blur-md overflow-hidden shadow-xl">
|
||
|
||
{/* ── Panel Header (toggle) ─────────────────────────────────────────── */}
|
||
<button
|
||
id="license-lookup-toggle"
|
||
onClick={() => setExpanded(v => !v)}
|
||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-white/5 transition-colors"
|
||
>
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center">
|
||
<KeyRound className="w-4 h-4 text-violet-400" />
|
||
</div>
|
||
<div className="text-left">
|
||
<p className="text-sm font-semibold text-white">Alte Lizenznummer eingeben</p>
|
||
<p className="text-[11px] text-slate-400">Upgrade / Migration</p>
|
||
</div>
|
||
</div>
|
||
{expanded
|
||
? <ChevronUp className="w-4 h-4 text-slate-400" />
|
||
: <ChevronDown className="w-4 h-4 text-slate-400" />}
|
||
</button>
|
||
|
||
{/* ── Collapsible Body ──────────────────────────────────────────────── */}
|
||
<AnimatePresence initial={false}>
|
||
{expanded && (
|
||
<motion.div
|
||
key="body"
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
||
className="overflow-hidden"
|
||
>
|
||
<div className="px-5 pb-5 space-y-4 border-t border-white/5">
|
||
|
||
{/* ── Input + Search Button ──────────────────────────────── */}
|
||
<div className="pt-4">
|
||
<p className="text-xs text-slate-400 mb-2 leading-relaxed">
|
||
Geben Sie Ihre bisherige CAS-Lizenznummer ein,
|
||
um Lizenzinformationen automatisch abzurufen.
|
||
</p>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* Input */}
|
||
<div className="relative flex-1">
|
||
<Fingerprint className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-violet-400 pointer-events-none" />
|
||
<Input
|
||
ref={inputRef}
|
||
id="license-lookup-input"
|
||
value={licenseKey}
|
||
onChange={e => {
|
||
setLicenseKey(e.target.value)
|
||
setResult(null)
|
||
setNotFound(false)
|
||
}}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="z. B. CAS-2023-PRO-00123"
|
||
className="pl-9 pr-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500
|
||
focus:border-violet-500/60 focus:ring-violet-500/20 rounded-xl text-sm h-10"
|
||
/>
|
||
{licenseKey && (
|
||
<button
|
||
onClick={handleClear}
|
||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors"
|
||
aria-label="Eingabe löschen"
|
||
>
|
||
<XCircle className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Search Button */}
|
||
<button
|
||
id="license-lookup-search-btn"
|
||
onClick={handleSearch}
|
||
disabled={isPending || !licenseKey.trim()}
|
||
className="flex items-center gap-1.5 px-3.5 h-10 rounded-xl bg-violet-600 hover:bg-violet-500
|
||
disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium
|
||
transition-all shadow-lg shadow-violet-500/20 shrink-0"
|
||
>
|
||
{isPending
|
||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||
: <Search className="w-4 h-4" />}
|
||
Prüfen
|
||
</button>
|
||
</div>
|
||
|
||
{/* Demo quick-fill hints */}
|
||
<p className="text-[10px] text-slate-500 mt-1.5">
|
||
Demo:
|
||
<span
|
||
className="font-mono text-violet-400/80 cursor-pointer hover:text-violet-400 transition-colors"
|
||
onClick={() => setLicenseKey('CAS-2023-PRO-00123')}
|
||
>
|
||
CAS-2023-PRO-00123
|
||
</span>
|
||
{' · '}
|
||
<span
|
||
className="font-mono text-amber-400/80 cursor-pointer hover:text-amber-400 transition-colors"
|
||
onClick={() => setLicenseKey('CAS-2020-STD-00456')}
|
||
>
|
||
CAS-2020-STD-00456
|
||
</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* ── Animated Result Area ──────────────────────────────── */}
|
||
<AnimatePresence mode="wait">
|
||
|
||
{/* Loading skeleton */}
|
||
{isPending && (
|
||
<motion.div
|
||
key="loading"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="space-y-2"
|
||
>
|
||
{[1, 2, 3].map(i => (
|
||
<div key={i} className="h-7 rounded-lg bg-white/5 animate-pulse" />
|
||
))}
|
||
<p className="text-center text-[11px] text-slate-500 animate-pulse">
|
||
Lizenzserver wird abgefragt…
|
||
</p>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* Not Found */}
|
||
{notFound && !isPending && (
|
||
<motion.div
|
||
key="notfound"
|
||
initial={{ opacity: 0, y: 6 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0 }}
|
||
className="flex items-start gap-2.5 p-3 rounded-xl bg-red-500/10 border border-red-500/30"
|
||
>
|
||
<XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
|
||
<div>
|
||
<p className="text-sm font-medium text-red-300">Lizenz nicht gefunden</p>
|
||
<p className="text-xs text-red-400/70 mt-0.5">
|
||
Die eingegebene Nummer wurde im Lizenzsystem nicht gefunden.
|
||
Bitte prüfen Sie die Eingabe.
|
||
</p>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* Result Card */}
|
||
{result && !isPending && sc && (
|
||
<motion.div
|
||
key="result"
|
||
initial={{ opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0 }}
|
||
className={`rounded-xl border ${sc.ring} bg-slate-900/80 shadow-lg ${sc.glow} overflow-hidden`}
|
||
>
|
||
{/* Status Bar */}
|
||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-white/5">
|
||
<div className="flex items-center gap-2">
|
||
<ShieldCheck className="w-4 h-4 text-slate-300" />
|
||
<span className="text-xs font-mono text-slate-300 truncate max-w-[140px]">
|
||
{result.licenseKey}
|
||
</span>
|
||
</div>
|
||
<Badge className={`text-[10px] px-2 py-0.5 flex items-center gap-1 border ${sc.cls}`}>
|
||
{sc.icon}
|
||
{sc.label}
|
||
</Badge>
|
||
</div>
|
||
|
||
{/* Info Rows */}
|
||
<div className="px-4 py-3 space-y-2.5">
|
||
|
||
{/* Product */}
|
||
<div className="flex items-start gap-2">
|
||
<Package className="w-3.5 h-3.5 text-violet-400 mt-0.5 shrink-0" />
|
||
<div>
|
||
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Produkt</p>
|
||
<p className="text-xs font-semibold text-white leading-tight">{result.product}</p>
|
||
<p className="text-[10px] text-slate-400">
|
||
{result.edition} · v{result.version} · {result.seats} Arbeitsplätze
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Customer */}
|
||
<div className="flex items-start gap-2">
|
||
<Building2 className="w-3.5 h-3.5 text-blue-400 mt-0.5 shrink-0" />
|
||
<div>
|
||
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Lizenznehmer</p>
|
||
<p className="text-xs font-semibold text-white leading-tight">{result.customer}</p>
|
||
<p className="text-[10px] text-slate-400 flex items-center gap-1">
|
||
<User className="w-2.5 h-2.5" />
|
||
{result.contact}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Date Grid */}
|
||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||
<div className="rounded-lg bg-white/5 px-2.5 py-2">
|
||
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||
<CalendarDays className="w-2.5 h-2.5" /> Ausgestellt
|
||
</p>
|
||
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.issuedAt)}</p>
|
||
</div>
|
||
<div className="rounded-lg bg-white/5 px-2.5 py-2">
|
||
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||
<CalendarDays className="w-2.5 h-2.5" /> Läuft ab
|
||
</p>
|
||
<p className={`text-xs font-medium mt-0.5 ${result.status === 'expired' ? 'text-amber-400' : 'text-white'}`}>
|
||
{fmt(result.expiresAt)}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-lg bg-white/5 px-2.5 py-2 col-span-2">
|
||
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||
<Hash className="w-2.5 h-2.5" /> Wartung bis
|
||
</p>
|
||
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.maintenanceUntil)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modules (collapsible) */}
|
||
{result.modules.length > 0 && (
|
||
<div>
|
||
<button
|
||
onClick={() => setModulesOpen(v => !v)}
|
||
className="w-full flex items-center justify-between text-[10px] text-slate-400
|
||
hover:text-white transition-colors pt-1"
|
||
>
|
||
<span className="uppercase tracking-wide">
|
||
Lizenzierte Module ({result.modules.length})
|
||
</span>
|
||
{modulesOpen
|
||
? <ChevronUp className="w-3 h-3" />
|
||
: <ChevronDown className="w-3 h-3" />}
|
||
</button>
|
||
<AnimatePresence>
|
||
{modulesOpen && (
|
||
<motion.div
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
className="overflow-hidden"
|
||
>
|
||
<div className="flex flex-wrap gap-1 pt-2">
|
||
{result.modules.map(m => (
|
||
<span
|
||
key={m}
|
||
className="text-[10px] px-2 py-0.5 rounded-full bg-violet-500/15
|
||
border border-violet-500/30 text-violet-300"
|
||
>
|
||
{m}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Bottom hint */}
|
||
{result.status === 'active' && (
|
||
<div className="px-4 pb-3">
|
||
<p className="text-[10px] text-emerald-400/70 flex items-center gap-1">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
Lizenz erkannt — Daten können für die Bestellung übernommen werden.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{result.status === 'expired' && (
|
||
<div className="px-4 pb-3">
|
||
<p className="text-[10px] text-amber-400/70 flex items-center gap-1">
|
||
<AlertTriangle className="w-3 h-3" />
|
||
Lizenz abgelaufen — Upgrade empfohlen.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
)
|
||
}
|