diff --git a/app/(templates)/demo/[token]/[tool]/page.tsx b/app/(templates)/demo/[token]/[tool]/page.tsx new file mode 100644 index 0000000..3f3a0ca --- /dev/null +++ b/app/(templates)/demo/[token]/[tool]/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next" +import { notFound } from "next/navigation" +import { demoInfo } from "@/modules/templates/ai/demo-info" +import { AI_DEMOS, isDemoSlug } from "@/modules/templates/ai/demos" +import { AiDemo } from "@/modules/templates/ai/ui/ai-demo" +import { getSharedVariant } from "@/modules/templates/queries/shared-variant" + +export const metadata: Metadata = { + title: "KI-Demo", + robots: { index: false, follow: false }, +} + +// Öffentliche KI-Demo zum Freigabe-Link; nur Demos, die in der Variante freigeschaltet sind +export default async function SharedAiDemoPage({ params }: PageProps<"/demo/[token]/[tool]">) { + const { token, tool } = await params + const shared = isDemoSlug(tool) ? await getSharedVariant(token) : null + if (!shared || !isDemoSlug(tool) || !shared.content.ai[AI_DEMOS[tool].feature]) notFound() + + return +} diff --git a/app/(templates)/demo/[token]/page.tsx b/app/(templates)/demo/[token]/page.tsx new file mode 100644 index 0000000..3847e91 --- /dev/null +++ b/app/(templates)/demo/[token]/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next" +import { notFound } from "next/navigation" +import { brandName } from "@/modules/templates/lib/content" +import { getSharedVariant } from "@/modules/templates/queries/shared-variant" +import { TemplateRenderer } from "@/modules/templates/ui/components/template-renderer" + +export async function generateMetadata({ params }: PageProps<"/demo/[token]">): Promise { + const { token } = await params + const shared = await getSharedVariant(token) + + return { + title: shared ? (brandName(shared.content) ?? shared.name) : "Entwurf", + robots: { index: false, follow: false }, + } +} + +export default async function SharedTemplatePage({ params }: PageProps<"/demo/[token]">) { + const { token } = await params + const shared = await getSharedVariant(token) + if (!shared) notFound() + + return +} diff --git a/app/(templates)/ki/[id]/[tool]/page.tsx b/app/(templates)/ki/[id]/[tool]/page.tsx new file mode 100644 index 0000000..d3c0abc --- /dev/null +++ b/app/(templates)/ki/[id]/[tool]/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next" +import { notFound, redirect } from "next/navigation" +import { verifySession } from "@/lib/supabase/dal" +import { demoInfo } from "@/modules/templates/ai/demo-info" +import { isDemoSlug } from "@/modules/templates/ai/demos" +import { AiDemo } from "@/modules/templates/ai/ui/ai-demo" +import { resolveContent } from "@/modules/templates/lib/content" +import { getTemplate } from "@/modules/templates/lib/registry" +import { getVariant } from "@/modules/templates/queries/variants" + +export const metadata: Metadata = { + title: "KI-Demo", + robots: { index: false, follow: false }, +} + +// Interne KI-Demo für Admins: alle Demos, ohne Tageslimit, auch ohne Freigabe-Link +export default async function AdminAiDemoPage({ params }: PageProps<"/ki/[id]/[tool]">) { + const session = await verifySession() + if (!session.isAdmin) redirect("/client/dashboard") + + const { id, tool } = await params + if (!isDemoSlug(tool)) notFound() + + const variant = await getVariant(id) + const def = variant ? getTemplate(variant.template_key) : undefined + if (!variant || !def) notFound() + + const content = resolveContent(def, variant.content) + return +} diff --git a/app/(templates)/preview/[key]/page.tsx b/app/(templates)/preview/[key]/page.tsx new file mode 100644 index 0000000..ea82767 --- /dev/null +++ b/app/(templates)/preview/[key]/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next" +import { notFound, redirect } from "next/navigation" +import { verifySession } from "@/lib/supabase/dal" +import { resolveContent } from "@/modules/templates/lib/content" +import { getTemplate } from "@/modules/templates/lib/registry" +import { getVariant } from "@/modules/templates/queries/variants" +import { LivePreview } from "@/modules/templates/ui/components/live-preview" +import { TemplateRenderer } from "@/modules/templates/ui/components/template-renderer" + +export const metadata: Metadata = { + title: "Vorschau", + robots: { index: false, follow: false }, +} + +// Interne Vorschau für Admins: Grundvorlage, gespeicherte Variante (?variant=) oder Live-Modus für den Editor (?live=1) +export default async function TemplatePreviewPage({ params, searchParams }: PageProps<"/preview/[key]">) { + const session = await verifySession() + if (!session.isAdmin) redirect("/client/dashboard") + + const { key } = await params + const { variant: variantId, live } = await searchParams + const def = getTemplate(key) + if (!def) notFound() + + let raw: unknown = {} + if (typeof variantId === "string") { + const variant = await getVariant(variantId) + if (!variant || variant.template_key !== def.key) notFound() + raw = variant.content + } + + const content = resolveContent(def, raw) + // KI-Chat und -Demos brauchen eine gespeicherte Variante als Wissensquelle + const access = typeof variantId === "string" ? { variantId } : null + + return live === "1" + ? + : +} diff --git a/app/admin/templates/[id]/page.tsx b/app/admin/templates/[id]/page.tsx new file mode 100644 index 0000000..1332cdd --- /dev/null +++ b/app/admin/templates/[id]/page.tsx @@ -0,0 +1,34 @@ +import { notFound, redirect } from "next/navigation" +import { verifySession } from "@/lib/supabase/dal" +import { getClients } from "@/modules/admin/queries/clients" +import { resolveContent } from "@/modules/templates/lib/content" +import { shareState } from "@/modules/templates/lib/preview" +import { getTemplate } from "@/modules/templates/lib/registry" +import { getVariant } from "@/modules/templates/queries/variants" +import { VariantEditor } from "@/modules/templates/ui/views/variant-editor" + +export default async function VariantEditorPage({ params }: PageProps<"/admin/templates/[id]">) { + const session = await verifySession() + if (!session.isAdmin) redirect("/client/dashboard") + + const { id } = await params + const [variant, clients] = await Promise.all([getVariant(id), getClients()]) + const def = variant ? getTemplate(variant.template_key) : undefined + if (!variant || !def) notFound() + + return ( + ({ id, name }))} + /> + ) +} diff --git a/app/admin/templates/page.tsx b/app/admin/templates/page.tsx new file mode 100644 index 0000000..6304132 --- /dev/null +++ b/app/admin/templates/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation" +import { verifySession } from "@/lib/supabase/dal" +import TemplatesView from "@/modules/templates/ui/views/templates-view" + +export default async function TemplatesPage() { + const session = await verifySession() + if (!session.isAdmin) redirect("/client/dashboard") + + return +} diff --git a/app/api/ai/chat/route.ts b/app/api/ai/chat/route.ts new file mode 100644 index 0000000..7e70345 --- /dev/null +++ b/app/api/ai/chat/route.ts @@ -0,0 +1,88 @@ +import Anthropic from "@anthropic-ai/sdk" +import { BOOKING_TOOL, chatSystem, nowNote } from "@/modules/templates/ai/prompts" +import { BookingSchema, ChatRequestSchema, type AiEvent } from "@/modules/templates/ai/schemas" +import { AI_MODEL, FALLBACK, aiErrorMessage, getAnthropic, ndjsonResponse, resolveAiAccess } from "@/modules/templates/ai/server" + +export const maxDuration = 60 + +// Chat-Assistent der Webseite und KI-Telefon: streamt Text, erfasst Anfragen über das Tool anfrage_erfassen +export async function POST(request: Request) { + const parsed = ChatRequestSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) return Response.json({ error: "Ungültige Anfrage." }, { status: 400 }) + + const { access, mode, messages } = parsed.data + const first = messages.findIndex((message) => message.role === "user") + if (first < 0 || messages.at(-1)?.role !== "user") return Response.json({ error: "Ungültige Anfrage." }, { status: 400 }) + + const ctx = await resolveAiAccess(access, mode) + if ("error" in ctx) return Response.json({ error: ctx.error }, { status: ctx.status }) + + const system: Anthropic.Beta.BetaTextBlockParam[] = [ + { type: "text", text: chatSystem(ctx, mode), cache_control: { type: "ephemeral" } }, + { type: "text", text: nowNote() }, + ] + const encoder = new TextEncoder() + + const body = new ReadableStream({ + async start(controller) { + const send = (event: AiEvent) => controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)) + try { + const anthropic = getAnthropic() + let conversation: Anthropic.Beta.BetaMessageParam[] = messages.slice(first) + let retried = false + + // Höchstens drei Runden: Antwort, Anfrage erfassen, Bestätigung + for (let round = 0; round < 3; round++) { + const stream = anthropic.beta.messages.stream({ + ...FALLBACK, + model: AI_MODEL, + max_tokens: 4096, + output_config: { effort: "low" }, + system, + tools: [BOOKING_TOOL], + messages: conversation, + }) + stream.on("text", (text) => send({ type: "text", text })) + + let message: Anthropic.Beta.BetaMessage + try { + message = await stream.finalMessage() + } catch (error) { + // Nur unlesbare Tool-Eingaben einmal wiederholen, API-Fehler gehen nach außen + if (error instanceof Anthropic.APIError || retried) throw error + retried = true + continue + } + + if (message.stop_reason === "refusal") { + send({ type: "error", message: "Dabei kann ich leider nicht helfen." }) + break + } + const calls = message.content.filter((block): block is Anthropic.Beta.BetaToolUseBlock => block.type === "tool_use") + if (message.stop_reason !== "tool_use" || calls.length === 0) break + + const results: Anthropic.Beta.BetaToolResultBlockParam[] = calls.map((call) => { + const booking = BookingSchema.safeParse(call.input) + if (booking.success) send({ type: "booking", data: booking.data }) + return { + type: "tool_result", + tool_use_id: call.id, + is_error: !booking.success, + content: booking.success + ? "Anfrage gespeichert und an das Team weitergeleitet (Demo, es wird nichts versendet)." + : "Angaben unvollständig – bitte die fehlenden Daten erfragen.", + } + }) + conversation = [...conversation, { role: "assistant", content: message.content }, { role: "user", content: results }] + } + send({ type: "done" }) + } catch (error) { + console.error("AI chat failed", error) + send({ type: "error", message: aiErrorMessage(error) }) + } + controller.close() + }, + }) + + return ndjsonResponse(body) +} diff --git a/app/api/ai/generate/route.ts b/app/api/ai/generate/route.ts new file mode 100644 index 0000000..56ed814 --- /dev/null +++ b/app/api/ai/generate/route.ts @@ -0,0 +1,68 @@ +import { betaZodOutputFormat } from "@anthropic-ai/sdk/helpers/beta/zod" +import type * as z from "zod" +import { contentSystem, emailSystem, quoteSystem } from "@/modules/templates/ai/prompts" +import { ContentSchema, GenerateRequestSchema, QuoteSchema, type AiEvent } from "@/modules/templates/ai/schemas" +import { AI_MODEL, FALLBACK, aiErrorMessage, getAnthropic, ndjsonResponse, resolveAiAccess } from "@/modules/templates/ai/server" + +export const maxDuration = 90 + +async function structured(schema: T, system: string, prompt: string) { + const response = await getAnthropic().beta.messages.parse({ + ...FALLBACK, + model: AI_MODEL, + max_tokens: 16000, + output_config: { effort: "medium", format: betaZodOutputFormat(schema) }, + system, + messages: [{ role: "user", content: prompt }], + }) + return response.stop_reason === "refusal" ? null : response.parsed_output +} + +// Angebots- und E-Mail-Assistent sowie Content-Generator +export async function POST(request: Request) { + const parsed = GenerateRequestSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) return Response.json({ error: "Bitte die Eingabe prüfen (mindestens ein paar Worte)." }, { status: 400 }) + + const input = parsed.data + const ctx = await resolveAiAccess(input.access, input.task === "content" ? "content" : "assistant") + if ("error" in ctx) return Response.json({ error: ctx.error }, { status: ctx.status }) + + if (input.task === "email") { + const encoder = new TextEncoder() + const body = new ReadableStream({ + async start(controller) { + const send = (event: AiEvent) => controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)) + try { + const stream = getAnthropic().beta.messages.stream({ + ...FALLBACK, + model: AI_MODEL, + max_tokens: 8000, + output_config: { effort: "medium" }, + system: emailSystem(ctx), + messages: [{ role: "user", content: `\n${input.inquiry}\n` }], + }) + stream.on("text", (text) => send({ type: "text", text })) + const message = await stream.finalMessage() + send(message.stop_reason === "refusal" ? { type: "error", message: "Dazu kann ich keine Antwort schreiben." } : { type: "done" }) + } catch (error) { + console.error("AI email failed", error) + send({ type: "error", message: aiErrorMessage(error) }) + } + controller.close() + }, + }) + return ndjsonResponse(body) + } + + try { + const result = + input.task === "quote" + ? await structured(QuoteSchema, quoteSystem(ctx), `\n${input.inquiry}\n`) + : await structured(ContentSchema, contentSystem(ctx, input.channel, input.tone), `Thema: ${input.topic}`) + if (!result) return Response.json({ error: "Dazu konnte die KI nichts erstellen. Bitte die Eingabe umformulieren." }, { status: 422 }) + return Response.json({ result }) + } catch (error) { + console.error("AI generate failed", error) + return Response.json({ error: aiErrorMessage(error) }, { status: 502 }) + } +} diff --git a/app/globals.css b/app/globals.css index ccb8a67..c4785d4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -46,6 +46,15 @@ --font-sans: var(--font-inter); --font-mono: var(--font-jetbrains-mono); --font-heading: var(--font-sans); + /* Vorlagen (modules/templates): Werte setzt jede Variante selbst über ihre Palette */ + --color-t-bg: var(--t-bg); + --color-t-fg: var(--t-fg); + --color-t-muted: var(--t-muted); + --color-t-surface: var(--t-surface); + --color-t-accent: var(--t-accent); + --color-t-accent-fg: var(--t-accent-fg); + --font-t-display: var(--t-display); + --font-t-body: var(--t-body); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); diff --git a/lib/supabase/migrations/20261005100000_template_variants.sql b/lib/supabase/migrations/20261005100000_template_variants.sql new file mode 100644 index 0000000..7a51a54 --- /dev/null +++ b/lib/supabase/migrations/20261005100000_template_variants.sql @@ -0,0 +1,39 @@ +-- Vorlagen: Demo-Webseiten, die wir Kunden zeigen. Layout und Design stecken im Code +-- (modules/templates), hier liegen nur die angepassten Inhalte einer Variante. +create table public.template_variants ( + id uuid primary key default gen_random_uuid(), + template_key text not null, + name text not null, + client_id uuid references public.profiles(id) on delete set null, + content jsonb not null default '{}'::jsonb, + -- Öffentlicher Link /demo/; null = nicht geteilt + share_token text unique, + share_expires_at timestamptz, + created_by uuid references public.profiles(id) on delete set null default auth.uid(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index template_variants_updated_idx on public.template_variants (updated_at desc); +create index template_variants_client_idx on public.template_variants (client_id); + +create trigger set_updated_at before update on public.template_variants + for each row execute function public.set_updated_at(); + +alter table public.template_variants enable row level security; + +create policy admin_all on public.template_variants for all to authenticated + using ((select public.is_admin())) with check ((select public.is_admin())); + +-- Kunden ohne Login sehen genau eine Variante über ihren Token, die Tabelle bleibt für anon zu. +create or replace function public.get_shared_template_variant(p_token text) +returns table (template_key text, name text, content jsonb) +language sql stable security definer set search_path = '' as $$ + select v.template_key, v.name, v.content + from public.template_variants v + where v.share_token = p_token + and (v.share_expires_at is null or v.share_expires_at > now()); +$$; + +revoke all on function public.get_shared_template_variant(text) from public; +grant execute on function public.get_shared_template_variant(text) to anon, authenticated; diff --git a/lib/supabase/migrations/20261006100000_ai_usage.sql b/lib/supabase/migrations/20261006100000_ai_usage.sql new file mode 100644 index 0000000..703ca2c --- /dev/null +++ b/lib/supabase/migrations/20261006100000_ai_usage.sql @@ -0,0 +1,24 @@ +-- KI-Demos der Vorlagen: Zähler pro Variante und Tag als Kostenbremse für öffentliche Links +create table public.ai_usage ( + variant_id uuid not null references public.template_variants(id) on delete cascade, + day date not null default current_date, + requests integer not null default 0, + primary key (variant_id, day) +); + +alter table public.ai_usage enable row level security; + +create policy admin_select on public.ai_usage for select to authenticated + using ((select public.is_admin())); + +-- Zählt eine Anfrage und meldet, ob sie noch im Tageslimit liegt. Nur der Server (service_role) ruft das auf. +create or replace function public.consume_ai_quota(p_variant_id uuid, p_limit integer) +returns boolean language sql volatile security definer set search_path = '' as $$ + insert into public.ai_usage as u (variant_id, day, requests) + values (p_variant_id, current_date, 1) + on conflict (variant_id, day) do update set requests = u.requests + 1 + returning requests <= p_limit; +$$; + +revoke all on function public.consume_ai_quota(uuid, integer) from public, anon, authenticated; +grant execute on function public.consume_ai_quota(uuid, integer) to service_role; diff --git a/modules/shared/data/sidebar-menu-content.ts b/modules/shared/data/sidebar-menu-content.ts index 8651ddf..d6dcd75 100644 --- a/modules/shared/data/sidebar-menu-content.ts +++ b/modules/shared/data/sidebar-menu-content.ts @@ -4,6 +4,7 @@ import { InfoIcon, KeyIcon, LayoutDashboardIcon, + LayoutTemplateIcon, LucideIcon, SettingsIcon, UserRoundIcon, @@ -28,6 +29,11 @@ export const adminMenu: MenuItem[] = [ url: "/admin/clients", icon: UsersRoundIcon }, + { + title: "Vorlagen", + url: "/admin/templates", + icon: LayoutTemplateIcon + }, { title: "Settings", url: "/admin/settings", diff --git a/modules/templates/actions/variants.ts b/modules/templates/actions/variants.ts new file mode 100644 index 0000000..9348f9f --- /dev/null +++ b/modules/templates/actions/variants.ts @@ -0,0 +1,151 @@ +"use server" + +import { randomBytes } from "node:crypto" +import * as z from "zod" +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" +import { createClient } from "@/lib/supabase/server" +import { verifySession } from "@/lib/supabase/dal" +import { resolveContent } from "../lib/content" +import { SHARE_EXPIRY_OPTIONS } from "../lib/preview" +import { getTemplate } from "../lib/registry" + +export type VariantActionResult = { error: string } | { success: true } +export type ShareResult = { error: string } | { token: string | null; expiresAt: string | null } + +const IdSchema = z.string().uuid() + +const SaveSchema = z.object({ + name: z.string().trim().min(1, { error: "Bitte einen Namen für die Variante eingeben." }).max(120), + clientId: z.string().uuid().nullable(), + content: z.unknown(), +}) + +const ShareSchema = z.object({ + enabled: z.boolean(), + expiresInDays: z.number().refine((days) => SHARE_EXPIRY_OPTIONS.some((option) => option.days === days)), + regenerate: z.boolean().optional(), +}) + +async function requireAdmin() { + const session = await verifySession() + return session.isAdmin ? session : null +} + +function firstIssue(error: z.ZodError) { + return error.issues[0]?.message ?? "Ungültige Eingabe." +} + +// Nicht erratbar, URL-tauglich: 24 Byte ergeben 32 Zeichen +function newShareToken() { + return randomBytes(24).toString("base64url") +} + +function revalidateTemplates() { + revalidatePath("/admin/templates", "layout") +} + +export async function createVariant(templateKey: string): Promise<{ error: string } | undefined> { + if (!(await requireAdmin())) return { error: "Keine Berechtigung." } + + const def = getTemplate(templateKey) + if (!def) return { error: "Vorlage nicht gefunden." } + + const supabase = await createClient() + const { data, error } = await supabase + .from("template_variants") + .insert({ template_key: def.key, name: `${def.name} – Entwurf`, content: resolveContent(def, {}) }) + .select("id") + .single() + if (error || !data) return { error: "Variante konnte nicht angelegt werden." } + + revalidateTemplates() + redirect(`/admin/templates/${data.id}`) +} + +export async function saveVariant(id: string, input: z.input): Promise { + if (!(await requireAdmin())) return { error: "Keine Berechtigung." } + if (!IdSchema.safeParse(id).success) return { error: "Ungültige Variante." } + + const parsed = SaveSchema.safeParse(input) + if (!parsed.success) return { error: firstIssue(parsed.error) } + + const supabase = await createClient() + const { data: current } = await supabase.from("template_variants").select("template_key").eq("id", id).maybeSingle() + const def = current ? getTemplate(current.template_key) : undefined + if (!def) return { error: "Variante nicht gefunden." } + + const { error } = await supabase + .from("template_variants") + .update({ + name: parsed.data.name, + client_id: parsed.data.clientId, + content: resolveContent(def, parsed.data.content), + }) + .eq("id", id) + if (error) return { error: "Änderungen konnten nicht gespeichert werden." } + + revalidateTemplates() + return { success: true } +} + +export async function updateShare(id: string, input: z.input): Promise { + if (!(await requireAdmin())) return { error: "Keine Berechtigung." } + if (!IdSchema.safeParse(id).success) return { error: "Ungültige Variante." } + + const parsed = ShareSchema.safeParse(input) + if (!parsed.success) return { error: "Ungültige Freigabe-Einstellung." } + const { enabled, expiresInDays, regenerate } = parsed.data + + const supabase = await createClient() + const { data: current } = await supabase.from("template_variants").select("share_token").eq("id", id).maybeSingle() + if (!current) return { error: "Variante nicht gefunden." } + + const token = enabled ? (regenerate || !current.share_token ? newShareToken() : current.share_token) : null + const expiresAt = enabled && expiresInDays > 0 ? new Date(Date.now() + expiresInDays * 86_400_000).toISOString() : null + + const { error } = await supabase + .from("template_variants") + .update({ share_token: token, share_expires_at: expiresAt }) + .eq("id", id) + if (error) return { error: "Freigabe konnte nicht gespeichert werden." } + + revalidateTemplates() + return { token, expiresAt } +} + +export async function duplicateVariant(id: string): Promise<{ error: string } | undefined> { + if (!(await requireAdmin())) return { error: "Keine Berechtigung." } + if (!IdSchema.safeParse(id).success) return { error: "Ungültige Variante." } + + const supabase = await createClient() + const { data: source } = await supabase + .from("template_variants") + .select("name, template_key, client_id, content") + .eq("id", id) + .maybeSingle() + if (!source) return { error: "Variante nicht gefunden." } + + // Der Freigabe-Link wird bewusst nicht mitkopiert + const { data, error } = await supabase + .from("template_variants") + .insert({ ...source, name: `${source.name} (Kopie)`.slice(0, 120) }) + .select("id") + .single() + if (error || !data) return { error: "Variante konnte nicht dupliziert werden." } + + revalidateTemplates() + redirect(`/admin/templates/${data.id}`) +} + +export async function deleteVariant(id: string): Promise { + if (!(await requireAdmin())) return { error: "Keine Berechtigung." } + if (!IdSchema.safeParse(id).success) return { error: "Ungültige Variante." } + + const supabase = await createClient() + const { error } = await supabase.from("template_variants").delete().eq("id", id) + if (error) return { error: "Variante konnte nicht gelöscht werden." } + + revalidateTemplates() + return { success: true } +} diff --git a/modules/templates/ai/access-context.tsx b/modules/templates/ai/access-context.tsx new file mode 100644 index 0000000..8a27884 --- /dev/null +++ b/modules/templates/ai/access-context.tsx @@ -0,0 +1,15 @@ +"use client" + +import { createContext, useContext, type ReactNode } from "react" +import type { AiAccess } from "./demos" + +const AiAccessContext = createContext(null) + +// Ohne Zugang (Grundvorlage ohne Variante) bleiben Chat und KI-Demos aus +export function AiAccessProvider({ access, children }: { access: AiAccess | null; children: ReactNode }) { + return {children} +} + +export function useAiAccess() { + return useContext(AiAccessContext) +} diff --git a/modules/templates/ai/demo-info.ts b/modules/templates/ai/demo-info.ts new file mode 100644 index 0000000..d2e3a0e --- /dev/null +++ b/modules/templates/ai/demo-info.ts @@ -0,0 +1,37 @@ +import { accentColors } from "../lib/content" +import type { TemplateContent, TemplateDefinition } from "../lib/types" +import { AI_DEMO_SLUGS, AI_DEMOS, type AiDemoSlug } from "./demos" +import { businessName, collectImages } from "./knowledge" + +export type DemoInfo = { + brand: string + industry: string + accent: string + accentFg: string + assistantName: string + greeting: string + images: string[] + samples: { inquiry: string; topic: string } + siteHref: string + demos: AiDemoSlug[] +} + +// Serialisierbare Daten für die KI-Demo-Seiten; Admins sehen alle Demos, Kunden nur freigeschaltete +export function demoInfo(def: TemplateDefinition, content: TemplateContent, options: { siteHref: string; all: boolean }): DemoInfo { + const brand = businessName(def, content) + const name = content.ai.name.trim() + + return { + brand, + industry: def.industry, + ...accentColors(def, content), + assistantName: name || "KI-Assistent", + greeting: + content.ai.greeting.trim() || + (name ? `Guten Tag, hier ist ${name} von ${brand}. Wie kann ich Ihnen helfen?` : `Guten Tag, Sie sprechen mit dem digitalen Assistenten von ${brand}. Wie kann ich Ihnen helfen?`), + images: collectImages(def, content), + samples: def.samples, + siteHref: options.siteHref, + demos: options.all ? AI_DEMO_SLUGS : AI_DEMO_SLUGS.filter((slug) => content.ai[AI_DEMOS[slug].feature]), + } +} diff --git a/modules/templates/ai/demos.ts b/modules/templates/ai/demos.ts new file mode 100644 index 0000000..b830e47 --- /dev/null +++ b/modules/templates/ai/demos.ts @@ -0,0 +1,23 @@ +// Ohne zod, damit Vorlagen-Seiten (Badge, Chat) schlank bleiben + +// Admins greifen über die Varianten-ID zu, Kunden über den Freigabe-Token +export type AiAccess = { token: string } | { variantId: string } + +// KI-Demos als eigene Seiten; `feature` ist der Schalter in AiSettings +export const AI_DEMOS = { + telefon: { feature: "phone", label: "KI-Telefon" }, + assistent: { feature: "assistant", label: "Angebots-Assistent" }, + content: { feature: "content", label: "Content-Generator" }, +} as const + +export type AiDemoSlug = keyof typeof AI_DEMOS + +export const AI_DEMO_SLUGS = Object.keys(AI_DEMOS) as AiDemoSlug[] + +export function isDemoSlug(value: string): value is AiDemoSlug { + return value in AI_DEMOS +} + +export function demoHref(access: AiAccess, slug: AiDemoSlug) { + return "token" in access ? `/demo/${access.token}/${slug}` : `/ki/${access.variantId}/${slug}` +} diff --git a/modules/templates/ai/knowledge.ts b/modules/templates/ai/knowledge.ts new file mode 100644 index 0000000..ad8b21a --- /dev/null +++ b/modules/templates/ai/knowledge.ts @@ -0,0 +1,60 @@ +import { brandName } from "../lib/content" +import type { TemplateContent, TemplateDefinition } from "../lib/types" + +const clean = (value: string) => value.replace(/\*/g, "").replace(/\s*\n\s*/g, " · ").trim() + +export function businessName(def: TemplateDefinition, content: TemplateContent) { + return brandName(content) ?? def.name +} + +// Alles, was auf der Webseite steht, als Wissen für die KI – Bilder ausgenommen, ausgeblendete Sektionen auch +export function buildKnowledge(def: TemplateDefinition, content: TemplateContent) { + const parts = [`Betrieb: ${businessName(def, content)}`, `Branche: ${def.industry}`] + + for (const section of def.sections) { + const data = content.data[section.key] + if (!data || content.hidden.includes(section.key)) continue + + const lines: string[] = [] + for (const field of section.fields) { + const value = data[field.key] + if (field.type === "list") { + if (!Array.isArray(value)) continue + const items = value + .map((item) => + field.fields + .filter((sub) => sub.type !== "image" && item[sub.key]?.trim()) + .map((sub) => `${sub.label}: ${clean(item[sub.key])}`) + .join("; ") + ) + .filter(Boolean) + if (items.length) lines.push(`${field.label}:\n${items.map((item) => `- ${item}`).join("\n")}`) + } else if (field.type !== "image" && typeof value === "string" && value.trim()) { + lines.push(`${field.label}: ${clean(value)}`) + } + } + if (lines.length) parts.push(`\n## ${section.label}\n${lines.join("\n")}`) + } + + if (content.ai.knowledge.trim()) parts.push(`\n## Zusatzwissen\n${content.ai.knowledge.trim()}`) + return parts.join("\n") +} + +// Bild-Links der Variante, z. B. für die Post-Vorschauen im Content-Generator +export function collectImages(def: TemplateDefinition, content: TemplateContent) { + const images: string[] = [] + for (const section of def.sections) { + const data = content.data[section.key] + if (!data) continue + for (const field of section.fields) { + const value = data[field.key] + if (field.type === "image" && typeof value === "string") images.push(value) + if (field.type === "list" && Array.isArray(value)) { + for (const sub of field.fields.filter((f) => f.type === "image")) { + images.push(...value.map((item) => item[sub.key] ?? "")) + } + } + } + } + return [...new Set(images.filter((src) => src.startsWith("http")))] +} diff --git a/modules/templates/ai/prompts.ts b/modules/templates/ai/prompts.ts new file mode 100644 index 0000000..e2d426c --- /dev/null +++ b/modules/templates/ai/prompts.ts @@ -0,0 +1,100 @@ +import "server-only" +import type Anthropic from "@anthropic-ai/sdk" +import { buildKnowledge, businessName } from "./knowledge" +import { CHANNELS, type Channel, type Tone } from "./schemas" +import type { AiContext } from "./server" + +function facts(ctx: AiContext) { + return { + brand: businessName(ctx.def, ctx.content), + industry: ctx.def.industry, + knowledge: `\n${buildKnowledge(ctx.def, ctx.content)}\n`, + } +} + +// Datum in einem eigenen Block nach dem gecachten Wissen, damit der Cache den Tag überlebt +export function nowNote() { + const now = new Intl.DateTimeFormat("de-DE", { dateStyle: "full", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date()) + return `Aktuelles Datum und Uhrzeit (Europe/Berlin): ${now}.` +} + +export const BOOKING_TOOL: Anthropic.Beta.BetaTool = { + name: "anfrage_erfassen", + description: + "Erfasst eine Termin-, Reservierungs-, Rückruf- oder Angebotsanfrage und leitet sie an das Team weiter. Erst aufrufen, wenn Name, Kontakt und das Anliegen mit den nötigen Details (z. B. Wunschtermin, Personenzahl) feststehen.", + eager_input_streaming: true, + input_schema: { + type: "object", + properties: { + art: { type: "string", description: "Art der Anfrage, z. B. Reservierung, Termin, Rückruf, Angebot" }, + name: { type: "string", description: "Name der anfragenden Person" }, + kontakt: { type: "string", description: "Telefonnummer oder E-Mail-Adresse" }, + datum: { type: "string", description: "Wunschdatum, wie genannt" }, + uhrzeit: { type: "string", description: "Wunschuhrzeit, wie genannt" }, + personen: { type: "string", description: "Anzahl Personen, falls relevant" }, + anliegen: { type: "string", description: "Kurze Zusammenfassung des Anliegens" }, + }, + required: ["art", "name", "kontakt"], + }, +} + +export function chatSystem(ctx: AiContext, mode: "chat" | "phone") { + const { brand, industry, knowledge } = facts(ctx) + const name = ctx.content.ai.name.trim() || "der digitale Assistent" + const intro = + mode === "phone" + ? `Du bist ${name} und nimmst Anrufe für ${brand} (${industry}) entgegen. Deine Antworten werden vorgelesen. + +- Sprich natürlich und knapp: höchstens zwei kurze Sätze pro Antwort. +- Keine Listen, Abkürzungen, Sonderzeichen, Emojis oder Links. Uhrzeiten, Daten und Preise so, wie man sie ausspricht.` + : `Du bist ${name}, der KI-Assistent auf der Webseite von ${brand} (${industry}). + +- Antworte freundlich und auf den Punkt, meist in zwei bis vier Sätzen, als reiner Text ohne Markdown.` + + return `${intro} +- Antworte in der Sprache der Person, standardmäßig Deutsch in der Sie-Form. +- Stütze dich auf das Wissen unten. Fehlt eine Information, sag das offen und biete an, eine Anfrage aufzunehmen, oder nenne die Kontaktdaten. +- Möchte jemand einen Termin, eine Reservierung, einen Rückruf oder ein Angebot, sammle die nötigen Angaben im Gespräch – immer nur eine Frage auf einmal – und rufe dann anfrage_erfassen auf. Bestätige danach, dass die Anfrage weitergeleitet wurde und sich das Team meldet. Sage nie zu, dass etwas fest gebucht ist, und erfinde keine freien Termine. +- Bei Themen ohne Bezug zum Betrieb hilfst du kurz und lenkst freundlich zurück. + +${knowledge}` +} + +export function emailSystem(ctx: AiContext) { + const { brand, industry, knowledge } = facts(ctx) + return `Du beantwortest für ${brand} (${industry}) eine Kundenanfrage per E-Mail. + +- Herzlich, kompetent und konkret: Geh auf jede Frage der Anfrage ein, in der Sie-Form (außer die Anfrage duzt). +- Nutze Leistungen, Preise, Zeiten und Kontaktdaten nur aus dem Wissen unten. Wo Angaben fehlen, stelle eine passende Rückfrage statt zu raten, und schlage einen nächsten Schritt vor (Termin, Rückruf, Angebot). +- Format: erste Zeile „Betreff: …“, dann eine Leerzeile, dann die E-Mail mit Anrede, Text und Grußformel im Namen des Teams von ${brand}. Reiner Text, kein Markdown. + +${knowledge}` +} + +export function quoteSystem(ctx: AiContext) { + const { brand, industry, knowledge } = facts(ctx) + return `Du erstellst für ${brand} (${industry}) aus einer Kundenanfrage einen Angebotsentwurf, den das Team vor dem Versand prüft. + +- Leite sinnvolle Positionen aus der Anfrage ab, jeweils mit Menge, Einheit und Einzelpreis netto in Euro. +- Nutze Preise aus dem Wissen, wenn vorhanden. Sonst schätze realistische Preise für Deutschland und weise in den Hinweisen darauf hin, dass es Schätzwerte sind. +- Erfinde keine Details über die anfragende Person. Was für ein verbindliches Angebot noch fehlt, gehört in „rueckfragen“ (das sieht nur das Team). +- Anrede, Einleitung und Abschluss im Ton von ${brand}, in der Sie-Form. + +${knowledge}` +} + +const CHANNEL_RULES: Record = { + instagram: "starker erster Satz als hook, Text 60–140 Wörter mit Absätzen, sparsam Emojis, 5–8 passende Hashtags ohne #-Zeichen", + facebook: "persönlicher Ton, Text 40–110 Wörter, 0–3 Hashtags ohne #-Zeichen", + linkedin: "professionell mit echtem Mehrwert, Text 80–170 Wörter, 3 Hashtags ohne #-Zeichen", + google: "hook ist der Anzeigentitel mit höchstens 30 Zeichen, text die Beschreibung mit höchstens 90 Zeichen, keine Hashtags", +} + +export function contentSystem(ctx: AiContext, channel: Channel, tone: Tone) { + const { brand, industry, knowledge } = facts(ctx) + return `Du schreibst Social-Media- und Werbetexte für ${brand} (${industry}). + +Erstelle drei klar unterschiedliche Varianten für ${CHANNELS[channel]} (Regeln: ${CHANNEL_RULES[channel]}). Tonalität: ${tone}. Jede Variante hat einen Call-to-Action und eine kurze Bildidee. Nutze nur Fakten aus dem Wissen unten. + +${knowledge}` +} diff --git a/modules/templates/ai/schemas.ts b/modules/templates/ai/schemas.ts new file mode 100644 index 0000000..0e316c0 --- /dev/null +++ b/modules/templates/ai/schemas.ts @@ -0,0 +1,77 @@ +import * as z from "zod" + +export const AccessSchema = z.union([ + z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{20,64}$/) }), + z.object({ variantId: z.string().uuid() }), +]) + +export type AiAccess = z.infer + +export const ChatRequestSchema = z.object({ + access: AccessSchema, + mode: z.enum(["chat", "phone"]), + messages: z + .array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().trim().min(1).max(2000) })) + .min(1) + .max(40), +}) + +export const CHANNELS = { instagram: "Instagram", facebook: "Facebook", linkedin: "LinkedIn", google: "Google Ads" } as const +export type Channel = keyof typeof CHANNELS + +export const TONES = ["Locker", "Professionell", "Emotional", "Witzig"] as const +export type Tone = (typeof TONES)[number] + +export const GenerateRequestSchema = z.discriminatedUnion("task", [ + z.object({ task: z.literal("email"), access: AccessSchema, inquiry: z.string().trim().min(10).max(4000) }), + z.object({ task: z.literal("quote"), access: AccessSchema, inquiry: z.string().trim().min(10).max(4000) }), + z.object({ + task: z.literal("content"), + access: AccessSchema, + topic: z.string().trim().min(3).max(500), + channel: z.enum(["instagram", "facebook", "linkedin", "google"]), + tone: z.enum(TONES), + }), +]) + +export const BookingSchema = z.object({ + art: z.string(), + name: z.string(), + kontakt: z.string(), + datum: z.string().optional(), + uhrzeit: z.string().optional(), + personen: z.string().optional(), + anliegen: z.string().optional(), +}) + +export type Booking = z.infer + +export const QuoteSchema = z.object({ + betreff: z.string(), + anrede: z.string(), + einleitung: z.string(), + positionen: z.array( + z.object({ titel: z.string(), beschreibung: z.string(), menge: z.number(), einheit: z.string(), einzelpreis: z.number() }) + ), + hinweise: z.array(z.string()), + rueckfragen: z.array(z.string()), + gueltig_tage: z.number(), + abschluss: z.string(), +}) + +export type Quote = z.infer + +export const ContentSchema = z.object({ + varianten: z.array( + z.object({ hook: z.string(), text: z.string(), hashtags: z.array(z.string()), cta: z.string(), bildidee: z.string() }) + ), +}) + +export type ContentResult = z.infer + +// Zeilenweise JSON (NDJSON) von /api/ai/chat und /api/ai/generate (E-Mail) +export type AiEvent = + | { type: "text"; text: string } + | { type: "booking"; data: Booking } + | { type: "error"; message: string } + | { type: "done" } diff --git a/modules/templates/ai/server.ts b/modules/templates/ai/server.ts new file mode 100644 index 0000000..9c70392 --- /dev/null +++ b/modules/templates/ai/server.ts @@ -0,0 +1,76 @@ +import "server-only" +import Anthropic from "@anthropic-ai/sdk" +import { getProfile } from "@/lib/supabase/dal" +import { createAdminClient } from "@/lib/supabase/admin" +import { createClient } from "@/lib/supabase/server" +import { resolveContent } from "../lib/content" +import { getTemplate } from "../lib/registry" +import type { AiSettings, TemplateContent, TemplateDefinition } from "../lib/types" +import type { AiAccess } from "./schemas" + +export const AI_MODEL = "claude-opus-5-5" + +// Lehnt das Modell eine Anfrage aus Sicherheitsgründen ab, übernimmt serverseitig automatisch ein Ersatzmodell +export const FALLBACK = { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" as const } + +// KI-Anfragen pro geteiltem Link und Tag (Kostenbremse für öffentliche Demos) +const DAILY_LIMIT = Number(process.env.AI_DAILY_LIMIT ?? 150) + +let client: Anthropic | null = null + +export function getAnthropic() { + client ??= new Anthropic() + return client +} + +type AiFeature = keyof Pick + +export type AiContext = { def: TemplateDefinition; content: TemplateContent } +type AccessResult = AiContext | { error: string; status: number } + +// Admins testen über die Varianten-ID (ohne Limit), Kunden über den Freigabe-Link (nur freigeschaltete Demos, mit Tageslimit) +export async function resolveAiAccess(access: AiAccess, feature: AiFeature): Promise { + if ("variantId" in access) { + const profile = await getProfile() + if (profile?.role !== "admin") return { error: "Keine Berechtigung.", status: 403 } + + const supabase = await createClient() + const { data } = await supabase.from("template_variants").select("template_key, content").eq("id", access.variantId).maybeSingle() + const def = data ? getTemplate(data.template_key) : undefined + if (!data || !def) return { error: "Variante nicht gefunden.", status: 404 } + + return { def, content: resolveContent(def, data.content) } + } + + const admin = createAdminClient() + const { data } = await admin + .from("template_variants") + .select("id, template_key, content, share_expires_at") + .eq("share_token", access.token) + .maybeSingle() + const def = data ? getTemplate(data.template_key) : undefined + const expired = data?.share_expires_at ? new Date(data.share_expires_at).getTime() <= Date.now() : false + if (!data || !def || expired) return { error: "Dieser Link ist nicht mehr gültig.", status: 404 } + + const content = resolveContent(def, data.content) + if (!content.ai[feature]) return { error: "Diese KI-Demo ist für den Link nicht freigeschaltet.", status: 403 } + + const { data: allowed } = await admin.rpc("consume_ai_quota", { p_variant_id: data.id, p_limit: DAILY_LIMIT }) + if (allowed !== true) return { error: "Das Tageslimit für diese Demo ist erreicht. Bitte morgen erneut testen.", status: 429 } + + return { def, content } +} + +export function aiErrorMessage(error: unknown) { + if (error instanceof Anthropic.AuthenticationError || error instanceof Anthropic.PermissionDeniedError) { + return "Die KI ist noch nicht eingerichtet – bitte den API-Schlüssel prüfen." + } + if (error instanceof Anthropic.RateLimitError) return "Gerade sind sehr viele Anfragen unterwegs. Bitte gleich noch einmal versuchen." + if (error instanceof Anthropic.APIError) return "Die KI ist gerade nicht erreichbar. Bitte später erneut versuchen." + if (error instanceof Anthropic.AnthropicError) return "Die KI ist noch nicht eingerichtet: ANTHROPIC_API_KEY in der .env eintragen." + return "Etwas ist schiefgelaufen. Bitte erneut versuchen." +} + +export function ndjsonResponse(body: ReadableStream) { + return new Response(body, { headers: { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store" } }) +} diff --git a/modules/templates/ai/ui/ai-demo.tsx b/modules/templates/ai/ui/ai-demo.tsx new file mode 100644 index 0000000..64511b2 --- /dev/null +++ b/modules/templates/ai/ui/ai-demo.tsx @@ -0,0 +1,87 @@ +"use client" + +import type { CSSProperties } from "react" +import { ArrowLeftIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import type { DemoInfo } from "../demo-info" +import { AI_DEMOS, demoHref, type AiAccess, type AiDemoSlug } from "../demos" +import { AssistantDemo } from "./assistant-demo" +import { ContentDemo } from "./content-demo" +import { PhoneDemo } from "./phone-demo" + +const intro: Record { title: string; text: string }> = { + telefon: (brand) => ({ + title: "Ihr KI-Telefonassistent", + text: `Nimmt Anrufe an, beantwortet Fragen rund um ${brand} und notiert Terminwünsche – rund um die Uhr. Auf „Anrufen“ klicken und einfach lossprechen.`, + }), + assistent: () => ({ + title: "Angebots- & E-Mail-Assistent", + text: "Aus einer Kundenanfrage wird in Sekunden eine passende Antwort oder ein Angebotsentwurf – auf Basis Ihrer Leistungen und Preise.", + }), + content: (brand) => ({ + title: "Content-Generator", + text: `Posts und Anzeigen für ${brand} in Ihrem Ton – für Instagram, Facebook, LinkedIn und Google Ads.`, + }), +} + +// Gemeinsamer Rahmen der KI-Demos: dunkle Produktoberfläche in der Akzentfarbe des Kunden +export function AiDemo({ tool, access, info }: { tool: AiDemoSlug; access: AiAccess; info: DemoInfo }) { + const { title, text } = intro[tool](info.brand) + + return ( +
+
+
+ + + {info.brand.charAt(0).toUpperCase()} + + + {info.brand} + + Zur Webseite + + + + +
+ +
+
+

+ KI-Demo für {info.brand} +

+

{title}

+

{text}

+
+ {tool === "telefon" && } + {tool === "assistent" && } + {tool === "content" && } +
+ +
+ Demo von Since3 · Antworten entstehen live per KI und können Fehler enthalten. Es wird nichts versendet oder gebucht. +
+
+ ) +} diff --git a/modules/templates/ai/ui/assistant-demo.tsx b/modules/templates/ai/ui/assistant-demo.tsx new file mode 100644 index 0000000..bab3aa0 --- /dev/null +++ b/modules/templates/ai/ui/assistant-demo.tsx @@ -0,0 +1,284 @@ +"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 = { + 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("email") + const [email, setEmail] = useState("") + const [quote, setQuote] = useState(null) + const [pending, setPending] = useState(false) + const [step, setStep] = useState(0) + const [error, setError] = useState(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 ( +
+
+
+

Kundenanfrage

+ +
+

Eigene Anfrage einfügen oder das Beispiel nutzen.

+