mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
219 lines
12 KiB
TypeScript
219 lines
12 KiB
TypeScript
"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>
|
||
)
|
||
}
|