Files
webshop/shop/components/wizard/license-lookup-panel.tsx
DanielS c386fa03d4
Some checks failed
Staging Build / build (push) Failing after 43s
style(shop): resolve merge conflict in license lookup placeholder
2026-07-22 15:10:56 +02:00

532 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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> = {
<<<<<<< HEAD
'995500-0005': {
licenseKey: '995500-0005',
status: 'active',
product: 'CASPOS DEMO',
edition: 'Professional',
version: '14.1.2',
=======
'995502-00': {
licenseKey: '995502-00',
status: 'active',
product: 'CASPOS',
edition: 'GASTRO',
version: '4.8.6',
>>>>>>> 086a893e19593863f6b2044374187220cc3e392d
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'],
},
<<<<<<< HEAD
'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'],
=======
'995501-00': {
licenseKey: '995501-00',
status: 'expired',
product: 'CASPOS Handel',
edition: 'Standard',
version: '3.12.1',
seats: 1,
customer: 'Beispiel AG',
contact: 'Erika Muster',
issuedAt: '2016-03-14',
expiresAt: '2017-03-14',
maintenanceUntil: '2017-03-14',
modules: ['CASPOS Handel'],
},
'995500-00': {
licenseKey: '995500-00',
status: 'active',
product: 'CASPOS Handel',
edition: 'Standard',
version: '4.11.2',
seats: 2,
customer: 'Tech Solutions GmbH & Co. KG',
contact: 'Julia Schneider',
issuedAt: '2026-01-01',
expiresAt: '2027-12-31',
maintenanceUntil: '2027-12-31',
modules: ['CASPOS Handel'],
>>>>>>> 086a893e19593863f6b2044374187220cc3e392d
},
}
// ─── 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 CASPOS-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. 995500-00"
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"
<<<<<<< HEAD
onClick={() => setLicenseKey('995500-00')}
>
995500-00
=======
onClick={() => setLicenseKey('995502-00')}
>
995502-00
>>>>>>> 086a893e19593863f6b2044374187220cc3e392d
</span>
{' · '}
<span
className="font-mono text-amber-400/80 cursor-pointer hover:text-amber-400 transition-colors"
<<<<<<< HEAD
onClick={() => setLicenseKey('995500-01')}
>
995500-01
=======
onClick={() => setLicenseKey('995501-00')}
>
995500-00
>>>>>>> 086a893e19593863f6b2044374187220cc3e392d
</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>
)
}