feat(wizard): add license lookup panel (UI mockup)
All checks were successful
Staging Build / build (push) Successful in 2m55s

Add collapsible LicenseLookupPanel component to step 3
of the order wizard (left column). Panel queries mock
LicServer data by license key and displays product,
customer, dates, status badge and licensed modules.
Real API endpoint can replace mock in lookupLicense().

- New: components/wizard/license-lookup-panel.tsx
- Modified: components/order-wizard.tsx (4-col grid)
This commit is contained in:
DanielS
2026-07-14 12:57:46 +02:00
parent 0b4755d438
commit 72b906779c
2 changed files with 487 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ import { StepBilling } from './wizard/step-billing'
import { StepSoftware } from './wizard/step-software' import { StepSoftware } from './wizard/step-software'
import { StepSummary } from './wizard/step-summary' import { StepSummary } from './wizard/step-summary'
import { SummarySidebar } from './wizard/summary-sidebar' import { SummarySidebar } from './wizard/summary-sidebar'
import { LicenseLookupPanel } from './wizard/license-lookup-panel'
type CategorySelection = { type CategorySelection = {
productId: string | null // selected product in this category productId: string | null // selected product in this category
@@ -734,10 +735,16 @@ export function OrderWizard({
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, x: -20 }}
> >
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{/* LEFT: Sticky header (stepper) + scrollable categories */}
{/* FAR LEFT: License Lookup Panel (sticky) */}
<div className="lg:col-span-1 sticky top-6">
<LicenseLookupPanel />
</div>
{/* CENTER: Sticky stepper + scrollable categories */}
<div className="lg:col-span-2 space-y-0"> <div className="lg:col-span-2 space-y-0">
{/* Sticky stepper header — only covers left column */} {/* Sticky stepper header — only covers center column */}
<div className="sticky top-0 z-20 bg-slate-950/80 backdrop-blur-md pb-4 pt-6 border-b border-white/5 mb-6"> <div className="sticky top-0 z-20 bg-slate-950/80 backdrop-blur-md pb-4 pt-6 border-b border-white/5 mb-6">
<p className="text-center text-slate-400 text-sm mb-4">Schritt 3 von 4 Software konfigurieren</p> <p className="text-center text-slate-400 text-sm mb-4">Schritt 3 von 4 Software konfigurieren</p>
<ProgressStepper step={step} basketItemsCount={basketItems.length} /> <ProgressStepper step={step} basketItemsCount={basketItems.length} />
@@ -760,7 +767,7 @@ export function OrderWizard({
/> />
</div> </div>
{/* RIGHT: Independent full-height sidebar column */} {/* RIGHT: Summary sidebar (sticky) */}
<div className="lg:col-span-1 sticky top-6"> <div className="lg:col-span-1 sticky top-6">
<SummarySidebar <SummarySidebar
visibleCategories={visibleCategories} visibleCategories={visibleCategories}

View File

@@ -0,0 +1,476 @@
'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'
// ─── 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',
})
}
// ─── MOCK LOOKUP (replace with real LicServer API call) ──────────────────────
async function lookupLicense(key: string): Promise<LicenseInfo | null> {
// TODO: replace with → fetch(`/api/license-lookup?key=${encodeURIComponent(key)}`)
await new Promise(r => setTimeout(r, 800)) // simulate network latency
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:&nbsp;
<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>
)
}