since3-portal/modules/templates/ai/ui/content-demo.tsx
2026-10-05 08:23:02 +02:00

219 lines
12 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 { motion } from "motion/react"
import { useState } from "react"
import { BookmarkIcon, CheckIcon, CopyIcon, GlobeIcon, HeartIcon, MessageCircleIcon, SendIcon, SparklesIcon, ThumbsUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { TImage } from "../../catalog/shared/media"
import type { DemoInfo } from "../demo-info"
import type { AiAccess } from "../demos"
import { CHANNELS, TONES, type Channel, type ContentResult, type Tone } from "../schemas"
type Variant = ContentResult["varianten"][number]
function slug(brand: string) {
return brand.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/[^a-z0-9]/g, "")
}
const tags = (variant: Variant) => variant.hashtags.map((tag) => `#${tag.replace(/^#/, "")}`).join(" ")
export function ContentDemo({ access, info }: { access: AiAccess; info: DemoInfo }) {
const [channel, setChannel] = useState<Channel>("instagram")
const [topic, setTopic] = useState(info.samples.topic)
const [tone, setTone] = useState<Tone>("Locker")
const [result, setResult] = useState<{ channel: Channel; items: Variant[] } | null>(null)
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
async function run() {
setPending(true)
setError(null)
try {
const response = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task: "content", access, topic, channel, tone }),
})
const data = (await response.json().catch(() => null)) as { result?: ContentResult; error?: string } | null
if (!response.ok || !data?.result) throw new Error(data?.error ?? "Die KI ist gerade nicht erreichbar.")
setResult({ channel, items: data.result.varianten.slice(0, 3) })
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Die KI ist gerade nicht erreichbar.")
} finally {
setPending(false)
}
}
const handle = slug(info.brand) || "ihrbetrieb"
const image = (i: number) => info.images[(i * 2 + 1) % Math.max(info.images.length, 1)] ?? ""
return (
<div className="space-y-8">
<section className="rounded-[2rem] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-5 lg:grid-cols-[1fr_auto] lg:items-end">
<label className="block">
<span className="text-sm font-medium">Worum geht es?</span>
<input
value={topic}
onChange={(event) => setTopic(event.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-sm outline-none focus:border-t-accent"
/>
</label>
<button
type="button"
onClick={run}
disabled={pending || topic.trim().length < 3}
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-t-accent px-6 py-3 text-sm font-medium text-t-accent-fg transition-opacity disabled:opacity-40"
>
<SparklesIcon className={cn("size-4", pending && "animate-spin")} />
{pending ? "Wird erstellt …" : "Drei Varianten erstellen"}
</button>
</div>
<div className="mt-5 flex flex-wrap gap-6">
<Segmented label="Kanal" value={channel} options={Object.entries(CHANNELS) as [Channel, string][]} onChange={setChannel} />
<Segmented label="Tonalität" value={tone} options={TONES.map((t) => [t, t] as [Tone, string])} onChange={setTone} />
</div>
</section>
{error && <p className="rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</p>}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{pending
? [0, 1, 2].map((i) => <div key={i} className="h-[30rem] animate-pulse rounded-2xl bg-white/[0.06]" />)
: result?.items.map((variant, i) => (
<motion.div key={`${result.channel}-${i}`} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.1 }}>
<Mockup channel={result.channel} variant={variant} brand={info.brand} handle={handle} image={image(i)} />
</motion.div>
))}
</div>
{!pending && !result && (
<p className="text-center text-sm text-zinc-500">Kanal und Tonalität wählen, Thema anpassen – fertig sind drei Entwürfe als echte Vorschau.</p>
)}
</div>
)
}
function Segmented<T extends string>({ label, value, options, onChange }: { label: string; value: T; options: [T, string][]; onChange: (value: T) => void }) {
return (
<div>
<p className="mb-2 text-xs text-zinc-400">{label}</p>
<div className="flex flex-wrap gap-1 rounded-xl border border-white/10 bg-black/30 p-1">
{options.map(([key, text]) => (
<button
key={key}
type="button"
aria-pressed={key === value}
onClick={() => onChange(key)}
className={cn("cursor-pointer rounded-lg px-3 py-1.5 text-sm transition-colors", key === value ? "bg-white text-zinc-900" : "text-zinc-300 hover:text-white")}
>
{text}
</button>
))}
</div>
</div>
)
}
function Avatar({ brand, ring }: { brand: string; ring?: boolean }) {
return (
<span className={cn("grid size-9 shrink-0 place-items-center rounded-full bg-t-accent text-sm font-semibold text-t-accent-fg", ring && "ring-2 ring-pink-500 ring-offset-2")}>
{brand.charAt(0).toUpperCase()}
</span>
)
}
function Mockup({ channel, variant, brand, handle, image }: { channel: Channel; variant: Variant; brand: string; handle: string; image: string }) {
const [copied, setCopied] = useState(false)
const text = [variant.hook, variant.text, tags(variant)].filter(Boolean).join("\n\n")
async function copy() {
await navigator.clipboard.writeText(channel === "google" ? `${variant.hook}\n${variant.text}` : text).catch(() => undefined)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<article className="flex h-full flex-col overflow-hidden rounded-2xl bg-white text-zinc-900 shadow-xl">
{channel === "google" ? (
<div className="flex-1 p-5">
<p className="text-xs font-semibold">Gesponsert</p>
<div className="mt-2 flex items-center gap-2">
<span className="grid size-7 place-items-center rounded-full bg-zinc-100 text-xs font-semibold">{brand.charAt(0)}</span>
<div className="text-xs leading-tight">
<p>{brand}</p>
<p className="text-zinc-500">www.{handle}.de</p>
</div>
</div>
<p className="mt-3 text-xl leading-snug text-[#1a0dab]">{variant.hook}</p>
<p className="mt-1 text-sm text-zinc-600">{variant.text}</p>
<p className="mt-4 text-xs text-zinc-400">{variant.hook.length}/30 · {variant.text.length}/90 Zeichen</p>
</div>
) : (
<>
<header className="flex items-center gap-3 px-4 py-3">
<Avatar brand={brand} ring={channel === "instagram"} />
<div className="min-w-0 text-sm leading-tight">
<p className="truncate font-semibold">{channel === "instagram" ? handle : brand}</p>
<p className="flex items-center gap-1 text-xs text-zinc-500">
{channel === "linkedin" ? "Unternehmensseite · 2.814 Follower" : channel === "facebook" ? <>Gesponsert · <GlobeIcon className="size-3" /></> : "Gesponsert"}
</p>
</div>
</header>
{channel !== "instagram" && <PostText variant={variant} className="px-4 pb-3" />}
<div className={cn("relative bg-zinc-100", channel === "instagram" ? "aspect-square" : "aspect-[1.91/1]")}>
<TImage src={image} sizes="(min-width: 1280px) 30vw, (min-width: 768px) 45vw, 100vw" />
</div>
{channel === "instagram" ? (
<div className="px-4 py-3">
<div className="flex items-center gap-4">
<HeartIcon className="size-6" />
<MessageCircleIcon className="size-6" />
<SendIcon className="size-6" />
<BookmarkIcon className="ml-auto size-6" />
</div>
<p className="mt-2 text-sm font-semibold">Gefällt 1.284 Mal</p>
<p className="mt-1 text-sm">
<span className="font-semibold">{handle}</span> <PostText variant={variant} inline />
</p>
</div>
) : (
<div className="flex items-center gap-6 border-t border-zinc-100 px-4 py-2.5 text-sm text-zinc-500">
<span className="flex items-center gap-1.5"><ThumbsUpIcon className="size-4" /> Gefällt mir</span>
<span className="flex items-center gap-1.5"><MessageCircleIcon className="size-4" /> Kommentieren</span>
</div>
)}
</>
)}
<footer className="flex items-center justify-between gap-3 border-t border-zinc-100 bg-zinc-50 px-4 py-2.5 text-xs text-zinc-500">
<span className="line-clamp-1">Bildidee: {variant.bildidee}</span>
<button type="button" onClick={copy} className="flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-zinc-700 hover:bg-zinc-200">
{copied ? <CheckIcon className="size-3.5 text-emerald-600" /> : <CopyIcon className="size-3.5" />}
{copied ? "Kopiert" : "Kopieren"}
</button>
</footer>
</article>
)
}
function PostText({ variant, inline, className }: { variant: Variant; inline?: boolean; className?: string }) {
const Tag = inline ? "span" : "div"
return (
<Tag className={cn("whitespace-pre-line text-sm leading-relaxed", className)}>
<span className="font-medium">{variant.hook}</span>
{"\n"}
{variant.text}
{variant.hashtags.length > 0 && (
<>
{"\n"}
<span className="text-[#00376b]">{tags(variant)}</span>
</>
)}
{variant.cta && (
<>
{"\n"}
<span className="font-medium">→ {variant.cta}</span>
</>
)}
</Tag>
)
}