mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
284 lines
15 KiB
TypeScript
284 lines
15 KiB
TypeScript
"use client"
|
||
|
||
import { AnimatePresence, motion } from "motion/react"
|
||
import { useEffect, useState } from "react"
|
||
import { CheckIcon, CopyIcon, FileTextIcon, LoaderCircleIcon, MailIcon, RotateCcwIcon } from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
import type { DemoInfo } from "../demo-info"
|
||
import type { AiAccess } from "../demos"
|
||
import type { Quote } from "../schemas"
|
||
import { readNdjson } from "./use-ai-chat"
|
||
|
||
type Mode = "email" | "quote"
|
||
type QuoteView = Quote & { number: string; date: Date }
|
||
|
||
const steps: Record<Mode, string[]> = {
|
||
email: ["Anfrage verstehen", "Passende Infos heraussuchen", "Antwort formulieren"],
|
||
quote: ["Anfrage analysieren", "Positionen ableiten", "Preise kalkulieren", "Angebot gestalten"],
|
||
}
|
||
|
||
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
|
||
const day = new Intl.DateTimeFormat("de-DE", { dateStyle: "long" })
|
||
|
||
function quoteNumber(text: string, year: number) {
|
||
let hash = 0
|
||
for (const char of text) hash = (hash * 31 + char.charCodeAt(0)) | 0
|
||
return `A-${year}-${(Math.abs(hash) % 9000) + 1000}`
|
||
}
|
||
|
||
export function AssistantDemo({ access, info }: { access: AiAccess; info: DemoInfo }) {
|
||
const [inquiry, setInquiry] = useState(info.samples.inquiry)
|
||
const [mode, setMode] = useState<Mode>("email")
|
||
const [email, setEmail] = useState("")
|
||
const [quote, setQuote] = useState<QuoteView | null>(null)
|
||
const [pending, setPending] = useState(false)
|
||
const [step, setStep] = useState(0)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [copied, setCopied] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!pending) return
|
||
const timer = setInterval(() => setStep((s) => s + 1), 1600)
|
||
return () => clearInterval(timer)
|
||
}, [pending])
|
||
|
||
async function run(next: Mode) {
|
||
setMode(next)
|
||
setPending(true)
|
||
setStep(0)
|
||
setError(null)
|
||
try {
|
||
if (next === "email") {
|
||
setEmail("")
|
||
const response = await fetch("/api/ai/generate", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ task: "email", access, inquiry }),
|
||
})
|
||
await readNdjson(response, (event) => {
|
||
if (event.type === "error") throw new Error(event.message)
|
||
if (event.type === "text") setEmail((text) => text + event.text)
|
||
})
|
||
} else {
|
||
setQuote(null)
|
||
const response = await fetch("/api/ai/generate", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ task: "quote", access, inquiry }),
|
||
})
|
||
const data = (await response.json().catch(() => null)) as { result?: Quote; error?: string } | null
|
||
if (!response.ok || !data?.result) throw new Error(data?.error ?? "Die KI ist gerade nicht erreichbar.")
|
||
const today = new Date()
|
||
setQuote({ ...data.result, number: quoteNumber(inquiry, today.getFullYear()), date: today })
|
||
}
|
||
} catch (cause) {
|
||
setError(cause instanceof Error ? cause.message : "Die KI ist gerade nicht erreichbar.")
|
||
} finally {
|
||
setPending(false)
|
||
}
|
||
}
|
||
|
||
async function copy(text: string) {
|
||
await navigator.clipboard.writeText(text).catch(() => undefined)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 1500)
|
||
}
|
||
|
||
const subjectLine = email.match(/^Betreff:\s*(.*)$/m)
|
||
const subject = subjectLine?.[1]?.trim() ?? ""
|
||
const body = subject ? email.replace(/^Betreff:.*\n?/m, "").replace(/^\n+/, "") : email
|
||
const showLoader = pending && (mode === "quote" || !email)
|
||
|
||
return (
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
||
<section className="flex flex-col rounded-[2rem] border border-white/10 bg-white/[0.03] p-6">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="font-medium">Kundenanfrage</h2>
|
||
<button
|
||
type="button"
|
||
onClick={() => setInquiry(info.samples.inquiry)}
|
||
className="flex cursor-pointer items-center gap-1.5 text-xs text-zinc-400 hover:text-white"
|
||
>
|
||
<RotateCcwIcon className="size-3.5" /> Beispiel
|
||
</button>
|
||
</div>
|
||
<p className="mt-1 text-sm text-zinc-500">Eigene Anfrage einfügen oder das Beispiel nutzen.</p>
|
||
<textarea
|
||
value={inquiry}
|
||
onChange={(event) => setInquiry(event.target.value)}
|
||
rows={10}
|
||
aria-label="Kundenanfrage"
|
||
className="mt-4 w-full flex-1 resize-none rounded-2xl border border-white/10 bg-black/30 p-4 text-sm leading-relaxed outline-none focus:border-t-accent"
|
||
/>
|
||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={pending || inquiry.trim().length < 10}
|
||
onClick={() => run("email")}
|
||
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-t-accent px-4 py-3 text-sm font-medium text-t-accent-fg transition-opacity disabled:opacity-40"
|
||
>
|
||
<MailIcon className="size-4" /> Antwort schreiben
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={pending || inquiry.trim().length < 10}
|
||
onClick={() => run("quote")}
|
||
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-white/15 px-4 py-3 text-sm font-medium transition-colors hover:border-white/40 disabled:opacity-40"
|
||
>
|
||
<FileTextIcon className="size-4" /> Angebot erstellen
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="min-h-[34rem] rounded-[2rem] border border-white/10 bg-white/[0.03] p-6">
|
||
{error && <p className="mb-4 rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</p>}
|
||
|
||
{showLoader ? (
|
||
<ol className="mx-auto mt-16 max-w-xs space-y-3">
|
||
{steps[mode].map((label, i) => {
|
||
const current = Math.min(step, steps[mode].length - 1)
|
||
return (
|
||
<li key={label} className={cn("flex items-center gap-3 text-sm transition-colors", i <= current ? "text-zinc-100" : "text-zinc-600")}>
|
||
{i < current ? (
|
||
<CheckIcon className="size-4 text-t-accent" />
|
||
) : i === current ? (
|
||
<LoaderCircleIcon className="size-4 animate-spin text-t-accent" />
|
||
) : (
|
||
<span className="size-4 rounded-full border border-zinc-700" />
|
||
)}
|
||
{label}
|
||
</li>
|
||
)
|
||
})}
|
||
</ol>
|
||
) : mode === "email" && email ? (
|
||
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="overflow-hidden rounded-2xl bg-white text-zinc-900">
|
||
<header className="space-y-1 border-b border-zinc-200 px-6 py-4 text-sm">
|
||
<p><span className="text-zinc-500">Von:</span> Team {info.brand}</p>
|
||
<p><span className="text-zinc-500">Betreff:</span> <span className="font-medium">{subject || "…"}</span></p>
|
||
</header>
|
||
<div className="whitespace-pre-wrap px-6 py-5 text-[15px] leading-relaxed">
|
||
{body}
|
||
{pending && <span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-zinc-900 align-middle" />}
|
||
</div>
|
||
{!pending && (
|
||
<footer className="flex justify-end border-t border-zinc-200 px-6 py-3">
|
||
<CopyButton copied={copied} onClick={() => copy(email)} />
|
||
</footer>
|
||
)}
|
||
</motion.article>
|
||
) : mode === "quote" && quote ? (
|
||
<QuoteDocument quote={quote} brand={info.brand} copied={copied} onCopy={copy} />
|
||
) : (
|
||
<div className="grid h-full min-h-[28rem] place-items-center text-center text-sm text-zinc-500">
|
||
<p>Wählen Sie links „Antwort schreiben“ oder „Angebot erstellen“.<br />Das Ergebnis erscheint hier – in wenigen Sekunden.</p>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CopyButton({ copied, onClick }: { copied: boolean; onClick: () => void }) {
|
||
return (
|
||
<button type="button" onClick={onClick} className="flex cursor-pointer items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-zinc-600 hover:bg-zinc-100">
|
||
{copied ? <CheckIcon className="size-4 text-emerald-600" /> : <CopyIcon className="size-4" />}
|
||
{copied ? "Kopiert" : "Kopieren"}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function QuoteDocument({ quote, brand, copied, onCopy }: { quote: QuoteView; brand: string; copied: boolean; onCopy: (text: string) => void }) {
|
||
const net = quote.positionen.reduce((sum, item) => sum + item.menge * item.einzelpreis, 0)
|
||
const vat = net * 0.19
|
||
const validUntil = new Date(quote.date.getTime() + Math.max(1, quote.gueltig_tage) * 86_400_000)
|
||
const plain = [
|
||
`Angebot ${quote.number} – ${quote.betreff}`,
|
||
"",
|
||
quote.anrede,
|
||
quote.einleitung,
|
||
"",
|
||
...quote.positionen.map((item, i) => `${i + 1}. ${item.titel} – ${item.menge} ${item.einheit} × ${euro.format(item.einzelpreis)} = ${euro.format(item.menge * item.einzelpreis)}`),
|
||
"",
|
||
`Netto: ${euro.format(net)} · MwSt. 19 %: ${euro.format(vat)} · Gesamt: ${euro.format(net + vat)}`,
|
||
...quote.hinweise.map((note) => `– ${note}`),
|
||
"",
|
||
quote.abschluss,
|
||
].join("\n")
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<AnimatePresence>
|
||
{quote.rueckfragen.length > 0 && (
|
||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="rounded-2xl border border-amber-400/30 bg-amber-400/10 p-4 text-sm text-amber-100">
|
||
<p className="font-medium">Vor dem Versand klären (nur intern)</p>
|
||
<ul className="mt-2 list-disc space-y-1 pl-5 text-amber-100/80">
|
||
{quote.rueckfragen.map((question) => (
|
||
<li key={question}>{question}</li>
|
||
))}
|
||
</ul>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="rounded-2xl bg-white p-6 text-zinc-900 md:p-8">
|
||
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-200 pb-6">
|
||
<div>
|
||
<p className="text-xl font-semibold">{brand}</p>
|
||
<p className="text-sm text-zinc-500">Angebot {quote.number}</p>
|
||
</div>
|
||
<p className="text-sm text-zinc-500">{day.format(quote.date)}</p>
|
||
</header>
|
||
<h3 className="mt-6 text-lg font-semibold">{quote.betreff}</h3>
|
||
<p className="mt-4 text-sm">{quote.anrede}</p>
|
||
<p className="mt-2 text-sm leading-relaxed text-zinc-700">{quote.einleitung}</p>
|
||
|
||
<div className="mt-6 overflow-x-auto">
|
||
<table className="w-full min-w-[520px] text-sm">
|
||
<thead>
|
||
<tr className="border-b border-zinc-200 text-left text-xs uppercase tracking-wider text-zinc-500">
|
||
<th className="py-2 pr-2 font-medium">Pos.</th>
|
||
<th className="py-2 pr-2 font-medium">Leistung</th>
|
||
<th className="py-2 pr-2 text-right font-medium">Menge</th>
|
||
<th className="py-2 pr-2 text-right font-medium">Einzelpreis</th>
|
||
<th className="py-2 text-right font-medium">Gesamt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{quote.positionen.map((item, i) => (
|
||
<tr key={i} className="border-b border-zinc-100 align-top">
|
||
<td className="py-3 pr-2 text-zinc-500">{i + 1}</td>
|
||
<td className="py-3 pr-2">
|
||
<p className="font-medium">{item.titel}</p>
|
||
<p className="text-xs text-zinc-500">{item.beschreibung}</p>
|
||
</td>
|
||
<td className="py-3 pr-2 text-right whitespace-nowrap">{item.menge.toLocaleString("de-DE")} {item.einheit}</td>
|
||
<td className="py-3 pr-2 text-right whitespace-nowrap">{euro.format(item.einzelpreis)}</td>
|
||
<td className="py-3 text-right font-medium whitespace-nowrap">{euro.format(item.menge * item.einzelpreis)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<dl className="mt-4 ml-auto w-full max-w-xs space-y-1 text-sm">
|
||
<div className="flex justify-between"><dt className="text-zinc-500">Netto</dt><dd>{euro.format(net)}</dd></div>
|
||
<div className="flex justify-between"><dt className="text-zinc-500">MwSt. 19 %</dt><dd>{euro.format(vat)}</dd></div>
|
||
<div className="flex justify-between border-t border-zinc-200 pt-2 text-base font-semibold"><dt>Gesamt</dt><dd>{euro.format(net + vat)}</dd></div>
|
||
</dl>
|
||
|
||
{quote.hinweise.length > 0 && (
|
||
<ul className="mt-6 space-y-1 text-xs text-zinc-500">
|
||
{quote.hinweise.map((note) => (
|
||
<li key={note}>– {note}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<p className="mt-6 text-sm leading-relaxed text-zinc-700">{quote.abschluss}</p>
|
||
<footer className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 pt-4 text-xs text-zinc-500">
|
||
<span>Gültig bis {day.format(validUntil)}</span>
|
||
<CopyButton copied={copied} onClick={() => onCopy(plain)} />
|
||
</footer>
|
||
</motion.article>
|
||
</div>
|
||
)
|
||
}
|