feat(wizard): add license lookup panel (UI mockup)
All checks were successful
Staging Build / build (push) Successful in 2m55s
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:
@@ -16,6 +16,7 @@ import { StepBilling } from './wizard/step-billing'
|
||||
import { StepSoftware } from './wizard/step-software'
|
||||
import { StepSummary } from './wizard/step-summary'
|
||||
import { SummarySidebar } from './wizard/summary-sidebar'
|
||||
import { LicenseLookupPanel } from './wizard/license-lookup-panel'
|
||||
|
||||
type CategorySelection = {
|
||||
productId: string | null // selected product in this category
|
||||
@@ -734,10 +735,16 @@ export function OrderWizard({
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||
{/* LEFT: Sticky header (stepper) + scrollable categories */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
||||
|
||||
{/* 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">
|
||||
{/* 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">
|
||||
<p className="text-center text-slate-400 text-sm mb-4">Schritt 3 von 4 — Software konfigurieren</p>
|
||||
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
|
||||
@@ -760,7 +767,7 @@ export function OrderWizard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Independent full-height sidebar column */}
|
||||
{/* RIGHT: Summary sidebar (sticky) */}
|
||||
<div className="lg:col-span-1 sticky top-6">
|
||||
<SummarySidebar
|
||||
visibleCategories={visibleCategories}
|
||||
|
||||
476
shop/components/wizard/license-lookup-panel.tsx
Normal file
476
shop/components/wizard/license-lookup-panel.tsx
Normal 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:
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user