mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
added bunch of stuff
This commit is contained in:
parent
dfcbf1d536
commit
46a600d3c3
91 changed files with 11270 additions and 1 deletions
20
app/(templates)/demo/[token]/[tool]/page.tsx
Normal file
20
app/(templates)/demo/[token]/[tool]/page.tsx
Normal file
|
|
@ -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 <AiDemo tool={tool} access={{ token }} info={demoInfo(shared.def, shared.content, { siteHref: `/demo/${token}`, all: false })} />
|
||||||
|
}
|
||||||
23
app/(templates)/demo/[token]/page.tsx
Normal file
23
app/(templates)/demo/[token]/page.tsx
Normal file
|
|
@ -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<Metadata> {
|
||||||
|
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 <TemplateRenderer templateKey={shared.def.key} content={shared.content} access={{ token }} />
|
||||||
|
}
|
||||||
30
app/(templates)/ki/[id]/[tool]/page.tsx
Normal file
30
app/(templates)/ki/[id]/[tool]/page.tsx
Normal file
|
|
@ -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 <AiDemo tool={tool} access={{ variantId: id }} info={demoInfo(def, content, { siteHref: `/preview/${def.key}?variant=${id}`, all: true })} />
|
||||||
|
}
|
||||||
39
app/(templates)/preview/[key]/page.tsx
Normal file
39
app/(templates)/preview/[key]/page.tsx
Normal file
|
|
@ -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"
|
||||||
|
? <LivePreview templateKey={def.key} initialContent={content} access={access} />
|
||||||
|
: <TemplateRenderer templateKey={def.key} content={content} access={access} />
|
||||||
|
}
|
||||||
34
app/admin/templates/[id]/page.tsx
Normal file
34
app/admin/templates/[id]/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<VariantEditor
|
||||||
|
variant={{
|
||||||
|
id: variant.id,
|
||||||
|
name: variant.name,
|
||||||
|
templateKey: def.key,
|
||||||
|
clientId: variant.client_id,
|
||||||
|
content: resolveContent(def, variant.content),
|
||||||
|
shareToken: variant.share_token,
|
||||||
|
shareExpiresAt: variant.share_expires_at,
|
||||||
|
shareState: shareState(variant.share_token, variant.share_expires_at),
|
||||||
|
}}
|
||||||
|
clients={clients.map(({ id, name }) => ({ id, name }))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
app/admin/templates/page.tsx
Normal file
10
app/admin/templates/page.tsx
Normal file
|
|
@ -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 <TemplatesView />
|
||||||
|
}
|
||||||
88
app/api/ai/chat/route.ts
Normal file
88
app/api/ai/chat/route.ts
Normal file
|
|
@ -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<Uint8Array>({
|
||||||
|
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)
|
||||||
|
}
|
||||||
68
app/api/ai/generate/route.ts
Normal file
68
app/api/ai/generate/route.ts
Normal file
|
|
@ -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<T extends z.ZodType>(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<Uint8Array>({
|
||||||
|
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: `<anfrage>\n${input.inquiry}\n</anfrage>` }],
|
||||||
|
})
|
||||||
|
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), `<anfrage>\n${input.inquiry}\n</anfrage>`)
|
||||||
|
: 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,15 @@
|
||||||
--font-sans: var(--font-inter);
|
--font-sans: var(--font-inter);
|
||||||
--font-mono: var(--font-jetbrains-mono);
|
--font-mono: var(--font-jetbrains-mono);
|
||||||
--font-heading: var(--font-sans);
|
--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-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
|
|
||||||
39
lib/supabase/migrations/20261005100000_template_variants.sql
Normal file
39
lib/supabase/migrations/20261005100000_template_variants.sql
Normal file
|
|
@ -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/<token>; 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;
|
||||||
24
lib/supabase/migrations/20261006100000_ai_usage.sql
Normal file
24
lib/supabase/migrations/20261006100000_ai_usage.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
|
LayoutTemplateIcon,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UserRoundIcon,
|
UserRoundIcon,
|
||||||
|
|
@ -28,6 +29,11 @@ export const adminMenu: MenuItem[] = [
|
||||||
url: "/admin/clients",
|
url: "/admin/clients",
|
||||||
icon: UsersRoundIcon
|
icon: UsersRoundIcon
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Vorlagen",
|
||||||
|
url: "/admin/templates",
|
||||||
|
icon: LayoutTemplateIcon
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
url: "/admin/settings",
|
url: "/admin/settings",
|
||||||
|
|
|
||||||
151
modules/templates/actions/variants.ts
Normal file
151
modules/templates/actions/variants.ts
Normal file
|
|
@ -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<typeof SaveSchema>): Promise<VariantActionResult> {
|
||||||
|
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<typeof ShareSchema>): Promise<ShareResult> {
|
||||||
|
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<VariantActionResult> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
15
modules/templates/ai/access-context.tsx
Normal file
15
modules/templates/ai/access-context.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from "react"
|
||||||
|
import type { AiAccess } from "./demos"
|
||||||
|
|
||||||
|
const AiAccessContext = createContext<AiAccess | null>(null)
|
||||||
|
|
||||||
|
// Ohne Zugang (Grundvorlage ohne Variante) bleiben Chat und KI-Demos aus
|
||||||
|
export function AiAccessProvider({ access, children }: { access: AiAccess | null; children: ReactNode }) {
|
||||||
|
return <AiAccessContext value={access}>{children}</AiAccessContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAiAccess() {
|
||||||
|
return useContext(AiAccessContext)
|
||||||
|
}
|
||||||
37
modules/templates/ai/demo-info.ts
Normal file
37
modules/templates/ai/demo-info.ts
Normal file
|
|
@ -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]),
|
||||||
|
}
|
||||||
|
}
|
||||||
23
modules/templates/ai/demos.ts
Normal file
23
modules/templates/ai/demos.ts
Normal file
|
|
@ -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}`
|
||||||
|
}
|
||||||
60
modules/templates/ai/knowledge.ts
Normal file
60
modules/templates/ai/knowledge.ts
Normal file
|
|
@ -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")))]
|
||||||
|
}
|
||||||
100
modules/templates/ai/prompts.ts
Normal file
100
modules/templates/ai/prompts.ts
Normal file
|
|
@ -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: `<wissen>\n${buildKnowledge(ctx.def, ctx.content)}\n</wissen>`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Channel, string> = {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
77
modules/templates/ai/schemas.ts
Normal file
77
modules/templates/ai/schemas.ts
Normal file
|
|
@ -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<typeof AccessSchema>
|
||||||
|
|
||||||
|
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<typeof BookingSchema>
|
||||||
|
|
||||||
|
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<typeof QuoteSchema>
|
||||||
|
|
||||||
|
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<typeof ContentSchema>
|
||||||
|
|
||||||
|
// 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" }
|
||||||
76
modules/templates/ai/server.ts
Normal file
76
modules/templates/ai/server.ts
Normal file
|
|
@ -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<AiSettings, "chat" | "phone" | "assistant" | "content">
|
||||||
|
|
||||||
|
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<AccessResult> {
|
||||||
|
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<Uint8Array>) {
|
||||||
|
return new Response(body, { headers: { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store" } })
|
||||||
|
}
|
||||||
87
modules/templates/ai/ui/ai-demo.tsx
Normal file
87
modules/templates/ai/ui/ai-demo.tsx
Normal file
|
|
@ -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<AiDemoSlug, (brand: string) => { 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 (
|
||||||
|
<div
|
||||||
|
style={{ "--t-accent": info.accent, "--t-accent-fg": info.accentFg } as CSSProperties}
|
||||||
|
className="relative min-h-dvh overflow-x-clip bg-[#09090b] font-sans text-zinc-100 [color-scheme:dark]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none fixed inset-0 bg-[radial-gradient(70rem_40rem_at_80%_-10%,color-mix(in_oklab,var(--t-accent)_22%,transparent),transparent_70%)]"
|
||||||
|
/>
|
||||||
|
<header className="relative mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-5 py-5">
|
||||||
|
<a href={info.siteHref} className="group flex items-center gap-3">
|
||||||
|
<span className="grid size-10 place-items-center rounded-xl bg-t-accent text-lg font-semibold text-t-accent-fg">
|
||||||
|
{info.brand.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold">{info.brand}</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-zinc-400 group-hover:text-zinc-200">
|
||||||
|
<ArrowLeftIcon className="size-3" /> Zur Webseite
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<nav aria-label="KI-Demos" className="flex flex-wrap gap-1 rounded-full border border-white/10 bg-white/5 p-1 text-sm">
|
||||||
|
{info.demos.map((slug) => (
|
||||||
|
<a
|
||||||
|
key={slug}
|
||||||
|
href={demoHref(access, slug)}
|
||||||
|
aria-current={slug === tool ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-4 py-2 transition-colors",
|
||||||
|
slug === tool ? "bg-white font-medium text-zinc-900" : "text-zinc-300 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{AI_DEMOS[slug].label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="relative mx-auto max-w-6xl px-5 pt-6 pb-16">
|
||||||
|
<div className="mb-10 max-w-2xl">
|
||||||
|
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-zinc-300">
|
||||||
|
<span className="size-1.5 rounded-full bg-t-accent" /> KI-Demo für {info.brand}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight md:text-5xl">{title}</h1>
|
||||||
|
<p className="mt-4 text-zinc-400 md:text-lg">{text}</p>
|
||||||
|
</div>
|
||||||
|
{tool === "telefon" && <PhoneDemo access={access} info={info} />}
|
||||||
|
{tool === "assistent" && <AssistantDemo access={access} info={info} />}
|
||||||
|
{tool === "content" && <ContentDemo access={access} info={info} />}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="relative mx-auto max-w-6xl px-5 pb-8 text-xs text-zinc-500">
|
||||||
|
Demo von Since3 · Antworten entstehen live per KI und können Fehler enthalten. Es wird nichts versendet oder gebucht.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
284
modules/templates/ai/ui/assistant-demo.tsx
Normal file
284
modules/templates/ai/ui/assistant-demo.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
modules/templates/ai/ui/booking-card.tsx
Normal file
33
modules/templates/ai/ui/booking-card.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Fragment } from "react"
|
||||||
|
import { CircleCheckIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { Booking } from "../schemas"
|
||||||
|
|
||||||
|
export function BookingCard({ data, className }: { data: Booking; className?: string }) {
|
||||||
|
const rows = [
|
||||||
|
["Anliegen", data.anliegen],
|
||||||
|
["Datum", data.datum],
|
||||||
|
["Uhrzeit", data.uhrzeit],
|
||||||
|
["Personen", data.personen],
|
||||||
|
["Name", data.name],
|
||||||
|
["Kontakt", data.kontakt],
|
||||||
|
].filter((row): row is [string, string] => Boolean(row[1]?.trim()))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-2xl border p-4 text-sm", className)}>
|
||||||
|
<p className="flex items-center gap-2 font-medium">
|
||||||
|
<CircleCheckIcon className="size-4 shrink-0" />
|
||||||
|
{data.art || "Anfrage"} erfasst
|
||||||
|
</p>
|
||||||
|
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<Fragment key={label}>
|
||||||
|
<dt className="opacity-60">{label}</dt>
|
||||||
|
<dd className="break-words">{value}</dd>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<p className="mt-3 text-xs opacity-60">Demo: Live landet die Anfrage direkt im Postfach oder Kalender des Teams.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
192
modules/templates/ai/ui/chat-widget.tsx
Normal file
192
modules/templates/ai/ui/chat-widget.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { ArrowUpIcon, MessageCircleIcon, XIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { brandName } from "../../lib/content"
|
||||||
|
import type { TemplateContent } from "../../lib/types"
|
||||||
|
import { useAiAccess } from "../access-context"
|
||||||
|
import { BookingCard } from "./booking-card"
|
||||||
|
import { useAiChat } from "./use-ai-chat"
|
||||||
|
|
||||||
|
const ease = [0.22, 1, 0.36, 1] as const
|
||||||
|
const QUICK_REPLIES = ["Wie sind Ihre Öffnungszeiten?", "Ich möchte einen Termin vereinbaren", "Was bieten Sie an?"]
|
||||||
|
|
||||||
|
// Chat-Assistent unten rechts; nutzt Palette und Schriften der Vorlage (bg-t-*, font-t-*)
|
||||||
|
export function ChatWidget({ content }: { content: TemplateContent }) {
|
||||||
|
const access = useAiAccess()
|
||||||
|
const chat = useAiChat({ access, mode: "chat" })
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [teaser, setTeaser] = useState(false)
|
||||||
|
const [draft, setDraft] = useState("")
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const enabled = Boolean(access && content.ai.chat)
|
||||||
|
|
||||||
|
const name = content.ai.name.trim() || "Assistent"
|
||||||
|
const brand = brandName(content)
|
||||||
|
const greeting = content.ai.greeting.trim() || `Hallo! Ich bin ${name}${brand ? ` von ${brand}` : ""}. Wie kann ich Ihnen helfen?`
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
const timer = setTimeout(() => setTeaser(true), 4000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [enabled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" })
|
||||||
|
}, [chat.messages, open])
|
||||||
|
|
||||||
|
if (!enabled) return null
|
||||||
|
|
||||||
|
const last = chat.messages[chat.messages.length - 1]
|
||||||
|
const typing = chat.pending && last?.role === "assistant" && last.parts.length === 0
|
||||||
|
|
||||||
|
function submit(text: string) {
|
||||||
|
if (!text.trim() || chat.pending) return
|
||||||
|
setDraft("")
|
||||||
|
void chat.send(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed right-4 bottom-[calc(1rem+var(--t-safe-bottom,0px))] z-[70] flex flex-col items-end gap-3 font-t-body">
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.section
|
||||||
|
aria-label={`Chat mit ${name}`}
|
||||||
|
initial={{ opacity: 0, y: 16, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 16, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.35, ease }}
|
||||||
|
className="flex h-[min(560px,calc(100dvh-7rem))] w-[min(380px,calc(100vw-2rem))] origin-bottom-right flex-col overflow-hidden rounded-3xl border border-t-fg/10 bg-t-bg text-t-fg shadow-[0_30px_80px_-20px_rgba(0,0,0,0.45)]"
|
||||||
|
>
|
||||||
|
<header className="flex items-center gap-3 border-b border-t-fg/10 px-4 py-3">
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-t-accent font-semibold text-t-accent-fg">
|
||||||
|
{name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{name}</p>
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-t-muted">
|
||||||
|
<span className="size-1.5 rounded-full bg-emerald-500" /> KI-Assistent · antwortet sofort
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" aria-label="Chat schließen" onClick={() => setOpen(false)} className="grid size-9 cursor-pointer place-items-center rounded-full hover:bg-t-fg/5">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||||
|
<Bubble role="assistant">{greeting}</Bubble>
|
||||||
|
{chat.messages.map((message, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
{message.parts.map((part, j) =>
|
||||||
|
part.type === "booking" ? (
|
||||||
|
<BookingCard key={j} data={part.data} className="border-t-accent/40 bg-t-surface" />
|
||||||
|
) : (
|
||||||
|
<Bubble key={j} role={message.role}>{part.text}</Bubble>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{typing && (
|
||||||
|
<div className="flex w-fit gap-1 rounded-2xl rounded-bl-md bg-t-surface px-4 py-3">
|
||||||
|
{[0, 1, 2].map((dot) => (
|
||||||
|
<motion.span
|
||||||
|
key={dot}
|
||||||
|
className="size-1.5 rounded-full bg-t-muted"
|
||||||
|
animate={{ y: [0, -4, 0] }}
|
||||||
|
transition={{ duration: 0.8, repeat: Infinity, delay: dot * 0.15 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chat.error && <p className="text-sm text-red-500">{chat.error}</p>}
|
||||||
|
{chat.messages.length === 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{QUICK_REPLIES.map((reply) => (
|
||||||
|
<button
|
||||||
|
key={reply}
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit(reply)}
|
||||||
|
className="cursor-pointer rounded-full border border-t-fg/15 px-3 py-1.5 text-left text-xs transition-colors hover:border-t-accent hover:text-t-accent"
|
||||||
|
>
|
||||||
|
{reply}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2 border-t border-t-fg/10 p-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
submit(draft)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
placeholder="Nachricht schreiben …"
|
||||||
|
aria-label="Nachricht"
|
||||||
|
maxLength={1500}
|
||||||
|
className="min-w-0 flex-1 rounded-full bg-t-surface px-4 py-2.5 text-sm outline-none placeholder:text-t-muted focus:ring-2 focus:ring-t-accent/40"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="Senden"
|
||||||
|
disabled={!draft.trim() || chat.pending}
|
||||||
|
className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-full bg-t-accent text-t-accent-fg transition-opacity disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="pb-2 text-center text-[10px] text-t-muted">KI-Demo · Antworten können Fehler enthalten</p>
|
||||||
|
</motion.section>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{teaser && !open && (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 8 }}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="max-w-60 cursor-pointer rounded-2xl rounded-br-md bg-t-bg px-4 py-3 text-left text-sm text-t-fg shadow-xl ring-1 ring-t-fg/10"
|
||||||
|
>
|
||||||
|
Fragen? Ich antworte sofort – auch auf Terminwünsche.
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
aria-label={open ? "Chat schließen" : `Chat mit ${name} öffnen`}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(!open)
|
||||||
|
setTeaser(false)
|
||||||
|
}}
|
||||||
|
whileHover={{ scale: 1.06 }}
|
||||||
|
whileTap={{ scale: 0.94 }}
|
||||||
|
className="grid size-14 cursor-pointer place-items-center rounded-full bg-t-accent text-t-accent-fg shadow-[0_12px_40px_-8px_var(--t-accent)]"
|
||||||
|
>
|
||||||
|
{open ? <XIcon className="size-5" /> : <MessageCircleIcon className="size-6" />}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bubble({ role, children }: { role: "user" | "assistant"; children: string }) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"w-fit max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-2.5 text-sm leading-relaxed",
|
||||||
|
role === "user" ? "ml-auto rounded-br-md bg-t-accent text-t-accent-fg" : "rounded-bl-md bg-t-surface"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
219
modules/templates/ai/ui/content-demo.tsx
Normal file
219
modules/templates/ai/ui/content-demo.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
294
modules/templates/ai/ui/phone-demo.tsx
Normal file
294
modules/templates/ai/ui/phone-demo.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||||
|
import { ArrowUpIcon, PhoneIcon, PhoneOffIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { DemoInfo } from "../demo-info"
|
||||||
|
import type { AiAccess } from "../demos"
|
||||||
|
import { BookingCard } from "./booking-card"
|
||||||
|
import { useAiChat } from "./use-ai-chat"
|
||||||
|
|
||||||
|
// Web Speech API: nicht in allen TS-DOM-Typen enthalten, daher minimal selbst beschrieben
|
||||||
|
type RecognitionEvent = { resultIndex: number; results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }
|
||||||
|
type Recognition = {
|
||||||
|
lang: string
|
||||||
|
interimResults: boolean
|
||||||
|
continuous: boolean
|
||||||
|
start(): void
|
||||||
|
abort(): void
|
||||||
|
onresult: ((event: RecognitionEvent) => void) | null
|
||||||
|
onerror: ((event: { error: string }) => void) | null
|
||||||
|
onend: (() => void) | null
|
||||||
|
}
|
||||||
|
type RecognitionCtor = new () => Recognition
|
||||||
|
|
||||||
|
function recognitionCtor(): RecognitionCtor | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
const speech = window as unknown as { SpeechRecognition?: RecognitionCtor; webkitSpeechRecognition?: RecognitionCtor }
|
||||||
|
return speech.SpeechRecognition ?? speech.webkitSpeechRecognition ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribe = () => () => {}
|
||||||
|
const EXAMPLES = ["Haben Sie heute geöffnet?", "Ich möchte einen Termin vereinbaren", "Was kostet das bei Ihnen?"]
|
||||||
|
|
||||||
|
type CallState = "idle" | "speaking" | "listening" | "thinking" | "ended"
|
||||||
|
|
||||||
|
const statusLabel: Record<CallState, string> = {
|
||||||
|
idle: "Bereit für Ihren Anruf",
|
||||||
|
speaking: "Spricht …",
|
||||||
|
listening: "Hört zu …",
|
||||||
|
thinking: "Denkt nach …",
|
||||||
|
ended: "Anruf beendet",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhoneDemo({ access, info }: { access: AiAccess; info: DemoInfo }) {
|
||||||
|
const [call, setCall] = useState<CallState>("idle")
|
||||||
|
const [heard, setHeard] = useState("")
|
||||||
|
const [draft, setDraft] = useState("")
|
||||||
|
const [seconds, setSeconds] = useState(0)
|
||||||
|
const [micError, setMicError] = useState<string | null>(null)
|
||||||
|
const active = useRef(false)
|
||||||
|
const voice = useRef<SpeechSynthesisVoice | null>(null)
|
||||||
|
const recognition = useRef<Recognition | null>(null)
|
||||||
|
const canListen = useSyncExternalStore(subscribe, () => recognitionCtor() !== null, () => true)
|
||||||
|
const chat = useAiChat({ access, mode: "phone", onReply: speak, onError: () => listen() })
|
||||||
|
const live = call !== "idle" && call !== "ended"
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const synth = typeof window !== "undefined" ? window.speechSynthesis : undefined
|
||||||
|
const pick = () => {
|
||||||
|
const german = (synth?.getVoices() ?? []).filter((v) => v.lang.toLowerCase().startsWith("de"))
|
||||||
|
voice.current = german.find((v) => /google|anna|petra|helena|vicki|marlene|katja/i.test(v.name)) ?? german[0] ?? null
|
||||||
|
}
|
||||||
|
pick()
|
||||||
|
synth?.addEventListener("voiceschanged", pick)
|
||||||
|
return () => {
|
||||||
|
synth?.removeEventListener("voiceschanged", pick)
|
||||||
|
active.current = false
|
||||||
|
recognition.current?.abort()
|
||||||
|
synth?.cancel()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!live) return
|
||||||
|
const timer = setInterval(() => setSeconds((s) => s + 1), 1000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [live])
|
||||||
|
|
||||||
|
function speak(text: string) {
|
||||||
|
if (!active.current) return
|
||||||
|
const synth = window.speechSynthesis
|
||||||
|
if (!text || !synth) return listen()
|
||||||
|
|
||||||
|
synth.cancel()
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
|
utterance.lang = "de-DE"
|
||||||
|
utterance.rate = 1.04
|
||||||
|
if (voice.current) utterance.voice = voice.current
|
||||||
|
utterance.onend = () => listen()
|
||||||
|
setCall("speaking")
|
||||||
|
synth.speak(utterance)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen() {
|
||||||
|
if (!active.current) return
|
||||||
|
setHeard("")
|
||||||
|
const Ctor = recognitionCtor()
|
||||||
|
if (!Ctor || micError) {
|
||||||
|
setCall("listening")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = new Ctor()
|
||||||
|
rec.lang = "de-DE"
|
||||||
|
rec.interimResults = true
|
||||||
|
rec.continuous = false
|
||||||
|
let finalText = ""
|
||||||
|
let blocked = false
|
||||||
|
|
||||||
|
rec.onresult = (event) => {
|
||||||
|
let interim = ""
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const result = event.results[i]
|
||||||
|
if (result.isFinal) finalText += result[0].transcript
|
||||||
|
else interim += result[0].transcript
|
||||||
|
}
|
||||||
|
setHeard(`${finalText} ${interim}`.trim())
|
||||||
|
}
|
||||||
|
rec.onerror = (event) => {
|
||||||
|
if (event.error === "not-allowed" || event.error === "service-not-allowed") {
|
||||||
|
blocked = true
|
||||||
|
setMicError("Kein Mikrofonzugriff – bitte erlauben oder unten tippen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec.onend = () => {
|
||||||
|
recognition.current = null
|
||||||
|
if (!active.current) return
|
||||||
|
if (finalText.trim()) {
|
||||||
|
setHeard("")
|
||||||
|
setCall("thinking")
|
||||||
|
void chat.send(finalText)
|
||||||
|
} else if (blocked) {
|
||||||
|
setCall("listening")
|
||||||
|
} else {
|
||||||
|
listen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recognition.current = rec
|
||||||
|
setCall("listening")
|
||||||
|
rec.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
active.current = true
|
||||||
|
setSeconds(0)
|
||||||
|
setMicError(null)
|
||||||
|
chat.reset()
|
||||||
|
speak(info.greeting)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hangUp() {
|
||||||
|
active.current = false
|
||||||
|
recognition.current?.abort()
|
||||||
|
window.speechSynthesis?.cancel()
|
||||||
|
setCall("ended")
|
||||||
|
setHeard("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendText(text: string) {
|
||||||
|
if (!text.trim() || chat.pending) return
|
||||||
|
if (!active.current) {
|
||||||
|
active.current = true
|
||||||
|
setSeconds(0)
|
||||||
|
}
|
||||||
|
recognition.current?.abort()
|
||||||
|
setDraft("")
|
||||||
|
setCall("thinking")
|
||||||
|
void chat.send(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,400px)_minmax(0,1fr)]">
|
||||||
|
<section className="flex flex-col items-center rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center">
|
||||||
|
<p className="text-xs uppercase tracking-[0.25em] text-zinc-400">{live ? "Verbunden" : "KI-Telefon"}</p>
|
||||||
|
<div className="relative mt-10 grid size-52 place-items-center">
|
||||||
|
{[0, 1, 2].map((ring) => (
|
||||||
|
<motion.span
|
||||||
|
key={ring}
|
||||||
|
className="absolute inset-0 rounded-full border border-t-accent"
|
||||||
|
animate={call === "speaking" ? { scale: [1, 1.45], opacity: [0.5, 0] } : { scale: 1, opacity: live ? 0.25 : 0.1 }}
|
||||||
|
transition={call === "speaking" ? { duration: 1.8, repeat: Infinity, delay: ring * 0.6, ease: "easeOut" } : { duration: 0.4 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="relative grid size-36 place-items-center rounded-full bg-t-accent text-5xl font-semibold text-t-accent-fg shadow-[0_0_90px_-10px_var(--t-accent)]">
|
||||||
|
{info.brand.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-8 text-xl font-semibold">{info.assistantName}</p>
|
||||||
|
<p className="text-sm text-zinc-400">{info.brand}</p>
|
||||||
|
<p className="mt-4 flex h-6 items-center gap-2 text-sm text-t-accent">
|
||||||
|
{statusLabel[call]}
|
||||||
|
{live && <span className="font-mono text-zinc-400">{time}</span>}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex h-8 items-end gap-1" aria-hidden>
|
||||||
|
{call === "listening" &&
|
||||||
|
[0, 1, 2, 3, 4].map((bar) => (
|
||||||
|
<motion.span
|
||||||
|
key={bar}
|
||||||
|
className="w-1.5 rounded-full bg-t-accent"
|
||||||
|
animate={{ height: [8, 26, 12, 30, 8] }}
|
||||||
|
transition={{ duration: 1.1, repeat: Infinity, delay: bar * 0.12 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
{live ? (
|
||||||
|
<button type="button" onClick={hangUp} aria-label="Auflegen" className="grid size-16 cursor-pointer place-items-center rounded-full bg-red-500 text-white transition-transform hover:scale-105">
|
||||||
|
<PhoneOffIcon className="size-6" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={start} aria-label="Anrufen" className="relative grid size-16 cursor-pointer place-items-center rounded-full bg-emerald-500 text-white transition-transform hover:scale-105">
|
||||||
|
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-500/40" />
|
||||||
|
<PhoneIcon className="relative size-6" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 max-w-xs text-xs text-zinc-500">
|
||||||
|
{micError ??
|
||||||
|
(canListen
|
||||||
|
? "Nach dem Anrufen den Mikrofonzugriff erlauben und einfach sprechen. Ton einschalten!"
|
||||||
|
: "Dieser Browser kann keine Sprache erkennen – tippen Sie rechts (Chrome, Edge oder Safari empfohlen).")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex min-h-[32rem] flex-col rounded-[2rem] border border-white/10 bg-white/[0.03]">
|
||||||
|
<header className="border-b border-white/10 px-6 py-4 text-sm font-medium">Live-Mitschrift</header>
|
||||||
|
<div className="flex-1 space-y-3 overflow-y-auto px-6 py-5">
|
||||||
|
{call === "idle" && chat.messages.length === 0 && (
|
||||||
|
<p className="text-sm text-zinc-500">Hier erscheint das Gespräch, sobald Sie anrufen oder eine Frage tippen.</p>
|
||||||
|
)}
|
||||||
|
{call !== "idle" && <Line role="assistant" text={info.greeting} />}
|
||||||
|
{chat.messages.map((message, i) =>
|
||||||
|
message.parts.map((part, j) =>
|
||||||
|
part.type === "booking" ? (
|
||||||
|
<BookingCard key={`${i}-${j}`} data={part.data} className="border-t-accent/40 bg-white/5" />
|
||||||
|
) : (
|
||||||
|
<Line key={`${i}-${j}`} role={message.role} text={part.text} />
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{heard && <p className="text-sm italic text-zinc-400">„{heard} …“</p>}
|
||||||
|
{chat.error && <p className="text-sm text-red-400">{chat.error}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 border-t border-white/10 p-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{EXAMPLES.map((example) => (
|
||||||
|
<button
|
||||||
|
key={example}
|
||||||
|
type="button"
|
||||||
|
onClick={() => sendText(example)}
|
||||||
|
className="cursor-pointer rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:border-t-accent hover:text-white"
|
||||||
|
>
|
||||||
|
{example}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="flex gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
sendText(draft)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
placeholder="Oder hier tippen …"
|
||||||
|
aria-label="Nachricht an den Assistenten"
|
||||||
|
className="min-w-0 flex-1 rounded-full border border-white/10 bg-white/5 px-4 py-2.5 text-sm outline-none focus:border-t-accent"
|
||||||
|
/>
|
||||||
|
<button type="submit" aria-label="Senden" disabled={!draft.trim() || chat.pending} className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-full bg-t-accent text-t-accent-fg disabled:opacity-40">
|
||||||
|
<ArrowUpIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Line({ role, text }: { role: "user" | "assistant"; text: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex", role === "user" && "justify-end")}>
|
||||||
|
<p className={cn("max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed", role === "user" ? "bg-t-accent text-t-accent-fg" : "bg-white/[0.06]")}>
|
||||||
|
<span className="mb-0.5 block text-[10px] uppercase tracking-wider opacity-60">{role === "user" ? "Anrufer:in" : "Assistent"}</span>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
modules/templates/ai/ui/use-ai-chat.ts
Normal file
120
modules/templates/ai/ui/use-ai-chat.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRef, useState } from "react"
|
||||||
|
import type { AiAccess } from "../demos"
|
||||||
|
import type { AiEvent, Booking } from "../schemas"
|
||||||
|
|
||||||
|
export type ChatPart = { type: "text"; text: string } | { type: "booking"; data: Booking }
|
||||||
|
export type ChatMessage = { role: "user" | "assistant"; parts: ChatPart[] }
|
||||||
|
|
||||||
|
const FALLBACK_ERROR = "Die KI ist gerade nicht erreichbar."
|
||||||
|
|
||||||
|
export async function readNdjson(response: Response, onEvent: (event: AiEvent) => void) {
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const data = (await response.json().catch(() => null)) as { error?: string } | null
|
||||||
|
throw new Error(data?.error ?? FALLBACK_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ""
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
let newline = buffer.indexOf("\n")
|
||||||
|
while (newline >= 0) {
|
||||||
|
const line = buffer.slice(0, newline).trim()
|
||||||
|
buffer = buffer.slice(newline + 1)
|
||||||
|
if (line) onEvent(JSON.parse(line) as AiEvent)
|
||||||
|
newline = buffer.indexOf("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageText(message: ChatMessage) {
|
||||||
|
return message.parts.map((part) => (part.type === "text" ? part.text : "")).join("").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Verlauf liegt zusätzlich in einer Ref: Sprach-Callbacks des KI-Telefons rufen `send` aus älteren Renders auf
|
||||||
|
export function useAiChat({ access, mode, onReply, onError }: {
|
||||||
|
access: AiAccess | null
|
||||||
|
mode: "chat" | "phone"
|
||||||
|
onReply?: (text: string) => void
|
||||||
|
onError?: () => void
|
||||||
|
}) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const history = useRef<ChatMessage[]>([])
|
||||||
|
const busy = useRef(false)
|
||||||
|
|
||||||
|
function commit(next: ChatMessage[]) {
|
||||||
|
history.current = next
|
||||||
|
setMessages(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLast(change: (last: ChatMessage) => ChatMessage) {
|
||||||
|
const current = history.current
|
||||||
|
commit([...current.slice(0, -1), change(current[current.length - 1])])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(text: string) {
|
||||||
|
const content = text.trim()
|
||||||
|
if (!content || !access || busy.current) return
|
||||||
|
busy.current = true
|
||||||
|
|
||||||
|
const base: ChatMessage[] = [...history.current, { role: "user", parts: [{ type: "text", text: content }] }]
|
||||||
|
commit([...base, { role: "assistant", parts: [] }])
|
||||||
|
setPending(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
let reply = ""
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/ai/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
access,
|
||||||
|
mode,
|
||||||
|
messages: base
|
||||||
|
.map((message) => ({ role: message.role, content: messageText(message) }))
|
||||||
|
.filter((message) => message.content)
|
||||||
|
.slice(-30),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await readNdjson(response, (event) => {
|
||||||
|
if (event.type === "error") throw new Error(event.message)
|
||||||
|
if (event.type === "booking") {
|
||||||
|
updateLast((last) => ({ ...last, parts: [...last.parts, { type: "booking", data: event.data }] }))
|
||||||
|
} else if (event.type === "text") {
|
||||||
|
reply += event.text
|
||||||
|
updateLast((last) => {
|
||||||
|
const parts = [...last.parts]
|
||||||
|
const tail = parts[parts.length - 1]
|
||||||
|
if (tail?.type === "text") parts[parts.length - 1] = { type: "text", text: tail.text + event.text }
|
||||||
|
else parts.push({ type: "text", text: event.text })
|
||||||
|
return { ...last, parts }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
busy.current = false
|
||||||
|
setPending(false)
|
||||||
|
onReply?.(reply.trim())
|
||||||
|
} catch (cause) {
|
||||||
|
const last = history.current[history.current.length - 1]
|
||||||
|
if (last?.role === "assistant" && last.parts.length === 0) commit(history.current.slice(0, -1))
|
||||||
|
busy.current = false
|
||||||
|
setPending(false)
|
||||||
|
setError(cause instanceof Error ? cause.message : FALLBACK_ERROR)
|
||||||
|
onError?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
commit([])
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages, pending, error, send, reset }
|
||||||
|
}
|
||||||
190
modules/templates/catalog/beauty/definition.ts
Normal file
190
modules/templates/catalog/beauty/definition.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { defineTemplate, RICH_HINT, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
export const beauty = defineTemplate({
|
||||||
|
key: "beauty",
|
||||||
|
name: "Maison",
|
||||||
|
industry: "Friseur, Beauty & Fitness",
|
||||||
|
description: "Editorial und verspielt: Bild-Collage mit Parallax, Preisliste mit mitlaufenden Bildern, Team, Bewertungen und Buchung.",
|
||||||
|
cover: unsplash("1522337360788-8b13dee7a37e", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Hi, ich heirate im Juni und suche Brautstyling (Haare und Make-up) inklusive Probetermin – dazu Make-up für meine Mutter und zwei Brautjungfern. Was kostet das? Liebe Grüße, Julia",
|
||||||
|
topic: "Sommer-Special: 20 % auf Balayage im Juli",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "blush", label: "Blush", bg: "#f5ede6", fg: "#261c1a", muted: "#7d6b65", surface: "#ecdfd5", accent: "#c8664f" },
|
||||||
|
{ key: "noir", label: "Noir", dark: true, bg: "#0e0d0d", fg: "#f1ebe4", muted: "#8e857c", surface: "#1a1817", accent: "#e3c39d" },
|
||||||
|
{ key: "salbei", label: "Salbei", bg: "#eef0ea", fg: "#1f2a24", muted: "#66736b", surface: "#dfe4da", accent: "#5f7f6a" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "editorial", label: "Bodoni Moda & Jost" },
|
||||||
|
{ key: "expressiv", label: "Syne & DM Sans" },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
key: "brand",
|
||||||
|
label: "Marke",
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "tagline", label: "Untertitel" },
|
||||||
|
{ key: "instagram", label: "Instagram" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hero",
|
||||||
|
label: "Startbereich",
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "cta", label: "Button" },
|
||||||
|
{ key: "image", label: "Bild Mitte", type: "image" },
|
||||||
|
{ key: "imageLeft", label: "Bild links", type: "image" },
|
||||||
|
{ key: "imageRight", label: "Bild rechts", type: "image" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "intro",
|
||||||
|
label: "Leitsatz",
|
||||||
|
optional: true,
|
||||||
|
fields: [{ key: "text", label: "Text (leuchtet beim Scrollen auf)", type: "textarea" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "services",
|
||||||
|
label: "Leistungen & Preise",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
label: "Leistungen",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Leistung",
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "text", label: "Beschreibung" },
|
||||||
|
{ key: "duration", label: "Dauer" },
|
||||||
|
{ key: "price", label: "Preis" },
|
||||||
|
{ key: "image", label: "Bild (erscheint beim Darüberfahren)", type: "image" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gallery",
|
||||||
|
label: "Bildband",
|
||||||
|
optional: true,
|
||||||
|
fields: [{ key: "images", label: "Bilder", type: "list", itemLabel: "Bild", fields: [{ key: "src", label: "Bild", type: "image" }] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "team",
|
||||||
|
label: "Team",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{
|
||||||
|
key: "members",
|
||||||
|
label: "Personen",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Person",
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "role", label: "Rolle" },
|
||||||
|
{ key: "image", label: "Foto", type: "image" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reviews",
|
||||||
|
label: "Bewertungen",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
label: "Bewertungen",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Bewertung",
|
||||||
|
fields: [
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "author", label: "Von" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "booking",
|
||||||
|
label: "Buchung & Kontakt",
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "address", label: "Adresse" },
|
||||||
|
{ key: "phone", label: "Telefon" },
|
||||||
|
{ key: "hours", label: "Öffnungszeiten" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaults: {
|
||||||
|
brand: { name: "Maison Nove", tagline: "Hair & Beauty Studio", instagram: "@maisonnove" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Friseur · Kosmetik · Make-up",
|
||||||
|
title: "Schönheit,\n*neu* gedacht.",
|
||||||
|
text: "Individuelle Schnitte, natürliche Farben und Treatments, die sich anfühlen wie eine kleine Auszeit vom Alltag.",
|
||||||
|
cta: "Termin buchen",
|
||||||
|
image: unsplash("1522337360788-8b13dee7a37e", 1400),
|
||||||
|
imageLeft: unsplash("1562322140-8baeececf3df", 900),
|
||||||
|
imageRight: unsplash("1487412947147-5cebf100ffc2", 900),
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
text: "Wir glauben, dass gutes Aussehen mit einem guten Gefühl beginnt. Deshalb nehmen wir uns Zeit für jede Beratung, arbeiten mit hochwertigen, nachhaltigen Produkten und schaffen einen Ort, an dem Sie einfach ankommen dürfen.",
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
eyebrow: "Leistungen & Preise",
|
||||||
|
title: "Für jeden *Look.*",
|
||||||
|
items: [
|
||||||
|
{ name: "Schnitt & Styling", text: "Beratung, Waschen, Schnitt und Finish", duration: "60 Min", price: "ab 65 €", image: unsplash("1560066984-138dadb4c035", 600) },
|
||||||
|
{ name: "Coloration", text: "Ansatz, Glossing oder komplett neue Farbe", duration: "90 Min", price: "ab 89 €", image: unsplash("1580618672591-eb180b1a973f", 600) },
|
||||||
|
{ name: "Balayage", text: "Handgemalte Strähnen für natürliche Reflexe", duration: "180 Min", price: "ab 189 €", image: unsplash("1522337360788-8b13dee7a37e", 600) },
|
||||||
|
{ name: "Gesichtsbehandlung", text: "Tiefenreinigung, Maske und Massage", duration: "75 Min", price: "ab 95 €", image: unsplash("1570172619644-dfd03ed5d881", 600) },
|
||||||
|
{ name: "Make-up", text: "Für Hochzeiten, Shootings und besondere Anlässe", duration: "60 Min", price: "ab 79 €", image: unsplash("1487412947147-5cebf100ffc2", 600) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gallery: {
|
||||||
|
images: [
|
||||||
|
{ src: unsplash("1521590832167-7bcbfaa6381f", 900) },
|
||||||
|
{ src: unsplash("1562322140-8baeececf3df", 900) },
|
||||||
|
{ src: unsplash("1534528741775-53994a69daeb", 900) },
|
||||||
|
{ src: unsplash("1560066984-138dadb4c035", 900) },
|
||||||
|
{ src: unsplash("1570172619644-dfd03ed5d881", 900) },
|
||||||
|
{ src: unsplash("1580618672591-eb180b1a973f", 900) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
eyebrow: "Team",
|
||||||
|
title: "Die Menschen\nhinter *Nove.*",
|
||||||
|
members: [
|
||||||
|
{ name: "Sophie", role: "Gründerin & Creative Director", image: unsplash("1595476108010-b4d1f102b1b1", 900) },
|
||||||
|
{ name: "Lea", role: "Coloristin", image: unsplash("1580489944761-15a19d654956", 900) },
|
||||||
|
{ name: "Mara", role: "Kosmetikerin", image: unsplash("1494790108377-be9c29b29330", 900) },
|
||||||
|
{ name: "Jonas", role: "Stylist", image: unsplash("1500648767791-00dcc994a43e", 900) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
items: [
|
||||||
|
{ text: "Ich war noch nie so glücklich mit meiner Haarfarbe. Lea hat genau verstanden, was ich wollte – und es noch besser gemacht.", author: "Anna K." },
|
||||||
|
{ text: "Ein Salon, in dem man sich sofort wohlfühlt. Die Beratung ist ehrlich und das Ergebnis jedes Mal perfekt.", author: "Miriam S." },
|
||||||
|
{ text: "Die Gesichtsbehandlung war pure Entspannung. Den nächsten Termin habe ich direkt vor Ort gebucht.", author: "Laura B." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
booking: {
|
||||||
|
title: "Bereit für Ihren\n*neuen* Look?",
|
||||||
|
text: "Wählen Sie Ihre Wunschleistung und einen Termin – wir melden uns zur Bestätigung.",
|
||||||
|
address: "Lindenstraße 21, 40233 Düsseldorf",
|
||||||
|
phone: "0211 123 45 67",
|
||||||
|
hours: "Di – Fr 10 – 19 Uhr · Sa 9 – 15 Uhr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type BeautyData = typeof beauty.defaults
|
||||||
21
modules/templates/catalog/beauty/fonts.ts
Normal file
21
modules/templates/catalog/beauty/fonts.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Bodoni_Moda, DM_Sans, Jost, Syne } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts
|
||||||
|
const bodoni = Bodoni_Moda({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-bodoni", preload: false })
|
||||||
|
const jost = Jost({ subsets: ["latin"], variable: "--font-jost", preload: false })
|
||||||
|
const syne = Syne({ subsets: ["latin"], variable: "--font-syne", preload: false })
|
||||||
|
const dmSans = DM_Sans({ subsets: ["latin"], variable: "--font-dm-sans", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
editorial: {
|
||||||
|
className: `${bodoni.variable} ${jost.variable}`,
|
||||||
|
display: "var(--font-bodoni)",
|
||||||
|
body: "var(--font-jost)",
|
||||||
|
},
|
||||||
|
expressiv: {
|
||||||
|
className: `${syne.variable} ${dmSans.variable}`,
|
||||||
|
display: "var(--font-syne)",
|
||||||
|
body: "var(--font-dm-sans)",
|
||||||
|
},
|
||||||
|
}
|
||||||
418
modules/templates/catalog/beauty/template.tsx
Normal file
418
modules/templates/catalog/beauty/template.tsx
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion, useMotionValue, useScroll, useSpring, useTransform } from "motion/react"
|
||||||
|
import { useEffect, useId, useRef, useState, type InputHTMLAttributes } from "react"
|
||||||
|
import { ArrowUpRightIcon, StarIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { DemoForm } from "../shared/demo-form"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { Marquee, Reveal, RevealWords, ScrollWords, ease, useScrollNav } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { beauty, type BeautyData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "italic text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10"
|
||||||
|
const eyebrow = "mb-6 text-xs uppercase tracking-[0.3em] text-t-muted"
|
||||||
|
const pill = "inline-flex items-center gap-2 rounded-full bg-t-fg px-6 py-3.5 text-sm text-t-bg transition-transform duration-300 hover:scale-[1.04]"
|
||||||
|
|
||||||
|
export function BeautyTemplate({ content }: TemplateProps<BeautyData>) {
|
||||||
|
const { brand, hero, intro, services, gallery, team, reviews, booking } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const links = [
|
||||||
|
{ href: "#leistungen", label: "Leistungen", visible: show("services") },
|
||||||
|
{ href: "#team", label: "Team", visible: show("team") },
|
||||||
|
{ href: "#stimmen", label: "Stimmen", visible: show("reviews") },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={beauty} content={content} fonts={fonts[content.fonts] ?? fonts.editorial}>
|
||||||
|
<Nav name={brand.name} cta={hero.cta} links={links} />
|
||||||
|
<Hero hero={hero} />
|
||||||
|
|
||||||
|
{show("intro") && (
|
||||||
|
<section className="mx-auto max-w-[1200px] px-5 py-24 md:px-10 md:py-40">
|
||||||
|
<ScrollWords
|
||||||
|
text={intro.text}
|
||||||
|
className="text-center font-t-display text-[clamp(1.9rem,4.3vw,4rem)] leading-[1.14] tracking-[-0.015em]"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("services") && <Services services={services} />}
|
||||||
|
|
||||||
|
{show("gallery") && (
|
||||||
|
<section aria-label="Bildband" className="py-10 md:py-16">
|
||||||
|
<Marquee duration={55}>
|
||||||
|
{gallery.images.map((image, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"relative mx-2 h-[40vh] w-[29vh] shrink-0 overflow-hidden md:mx-3 md:h-[54vh] md:w-[39vh]",
|
||||||
|
i % 2 === 0 ? "rounded-t-full" : "rounded-[28px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TImage src={image.src} sizes="40vh" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("team") && (
|
||||||
|
<section id="team" className={cn(container, "py-24 md:py-40")}>
|
||||||
|
<Reveal><p className={eyebrow}>{team.eyebrow}</p></Reveal>
|
||||||
|
<RevealWords text={team.title} emClassName={em} className="font-t-display text-[clamp(2.75rem,6vw,5.75rem)] leading-[0.95] tracking-[-0.025em]" />
|
||||||
|
<div className="mt-16 grid grid-cols-2 gap-x-4 gap-y-12 md:grid-cols-4 md:gap-x-6">
|
||||||
|
{team.members.map((member, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.08} className={cn(i % 2 === 1 && "md:mt-20")}>
|
||||||
|
<div className="group">
|
||||||
|
<div className="relative aspect-[3/4] overflow-hidden rounded-t-full bg-t-surface">
|
||||||
|
<TImage
|
||||||
|
src={member.image}
|
||||||
|
alt={member.name}
|
||||||
|
sizes="(min-width: 768px) 25vw, 50vw"
|
||||||
|
className="grayscale transition duration-700 group-hover:scale-105 group-hover:grayscale-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-5 font-t-display text-2xl md:text-3xl">{member.name}</p>
|
||||||
|
<p className="mt-1 text-sm text-t-muted">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("reviews") && <Reviews reviews={reviews} />}
|
||||||
|
|
||||||
|
<section id="termin" className="p-3 md:p-6">
|
||||||
|
<div className="relative overflow-hidden rounded-[32px] bg-t-accent px-5 py-20 text-t-accent-fg md:rounded-[56px] md:px-16 md:py-28">
|
||||||
|
<div aria-hidden className="absolute -top-48 -right-48 size-[36rem] rounded-full border border-current opacity-20" />
|
||||||
|
<div aria-hidden className="absolute -top-28 -right-28 size-[36rem] rounded-full border border-current opacity-15" />
|
||||||
|
<div className="relative grid gap-14 md:grid-cols-2 md:gap-20">
|
||||||
|
<div>
|
||||||
|
<RevealWords text={booking.title} emClassName="italic" className="font-t-display text-[clamp(2.75rem,6vw,5.5rem)] leading-[0.95] tracking-[-0.025em]" />
|
||||||
|
<Reveal delay={0.15}>
|
||||||
|
<p className="mt-8 max-w-md text-lg leading-relaxed opacity-80">{booking.text}</p>
|
||||||
|
<dl className="mt-12 grid gap-6 text-sm">
|
||||||
|
{[
|
||||||
|
["Adresse", booking.address],
|
||||||
|
["Telefon", booking.phone],
|
||||||
|
["Öffnungszeiten", booking.hours],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<dt className="text-xs uppercase tracking-[0.25em] opacity-60">{label}</dt>
|
||||||
|
<dd className="mt-1 text-base">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<DemoForm
|
||||||
|
className="rounded-[28px] bg-t-bg p-6 text-t-fg md:p-10"
|
||||||
|
success={
|
||||||
|
<div className="py-10 text-center">
|
||||||
|
<p className="font-t-display text-5xl italic text-t-accent">Wir freuen uns!</p>
|
||||||
|
<p className="mt-4 text-t-muted">Wir bestätigen Ihren Termin in Kürze per SMS oder Anruf.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="font-t-display text-3xl">Termin anfragen</p>
|
||||||
|
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
<Soft label="Name" name="name" autoComplete="name" required className="sm:col-span-2" />
|
||||||
|
<Soft label="Telefon" name="phone" type="tel" autoComplete="tel" required />
|
||||||
|
<Soft label="Wunschtermin" name="date" type="date" />
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="px-4 text-xs uppercase tracking-[0.2em] text-t-muted">Leistung</span>
|
||||||
|
<select name="service" className="mt-1.5 w-full rounded-full border border-t-fg/15 bg-t-surface px-5 py-3.5 outline-none focus:border-t-accent">
|
||||||
|
{services.items.map((item, i) => (
|
||||||
|
<option key={i}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className={cn(pill, "mt-8 w-full cursor-pointer justify-center py-4")}>
|
||||||
|
Termin anfragen <ArrowUpRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</DemoForm>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className={cn(container, "flex flex-col gap-8 py-14 text-sm md:flex-row md:items-end md:justify-between")}>
|
||||||
|
<div>
|
||||||
|
<p className="font-t-display text-4xl tracking-tight">{brand.name}</p>
|
||||||
|
<p className="mt-1 text-t-muted">{brand.tagline}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-2 text-t-muted">
|
||||||
|
<a href="#" className="hover:text-t-fg">Instagram {brand.instagram}</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
animate={{ y: hidden ? "-120%" : "0%" }}
|
||||||
|
transition={{ duration: 0.45, ease }}
|
||||||
|
className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:px-6 md:pt-5"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
"mx-auto grid h-16 max-w-[1440px] grid-cols-[1fr_auto_1fr] items-center rounded-full px-4 transition-colors duration-500 md:px-6",
|
||||||
|
scrolled ? "bg-t-bg/80 shadow-[0_8px_30px_-12px_rgba(0,0,0,0.25)] backdrop-blur-xl" : "bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="hidden gap-8 text-sm md:flex">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-fg">
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a href="#top" className="col-start-1 justify-self-start font-t-display text-2xl tracking-tight md:col-start-2 md:justify-self-center md:text-3xl">
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
<a href="#termin" className={cn(pill, "col-start-3 justify-self-end px-5 py-2.5")}>{cta}</a>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hero({ hero }: { hero: BeautyData["hero"] }) {
|
||||||
|
const ref = useRef<HTMLElement>(null)
|
||||||
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
|
||||||
|
const left = useTransform(scrollYProgress, [0, 1], [0, -160])
|
||||||
|
const middle = useTransform(scrollYProgress, [0, 1], [0, -60])
|
||||||
|
const right = useTransform(scrollYProgress, [0, 1], [0, -240])
|
||||||
|
const reveal = (delay: number) => ({
|
||||||
|
initial: { opacity: 0, y: 80, scale: 0.94 },
|
||||||
|
animate: { opacity: 1, y: 0, scale: 1 },
|
||||||
|
transition: { duration: 1.3, ease, delay },
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} id="top" className="relative overflow-hidden pt-32 pb-16 md:pt-40 md:pb-24">
|
||||||
|
<div className={container}>
|
||||||
|
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.2 }} className={cn(eyebrow, "text-center")}>
|
||||||
|
{hero.eyebrow}
|
||||||
|
</motion.p>
|
||||||
|
<RevealWords
|
||||||
|
as="h1"
|
||||||
|
onMount
|
||||||
|
delay={0.3}
|
||||||
|
text={hero.title}
|
||||||
|
emClassName={em}
|
||||||
|
className="text-center font-t-display text-[clamp(3.6rem,12.5vw,13rem)] leading-[0.86] tracking-[-0.04em]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mt-14 grid grid-cols-12 items-start gap-3 md:mt-20 md:gap-6">
|
||||||
|
<motion.div style={{ y: left }} className="col-span-5 mt-20 md:col-span-3 md:mt-32">
|
||||||
|
<motion.div {...reveal(0.7)} className="relative aspect-[3/4] overflow-hidden rounded-t-full">
|
||||||
|
<TImage src={hero.imageLeft} sizes="(min-width: 768px) 25vw, 40vw" eager />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 1, ease, delay: 1.2 }}
|
||||||
|
className="mt-8 hidden md:block"
|
||||||
|
>
|
||||||
|
<p className="text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div style={{ y: middle }} className="relative col-span-7 md:col-span-5 md:col-start-5">
|
||||||
|
<motion.div {...reveal(0.5)} className="relative aspect-[4/5] overflow-hidden rounded-[32px] md:rounded-[48px]">
|
||||||
|
<TImage src={hero.image} sizes="(min-width: 768px) 42vw, 60vw" eager />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0, rotate: -90 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ type: "spring", bounce: 0.35, duration: 1.2, delay: 1.3 }}
|
||||||
|
className="absolute -bottom-10 -left-6 md:-left-16"
|
||||||
|
>
|
||||||
|
<RotatingBadge text={hero.cta} href="#termin" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div style={{ y: right }} className="hidden md:col-span-3 md:col-start-10 md:mt-48 md:block">
|
||||||
|
<motion.div {...reveal(0.9)} className="relative aspect-[3/4] overflow-hidden rounded-[28px]">
|
||||||
|
<TImage src={hero.imageRight} sizes="25vw" eager />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-24 text-center text-lg leading-relaxed text-t-muted md:hidden">{hero.text}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RotatingBadge({ text, href }: { text: string; href: string }) {
|
||||||
|
const pathId = `badge-${useId().replace(/[^a-zA-Z0-9]/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={href} className="group relative grid size-28 place-items-center rounded-full bg-t-accent text-t-accent-fg md:size-36">
|
||||||
|
<motion.svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 size-full"
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 16, ease: "linear", repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<path id={pathId} d="M50,50 m-36,0 a36,36 0 1,1 72,0 a36,36 0 1,1 -72,0" />
|
||||||
|
</defs>
|
||||||
|
<text className="fill-current text-[9.5px] uppercase" style={{ letterSpacing: "0.18em" }}>
|
||||||
|
<textPath href={`#${pathId}`} textLength="222" lengthAdjust="spacing">
|
||||||
|
{`${text} ✦ ${text} ✦ `}
|
||||||
|
</textPath>
|
||||||
|
</text>
|
||||||
|
</motion.svg>
|
||||||
|
<span className="sr-only">{text}</span>
|
||||||
|
<ArrowUpRightIcon className="size-7 transition-transform duration-500 group-hover:rotate-45" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Services({ services }: { services: BeautyData["services"] }) {
|
||||||
|
const areaRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [active, setActive] = useState<number | null>(null)
|
||||||
|
const x = useMotionValue(0)
|
||||||
|
const y = useMotionValue(0)
|
||||||
|
const springX = useSpring(x, { stiffness: 260, damping: 28 })
|
||||||
|
const springY = useSpring(y, { stiffness: 260, damping: 28 })
|
||||||
|
const activeItem = active === null ? undefined : services.items[active]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="leistungen" className={cn(container, "py-24 md:py-36")}>
|
||||||
|
<Reveal><p className={eyebrow}>{services.eyebrow}</p></Reveal>
|
||||||
|
<RevealWords text={services.title} emClassName={em} className="font-t-display text-[clamp(2.75rem,6vw,5.75rem)] leading-[0.95] tracking-[-0.025em]" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={areaRef}
|
||||||
|
className="relative mt-14 md:mt-20"
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
const rect = areaRef.current?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
x.set(event.clientX - rect.left)
|
||||||
|
y.set(event.clientY - rect.top)
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => setActive(null)}
|
||||||
|
>
|
||||||
|
<ul className="border-t border-t-fg/15">
|
||||||
|
{services.items.map((item, i) => (
|
||||||
|
<motion.li
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
||||||
|
transition={{ duration: 0.8, ease, delay: i * 0.06 }}
|
||||||
|
onPointerEnter={() => setActive(i)}
|
||||||
|
className="group grid grid-cols-12 items-baseline gap-x-4 gap-y-2 border-b border-t-fg/15 py-7 transition-[padding] duration-500 md:py-9 md:hover:pl-6"
|
||||||
|
>
|
||||||
|
<span className="col-span-12 font-t-display text-3xl tracking-tight transition-colors duration-300 group-hover:text-t-accent md:col-span-5 md:text-5xl">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="col-span-12 text-t-muted md:col-span-4">{item.text}</span>
|
||||||
|
<span className="col-span-6 text-sm text-t-muted md:col-span-1">{item.duration}</span>
|
||||||
|
<span className="col-span-6 text-right font-t-display text-2xl md:col-span-2">{item.price}</span>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<motion.div aria-hidden style={{ x: springX, y: springY }} className="pointer-events-none absolute top-0 left-0 z-10 hidden md:block">
|
||||||
|
<AnimatePresence>
|
||||||
|
{activeItem && (
|
||||||
|
<motion.div
|
||||||
|
key={active}
|
||||||
|
initial={{ opacity: 0, scale: 0.7, rotate: -8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.7, rotate: 8 }}
|
||||||
|
transition={{ duration: 0.4, ease }}
|
||||||
|
className="relative -mt-[160px] -ml-[120px] h-[320px] w-[240px] overflow-hidden rounded-t-full rounded-b-[28px] shadow-2xl"
|
||||||
|
>
|
||||||
|
<TImage src={activeItem.image} sizes="240px" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reviews({ reviews }: { reviews: BeautyData["reviews"] }) {
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
const count = reviews.items.length
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (count < 2) return
|
||||||
|
const timer = setInterval(() => setIndex((i) => (i + 1) % count), 6500)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [count])
|
||||||
|
|
||||||
|
if (count === 0) return null
|
||||||
|
const current = index % count
|
||||||
|
const review = reviews.items[current]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="stimmen" className="bg-t-surface py-24 md:py-36">
|
||||||
|
<div className="mx-auto max-w-[1000px] px-5 text-center">
|
||||||
|
<div className="flex justify-center gap-1.5 text-t-accent">
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<StarIcon key={i} className="size-5 fill-current" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 grid min-h-[15rem] items-center md:min-h-[13rem]">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.blockquote
|
||||||
|
key={current}
|
||||||
|
initial={{ opacity: 0, y: 24, filter: "blur(8px)" }}
|
||||||
|
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||||
|
exit={{ opacity: 0, y: -24, filter: "blur(8px)" }}
|
||||||
|
transition={{ duration: 0.7, ease }}
|
||||||
|
>
|
||||||
|
<p className="font-t-display text-[clamp(1.6rem,3.4vw,2.9rem)] leading-[1.2] tracking-[-0.01em]">„{review.text}“</p>
|
||||||
|
<footer className="mt-8 text-xs uppercase tracking-[0.3em] text-t-muted">{review.author}</footer>
|
||||||
|
</motion.blockquote>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 flex justify-center gap-2">
|
||||||
|
{reviews.items.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Bewertung ${i + 1} anzeigen`}
|
||||||
|
aria-current={i === current}
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
className={cn("h-1.5 cursor-pointer rounded-full transition-all duration-500", i === current ? "w-10 bg-t-accent" : "w-1.5 bg-t-fg/25")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Soft({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
||||||
|
return (
|
||||||
|
<label className={cn("block", className)}>
|
||||||
|
<span className="px-4 text-xs uppercase tracking-[0.2em] text-t-muted">{label}</span>
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
className="mt-1.5 w-full rounded-full border border-t-fg/15 bg-t-surface px-5 py-3.5 outline-none transition-colors focus:border-t-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
modules/templates/catalog/coach/definition.ts
Normal file
115
modules/templates/catalog/coach/definition.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
brand: { name: "Lena Hoffmann", role: "Coach für Führungskräfte & Speakerin", email: "hallo@lena-hoffmann.de" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Leadership Coaching",
|
||||||
|
title: "Führen, ohne sich\nselbst zu *verlieren.*",
|
||||||
|
text: "Ich begleite Führungskräfte dabei, klar zu entscheiden, gesund zu bleiben und Teams zu bauen, die gern Verantwortung übernehmen.",
|
||||||
|
cta: "Kostenloses Erstgespräch",
|
||||||
|
ctaSecondary: "Meine Angebote",
|
||||||
|
image: unsplash("1580894732444-8ecded7900cd", 1400),
|
||||||
|
quote: "Klarheit ist die freundlichste Form von Führung.",
|
||||||
|
badge: "300+ Führungskräfte begleitet",
|
||||||
|
},
|
||||||
|
press: { title: "Bekannt aus", items: [{ name: "Wirtschaftsforum" }, { name: "Leadership Talk" }, { name: "New Work Weekly" }, { name: "Führung heute" }, { name: "Podcast Kopfsache" }] },
|
||||||
|
pains: {
|
||||||
|
eyebrow: "Kennst du das?",
|
||||||
|
title: "Du gibst alles –\nund *trotzdem* fehlt etwas.",
|
||||||
|
items: [
|
||||||
|
{ text: "Dein Kalender gehört allen, nur nicht dir." },
|
||||||
|
{ text: "Entscheidungen kosten dich nachts den Schlaf." },
|
||||||
|
{ text: "Dein Team wartet auf dich, statt selbst zu handeln." },
|
||||||
|
{ text: "Du fragst dich, wie lange du das noch durchhältst." },
|
||||||
|
],
|
||||||
|
closing: "Dann lass uns reden.",
|
||||||
|
},
|
||||||
|
story: {
|
||||||
|
eyebrow: "Über mich",
|
||||||
|
title: "Von der Überholspur\nzur *eigenen* Spur.",
|
||||||
|
text: "Fünfzehn Jahre war ich Führungskraft in der Beratung – erfolgreich, erschöpft und irgendwann leer. Heute helfe ich Menschen in Verantwortung, ihren eigenen Stil zu finden: klar, menschlich und nachhaltig.",
|
||||||
|
image: unsplash("1573497019940-1c28c88b4f3e", 1200),
|
||||||
|
milestones: [
|
||||||
|
{ year: "2009", text: "Start als Beraterin in einer großen Unternehmensberatung" },
|
||||||
|
{ year: "2015", text: "Bereichsleitung mit 120 Mitarbeitenden" },
|
||||||
|
{ year: "2018", text: "Burnout – und der Anfang von etwas Neuem" },
|
||||||
|
{ year: "2019", text: "Ausbildung zur systemischen Coachin" },
|
||||||
|
{ year: "2026", text: "Über 300 begleitete Führungskräfte" },
|
||||||
|
],
|
||||||
|
signature: "Lena",
|
||||||
|
},
|
||||||
|
offers: {
|
||||||
|
eyebrow: "Angebote",
|
||||||
|
title: "Wie ich dich *begleite*",
|
||||||
|
items: [
|
||||||
|
{ title: "1:1 Coaching", text: "Individuelle Sessions für deine konkreten Führungsfragen – online oder in Hamburg.", price: "ab 290 € pro Session", cta: "Erstgespräch buchen", highlight: "" },
|
||||||
|
{ title: "Leadership-Programm", text: "Zwölf Wochen, die deinen Führungsalltag verändern: Coaching, Übungen und eine starke Peer-Gruppe.", price: "ab 2.900 €", cta: "Mehr erfahren", highlight: "ja" },
|
||||||
|
{ title: "Keynotes & Workshops", text: "Impulse für Teams und Konferenzen – ehrlich, nahbar und mit Humor.", price: "auf Anfrage", cta: "Anfragen", highlight: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
results: {
|
||||||
|
eyebrow: "Stimmen",
|
||||||
|
title: "Was sich *verändert*",
|
||||||
|
items: [
|
||||||
|
{ quote: "Ich führe heute mit halb so vielen Meetings und doppelt so viel Vertrauen. Mein Team blüht auf – und ich auch.", author: "Markus T.", role: "Geschäftsführer, Maschinenbau", image: unsplash("1560250097-0b93528c311a", 400) },
|
||||||
|
{ quote: "Lena stellt die Fragen, die sonst niemand stellt. Das war unbequem und das Beste, was mir beruflich passiert ist.", author: "Anna S.", role: "Head of Marketing", image: unsplash("1508214751196-bcfd4ca60f91", 400) },
|
||||||
|
{ quote: "Nach dem Programm habe ich endlich wieder Feierabend – ohne schlechtes Gewissen.", author: "Julia M.", role: "Gründerin & CEO", image: unsplash("1544005313-94ddf0286df2", 400) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
podcast: {
|
||||||
|
eyebrow: "Podcast",
|
||||||
|
title: "Klar führen –\n*der Podcast*",
|
||||||
|
items: [
|
||||||
|
{ number: "112", title: "Warum gute Chefs Nein sagen", duration: "34 Min" },
|
||||||
|
{ number: "111", title: "Feedback, das wirklich ankommt", duration: "28 Min" },
|
||||||
|
{ number: "110", title: "Der Mythos Work-Life-Balance", duration: "41 Min" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
leadmagnet: {
|
||||||
|
title: "Gratis: Der *Klarheits*-Kompass",
|
||||||
|
text: "Mein Workbook mit sieben Übungen für mehr Fokus im Führungsalltag – direkt in dein Postfach.",
|
||||||
|
book: "Der Klarheits-Kompass",
|
||||||
|
cta: "Workbook sichern",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: "Lass uns *sprechen.*",
|
||||||
|
text: "30 Minuten, kostenlos und unverbindlich. Wir schauen gemeinsam, wo du stehst und was dich weiterbringt.",
|
||||||
|
cta: "Erstgespräch buchen",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const coach = defineTemplate({
|
||||||
|
key: "coach",
|
||||||
|
name: "Persona",
|
||||||
|
industry: "Personal Brand & Coach",
|
||||||
|
description: "Persönlich und editorial: Porträt in lebendiger Form, handgezeichnete Hervorhebungen, Story als Zeitleiste, Angebote, Podcast und Lead-Magnet mit 3D-Buch.",
|
||||||
|
cover: unsplash("1580894732444-8ecded7900cd", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Hallo Frau Hoffmann, ich leite seit einem halben Jahr ein Team von 15 Personen und merke, dass ich mich völlig verausgabe. Wie läuft ein Coaching bei Ihnen ab, und was kostet es? Viele Grüße, Stefan Roth",
|
||||||
|
topic: "Drei Fragen, die sich jede Führungskraft montags stellen sollte",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "creme", label: "Creme", bg: "#f8f3ec", fg: "#2b2420", muted: "#776a60", surface: "#efe5d8", accent: "#d1603d" },
|
||||||
|
{ key: "salbei", label: "Salbei", bg: "#eef1ea", fg: "#202a22", muted: "#647066", surface: "#dfe5d9", accent: "#4f7a5a" },
|
||||||
|
{ key: "nacht", label: "Nacht", dark: true, bg: "#121014", fg: "#f3eee8", muted: "#9c948c", surface: "#1d1a1f", accent: "#e8b86d" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "editorial", label: "Gloock & Hanken Grotesk" },
|
||||||
|
{ key: "warm", label: "Young Serif & Familjen Grotesk" },
|
||||||
|
],
|
||||||
|
sections: describeSections(defaults, {
|
||||||
|
brand: "Person",
|
||||||
|
hero: "Startbereich",
|
||||||
|
press: { label: "Bekannt aus", optional: true, items: { items: "Medium" } },
|
||||||
|
pains: { label: "Kennst du das?", optional: true, items: { items: "Punkt" } },
|
||||||
|
story: { label: "Über mich", optional: true, items: { milestones: "Meilenstein" } },
|
||||||
|
offers: { label: "Angebote", optional: true, items: { items: "Angebot" } },
|
||||||
|
results: { label: "Stimmen", optional: true, items: { items: "Stimme" } },
|
||||||
|
podcast: { label: "Podcast", optional: true, items: { items: "Folge" } },
|
||||||
|
leadmagnet: { label: "Lead-Magnet", optional: true },
|
||||||
|
contact: "Kontakt",
|
||||||
|
}),
|
||||||
|
defaults,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CoachData = typeof coach.defaults
|
||||||
16
modules/templates/catalog/coach/fonts.ts
Normal file
16
modules/templates/catalog/coach/fonts.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Caveat, Familjen_Grotesk, Gloock, Hanken_Grotesk, Young_Serif } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts. Caveat nur für die handschriftliche Unterschrift.
|
||||||
|
const gloock = Gloock({ subsets: ["latin"], weight: "400", variable: "--font-coach-gloock", preload: false })
|
||||||
|
const hanken = Hanken_Grotesk({ subsets: ["latin"], variable: "--font-coach-hanken", preload: false })
|
||||||
|
const youngSerif = Young_Serif({ subsets: ["latin"], weight: "400", variable: "--font-coach-young", preload: false })
|
||||||
|
const familjen = Familjen_Grotesk({ subsets: ["latin"], variable: "--font-coach-familjen", preload: false })
|
||||||
|
const caveat = Caveat({ subsets: ["latin"], variable: "--font-coach-caveat", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
editorial: { className: `${gloock.variable} ${hanken.variable} ${caveat.variable}`, display: "var(--font-coach-gloock)", body: "var(--font-coach-hanken)" },
|
||||||
|
warm: { className: `${youngSerif.variable} ${familjen.variable} ${caveat.variable}`, display: "var(--font-coach-young)", body: "var(--font-coach-familjen)" },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const script = { fontFamily: "var(--font-coach-caveat), cursive" }
|
||||||
412
modules/templates/catalog/coach/template.tsx
Normal file
412
modules/templates/catalog/coach/template.tsx
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { motion, useScroll } from "motion/react"
|
||||||
|
import { Fragment, useRef, useState } from "react"
|
||||||
|
import { ArrowDownRightIcon, ArrowRightIcon, CheckIcon, PauseIcon, PlayIcon, QuoteIcon, XIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { DemoForm } from "../shared/demo-form"
|
||||||
|
import { TField } from "../shared/fields"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { Reveal, RevealWords, ease, parseRich, useScrollNav } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { coach, type CoachData } from "./definition"
|
||||||
|
import { fonts, script } from "./fonts"
|
||||||
|
|
||||||
|
const em = "italic text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1280px] px-5 md:px-8"
|
||||||
|
const heading = "font-t-display font-normal tracking-[-0.02em]"
|
||||||
|
|
||||||
|
function Eyebrow({ children }: { children: string }) {
|
||||||
|
return <p className="mb-5 text-sm font-semibold uppercase tracking-[0.2em] text-t-accent">{children}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wörter gleiten herein, hervorgehobene Wörter bekommen eine handgezeichnete Linie
|
||||||
|
function MarkedTitle({ text }: { text: string }) {
|
||||||
|
const lines: { word: string; em: boolean; index: number }[][] = []
|
||||||
|
let count = 0
|
||||||
|
for (const tokens of parseRich(text)) {
|
||||||
|
const line = []
|
||||||
|
for (const token of tokens) {
|
||||||
|
line.push({ ...token, index: count })
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h1 className={cn(heading, "text-[clamp(2.5rem,4.8vw,4.25rem)] leading-[1.05]")} aria-label={text.replace(/\*/g, "").replace(/\n/g, " ")}>
|
||||||
|
{lines.map((tokens, l) => (
|
||||||
|
<span key={l} aria-hidden className="block">
|
||||||
|
{tokens.map((token, t) => (
|
||||||
|
<Fragment key={t}>
|
||||||
|
{t > 0 && " "}
|
||||||
|
<span className="relative inline-block">
|
||||||
|
<span className="mb-[-0.12em] inline-block overflow-hidden pb-[0.12em] align-top">
|
||||||
|
<motion.span
|
||||||
|
className={cn("inline-block", token.em && em)}
|
||||||
|
initial={{ y: "105%" }}
|
||||||
|
animate={{ y: "0%" }}
|
||||||
|
transition={{ duration: 1, ease, delay: 0.2 + token.index * 0.06 }}
|
||||||
|
>
|
||||||
|
{token.word}
|
||||||
|
</motion.span>
|
||||||
|
</span>
|
||||||
|
{token.em && (
|
||||||
|
<svg viewBox="0 0 200 20" preserveAspectRatio="none" aria-hidden className="absolute -bottom-[0.1em] left-0 h-[0.3em] w-full overflow-visible text-t-accent">
|
||||||
|
<motion.path
|
||||||
|
d="M3 14 C 40 5, 90 4, 120 10 S 175 17, 197 7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.9, ease, delay: 1.1 + token.index * 0.05 }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</h1>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoachTemplate({ content }: TemplateProps<CoachData>) {
|
||||||
|
const { brand, hero, press, pains, story, offers, results, podcast, leadmagnet, contact } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const links = [
|
||||||
|
{ href: "#ueber-mich", label: "Über mich", visible: show("story") },
|
||||||
|
{ href: "#angebote", label: "Angebote", visible: show("offers") },
|
||||||
|
{ href: "#podcast", label: "Podcast", visible: show("podcast") },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={coach} content={content} fonts={fonts[content.fonts] ?? fonts.editorial}>
|
||||||
|
<Nav name={brand.name} cta={contact.cta} links={links} />
|
||||||
|
|
||||||
|
<section id="top" className={cn(container, "grid items-center gap-14 pt-32 pb-16 md:grid-cols-[1.1fr_1fr] md:pt-40 md:pb-24")}>
|
||||||
|
<div>
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }}>
|
||||||
|
<Eyebrow>{hero.eyebrow}</Eyebrow>
|
||||||
|
</motion.div>
|
||||||
|
<MarkedTitle text={hero.title} />
|
||||||
|
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.7 }}>
|
||||||
|
<p className="mt-8 max-w-lg text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
||||||
|
<div className="mt-9 flex flex-wrap gap-3">
|
||||||
|
<a href="#kontakt" className="inline-flex items-center gap-2 rounded-full bg-t-accent px-7 py-4 font-semibold text-t-accent-fg transition-transform hover:-translate-y-0.5">
|
||||||
|
{hero.cta} <ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
{show("offers") && (
|
||||||
|
<a href="#angebote" className="rounded-full px-7 py-4 font-semibold ring-1 ring-t-fg/20 transition-colors hover:bg-t-surface">{hero.ctaSecondary}</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto w-full max-w-[480px]">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
borderRadius: ["58% 42% 38% 62% / 52% 38% 62% 48%", "42% 58% 62% 38% / 38% 56% 44% 62%", "58% 42% 38% 62% / 52% 38% 62% 48%"],
|
||||||
|
}}
|
||||||
|
transition={{ opacity: { duration: 1 }, scale: { duration: 1.2, ease }, borderRadius: { duration: 14, repeat: Infinity, ease: "easeInOut" } }}
|
||||||
|
className="relative aspect-[4/5] overflow-hidden bg-t-surface"
|
||||||
|
>
|
||||||
|
<TImage src={hero.image} eager sizes="(min-width: 768px) 40vw, 90vw" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.figure
|
||||||
|
initial={{ opacity: 0, y: 20, rotate: -6 }}
|
||||||
|
animate={{ opacity: 1, y: 0, rotate: -3 }}
|
||||||
|
transition={{ duration: 0.9, ease, delay: 1 }}
|
||||||
|
className="absolute bottom-8 -left-4 max-w-60 rounded-2xl bg-t-bg p-5 shadow-xl md:-left-12"
|
||||||
|
>
|
||||||
|
<QuoteIcon className="size-5 text-t-accent" />
|
||||||
|
<blockquote className={cn(heading, "mt-2 text-lg leading-snug")}>{hero.quote}</blockquote>
|
||||||
|
</motion.figure>
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, scale: 0.6 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ type: "spring", bounce: 0.4, delay: 1.3 }}
|
||||||
|
className="absolute top-10 -right-2 rounded-full bg-t-accent px-4 py-2 text-sm font-semibold text-t-accent-fg shadow-lg md:-right-6"
|
||||||
|
>
|
||||||
|
{hero.badge}
|
||||||
|
</motion.span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("press") && (
|
||||||
|
<section className="border-y border-t-fg/10 py-8">
|
||||||
|
<div className={cn(container, "flex flex-col items-center gap-6 md:flex-row md:justify-between")}>
|
||||||
|
<p className="text-sm text-t-muted">{press.title}</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-10 gap-y-3">
|
||||||
|
{press.items.map((item, i) => (
|
||||||
|
<span key={i} className={cn(heading, "text-xl text-t-fg/55 italic")}>{item.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("pains") && (
|
||||||
|
<section className={cn(container, "grid gap-12 py-24 md:grid-cols-2 md:py-32")}>
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{pains.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={pains.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{pains.items.map((item, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.12}>
|
||||||
|
<li className="flex items-start gap-4 rounded-2xl bg-t-surface p-5 text-lg">
|
||||||
|
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-t-bg text-t-accent">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</span>
|
||||||
|
{item.text}
|
||||||
|
</li>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Reveal delay={0.5}>
|
||||||
|
<a href="#kontakt" className="mt-8 inline-flex items-center gap-3 text-4xl text-t-accent" style={script}>
|
||||||
|
{pains.closing} <ArrowDownRightIcon className="size-7" />
|
||||||
|
</a>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("story") && <Story story={story} />}
|
||||||
|
|
||||||
|
{show("offers") && (
|
||||||
|
<section id="angebote" className={cn(container, "py-24 md:py-32")}>
|
||||||
|
<Reveal><Eyebrow>{offers.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={offers.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||||
|
<div className="mt-14 grid gap-5 lg:grid-cols-3">
|
||||||
|
{offers.items.map((offer, i) => {
|
||||||
|
const featured = Boolean(offer.highlight.trim())
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
|
<article className={cn("flex h-full flex-col rounded-[2rem] p-8 transition-transform duration-500 hover:-translate-y-1.5", featured ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
|
||||||
|
<span className={cn(heading, "text-6xl opacity-30")}>0{i + 1}</span>
|
||||||
|
<h3 className={cn(heading, "mt-4 text-3xl")}>{offer.title}</h3>
|
||||||
|
<p className={cn("mt-3 flex-1 leading-relaxed", featured ? "opacity-85" : "text-t-muted")}>{offer.text}</p>
|
||||||
|
<p className="mt-8 font-semibold">{offer.price}</p>
|
||||||
|
<a href="#kontakt" className={cn("mt-5 inline-flex items-center justify-between rounded-full px-5 py-3.5 font-semibold", featured ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}>
|
||||||
|
{offer.cta} <ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("results") && (
|
||||||
|
<section className="bg-t-surface py-24 md:py-32">
|
||||||
|
<div className={container}>
|
||||||
|
<Reveal><Eyebrow>{results.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={results.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||||
|
<div className="mt-12 grid gap-5 md:grid-cols-3">
|
||||||
|
{results.items.map((item, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
|
<figure className="flex h-full flex-col rounded-[2rem] bg-t-bg p-7">
|
||||||
|
<blockquote className="flex-1 text-lg leading-relaxed">„{item.quote}“</blockquote>
|
||||||
|
<figcaption className="mt-6 flex items-center gap-3">
|
||||||
|
<span className="relative size-12 overflow-hidden rounded-full bg-t-surface">
|
||||||
|
<TImage src={item.image} alt={item.author} sizes="48px" />
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">
|
||||||
|
<span className="block font-semibold">{item.author}</span>
|
||||||
|
<span className="text-t-muted">{item.role}</span>
|
||||||
|
</span>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("podcast") && <Podcast podcast={podcast} />}
|
||||||
|
|
||||||
|
{show("leadmagnet") && (
|
||||||
|
<section className={cn(container, "pb-24 md:pb-32")}>
|
||||||
|
<div className="grid items-center gap-12 rounded-[2.5rem] bg-t-fg px-6 py-14 text-t-bg md:grid-cols-[0.8fr_1.2fr] md:px-14">
|
||||||
|
<div className="[perspective:1200px]">
|
||||||
|
<motion.div
|
||||||
|
initial={{ rotateY: -40, opacity: 0 }}
|
||||||
|
whileInView={{ rotateY: -22, opacity: 1 }}
|
||||||
|
whileHover={{ rotateY: -8 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 1, ease }}
|
||||||
|
className="relative mx-auto h-80 w-56 [transform-style:preserve-3d]"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 rounded-r-xl bg-[repeating-linear-gradient(90deg,#f5f5f4,#f5f5f4_2px,#d6d3d1_3px)] [transform:rotateY(180deg)_translateZ(14px)]" />
|
||||||
|
<div className="absolute inset-y-0 left-0 w-7 bg-t-accent brightness-75 [transform:rotateY(-90deg)_translateZ(14px)]" />
|
||||||
|
<div className="absolute inset-y-1 right-0 w-7 bg-[repeating-linear-gradient(90deg,#fafaf9,#fafaf9_2px,#e7e5e4_3px)] [transform:rotateY(90deg)_translateZ(14px)]" />
|
||||||
|
<div className="absolute inset-0 flex flex-col rounded-r-xl bg-t-accent p-6 text-t-accent-fg shadow-2xl [transform:translateZ(14px)]">
|
||||||
|
<p className="text-xs uppercase tracking-widest opacity-70">Workbook</p>
|
||||||
|
<p className={cn(heading, "mt-4 text-3xl leading-tight")}>{leadmagnet.book}</p>
|
||||||
|
<p className="mt-auto text-2xl" style={script}>{brand.name}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<RevealWords text={leadmagnet.title} emClassName={em} className={cn(heading, "text-[clamp(2rem,4vw,3.25rem)] leading-[1.08]")} />
|
||||||
|
<p className="mt-4 text-lg opacity-75">{leadmagnet.text}</p>
|
||||||
|
<DemoForm
|
||||||
|
className="mt-8 grid gap-3 sm:grid-cols-[1fr_1fr_auto]"
|
||||||
|
success={
|
||||||
|
<p className="mt-8 flex items-center gap-3 text-lg">
|
||||||
|
<CheckIcon className="size-6 text-t-accent" /> Ist unterwegs – schau in dein Postfach!
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TField label="Vorname" name="name" required autoComplete="given-name" variant="glass" />
|
||||||
|
<TField label="E-Mail" name="email" type="email" required autoComplete="email" variant="glass" />
|
||||||
|
<button type="submit" className="cursor-pointer self-end rounded-xl bg-t-accent px-6 py-3 font-semibold text-t-accent-fg">{leadmagnet.cta}</button>
|
||||||
|
</DemoForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section id="kontakt" className="mx-auto max-w-[900px] px-5 pb-24 text-center md:pb-32">
|
||||||
|
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(3rem,8vw,6.5rem)] leading-none")} />
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<p className="mx-auto mt-6 max-w-xl text-lg leading-relaxed text-t-muted">{contact.text}</p>
|
||||||
|
<a href={`mailto:${brand.email}`} className="mt-9 inline-flex items-center gap-2 rounded-full bg-t-accent px-8 py-4 font-semibold text-t-accent-fg transition-transform hover:-translate-y-0.5">
|
||||||
|
{contact.cta} <ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
<p className="mt-5 text-sm text-t-muted">oder schreib mir: {brand.email}</p>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||||
|
<p><span className={cn(heading, "text-lg text-t-fg")}>{brand.name}</span> · {brand.role}</p>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||||
|
transition={{ duration: 0.45, ease }}
|
||||||
|
className={cn("fixed inset-x-0 top-0 z-50 transition-colors duration-500", scrolled && "bg-t-bg/85 backdrop-blur-xl")}
|
||||||
|
>
|
||||||
|
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
|
||||||
|
<a href="#top" className={cn(heading, "text-2xl")}>{name}</a>
|
||||||
|
<div className="hidden items-center gap-8 text-sm font-medium md:flex">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-accent">{link.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a href="#kontakt" className="rounded-full bg-t-fg px-5 py-2.5 text-sm font-semibold text-t-bg">{cta}</a>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Story({ story }: { story: CoachData["story"] }) {
|
||||||
|
const ref = useRef<HTMLOListElement>(null)
|
||||||
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.8", "end 0.6"] })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="ueber-mich" className={cn(container, "grid gap-14 py-24 md:grid-cols-2 md:py-32")}>
|
||||||
|
<div>
|
||||||
|
<Reveal>
|
||||||
|
<div className="relative aspect-[4/5] overflow-hidden rounded-[2.5rem] bg-t-surface">
|
||||||
|
<TImage src={story.image} sizes="(min-width: 768px) 45vw, 100vw" />
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{story.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={story.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||||
|
<Reveal delay={0.1}><p className="mt-6 text-lg leading-relaxed text-t-muted">{story.text}</p></Reveal>
|
||||||
|
<ol ref={ref} className="relative mt-10 space-y-7 pl-8">
|
||||||
|
<span className="absolute top-2 bottom-2 left-[5px] w-0.5 bg-t-fg/10" />
|
||||||
|
<motion.span style={{ scaleY: scrollYProgress }} className="absolute top-2 bottom-2 left-[5px] w-0.5 origin-top bg-t-accent" />
|
||||||
|
{story.milestones.map((milestone, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.06}>
|
||||||
|
<li className="relative">
|
||||||
|
<span className="absolute top-1.5 -left-8 size-3 rounded-full bg-t-accent ring-4 ring-t-bg" />
|
||||||
|
<p className="text-sm font-semibold text-t-accent">{milestone.year}</p>
|
||||||
|
<p className="mt-0.5">{milestone.text}</p>
|
||||||
|
</li>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<Reveal delay={0.2}>
|
||||||
|
<p className="mt-10 text-5xl text-t-accent" style={script}>{story.signature}</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Podcast({ podcast }: { podcast: CoachData["podcast"] }) {
|
||||||
|
const [playing, setPlaying] = useState<number | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="podcast" className={cn(container, "grid gap-12 py-24 md:grid-cols-[0.8fr_1.2fr] md:py-32")}>
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{podcast.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={podcast.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{podcast.items.map((episode, i) => {
|
||||||
|
const active = playing === i
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={i * 0.08}>
|
||||||
|
<li className={cn("rounded-2xl p-4 transition-colors md:p-5", active ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={active ? "Pause" : `Folge ${episode.number} abspielen`}
|
||||||
|
onClick={() => setPlaying(active ? null : i)}
|
||||||
|
className={cn("grid size-12 shrink-0 cursor-pointer place-items-center rounded-full", active ? "bg-t-bg text-t-fg" : "bg-t-accent text-t-accent-fg")}
|
||||||
|
>
|
||||||
|
{active ? <PauseIcon className="size-5 fill-current" /> : <PlayIcon className="ml-0.5 size-5 fill-current" />}
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={cn("text-xs font-semibold", active ? "opacity-80" : "text-t-muted")}>Folge {episode.number} · {episode.duration}</p>
|
||||||
|
<p className={cn(heading, "truncate text-xl")}>{episode.title}</p>
|
||||||
|
</div>
|
||||||
|
{active && (
|
||||||
|
<span className="hidden h-8 items-end gap-0.5 sm:flex" aria-hidden>
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((bar) => (
|
||||||
|
<motion.span key={bar} className="w-1 rounded-full bg-current" animate={{ height: [6, 24, 10, 28, 8] }} transition={{ duration: 1, repeat: Infinity, delay: bar * 0.1 }} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{active && (
|
||||||
|
<div className="mt-4 h-1 overflow-hidden rounded-full bg-current/20">
|
||||||
|
<motion.div className="h-full bg-current" initial={{ width: "0%" }} animate={{ width: "100%" }} transition={{ duration: 40, ease: "linear" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
modules/templates/catalog/event/definition.ts
Normal file
103
modules/templates/catalog/event/definition.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
brand: { name: "FUTURE NOW", suffix: "Konferenz für digitale Zukunft" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Ausgabe 2026",
|
||||||
|
title: "FUTURE\n*NOW*",
|
||||||
|
date: "2026-11-19T09:00",
|
||||||
|
dateLabel: "19.–20. November 2026",
|
||||||
|
location: "Station Berlin",
|
||||||
|
text: "Zwei Tage, 40 Speaker, 1.500 Macher:innen. Die Konferenz für alle, die Zukunft nicht abwarten, sondern bauen.",
|
||||||
|
cta: "Tickets sichern",
|
||||||
|
image: unsplash("1540575467063-178a50c2df87"),
|
||||||
|
},
|
||||||
|
marquee: { items: [{ text: "Künstliche Intelligenz" }, { text: "Design" }, { text: "Nachhaltigkeit" }, { text: "Leadership" }, { text: "Commerce" }, { text: "Creator Economy" }] },
|
||||||
|
speakers: {
|
||||||
|
eyebrow: "Speaker",
|
||||||
|
title: "Die Stimmen der *Zukunft*",
|
||||||
|
items: [
|
||||||
|
{ name: "Dr. Mira Okafor", role: "KI-Forscherin", company: "Institute for Applied AI", image: unsplash("1531123897727-8f129e1688ce", 900) },
|
||||||
|
{ name: "Jonas Berg", role: "Gründer", company: "Northlane", image: unsplash("1552058544-f2b08422138a", 900) },
|
||||||
|
{ name: "Clara Weiss", role: "Chief Design Officer", company: "Studio Neon", image: unsplash("1524504388940-b1c1722653e1", 900) },
|
||||||
|
{ name: "Elif Kaya", role: "Klimaökonomin", company: "Future Lab", image: unsplash("1488426862026-3ee34a7d66df", 900) },
|
||||||
|
{ name: "Ben Hartmann", role: "CEO", company: "Voltworks", image: unsplash("1472099645785-5658abf4ff4e", 900) },
|
||||||
|
{ name: "Sofia Lindqvist", role: "Autorin", company: "„Morgen machen“", image: unsplash("1487412720507-e7ab37603c6f", 900) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
agenda: {
|
||||||
|
eyebrow: "Programm",
|
||||||
|
title: "Zwei Tage *volle* Energie",
|
||||||
|
items: [
|
||||||
|
{ day: "Tag 1", time: "09:00", title: "Einlass & Frühstück", speaker: "", room: "Foyer" },
|
||||||
|
{ day: "Tag 1", time: "10:00", title: "Keynote: Die nächsten zehn Jahre KI", speaker: "Dr. Mira Okafor", room: "Main Stage" },
|
||||||
|
{ day: "Tag 1", time: "11:15", title: "Design, das Menschen bewegt", speaker: "Clara Weiss", room: "Stage 2" },
|
||||||
|
{ day: "Tag 1", time: "14:30", title: "Panel: Wachstum ohne Burnout", speaker: "Jonas Berg, Sofia Lindqvist", room: "Main Stage" },
|
||||||
|
{ day: "Tag 1", time: "19:00", title: "After-Party", speaker: "", room: "Halle 7" },
|
||||||
|
{ day: "Tag 2", time: "09:30", title: "Climate Tech, die sich rechnet", speaker: "Elif Kaya", room: "Main Stage" },
|
||||||
|
{ day: "Tag 2", time: "11:00", title: "Workshops nach Wahl", speaker: "", room: "Räume A–D" },
|
||||||
|
{ day: "Tag 2", time: "14:00", title: "Founder Stories", speaker: "Ben Hartmann", room: "Stage 2" },
|
||||||
|
{ day: "Tag 2", time: "16:30", title: "Closing Keynote", speaker: "Überraschungsgast", room: "Main Stage" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tickets: {
|
||||||
|
eyebrow: "Tickets",
|
||||||
|
title: "Sei *dabei.*",
|
||||||
|
items: [
|
||||||
|
{ name: "Early Bird", price: "249", text: "Beide Tage, alle Talks", features: "Zugang zu allen Bühnen\nFrühstück, Lunch & Kaffee\nAfter-Party", left: "23", highlight: "" },
|
||||||
|
{ name: "Regular", price: "349", text: "Das volle Programm", features: "Zugang zu allen Bühnen\nWorkshops nach Wahl\nCatering & After-Party", left: "", highlight: "ja" },
|
||||||
|
{ name: "VIP", price: "799", text: "Für das besondere Erlebnis", features: "Reservierte Plätze vorne\nSpeaker-Dinner\nLounge mit Getränken", left: "8", highlight: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
venue: {
|
||||||
|
eyebrow: "Location",
|
||||||
|
title: "Station *Berlin*",
|
||||||
|
text: "Historische Hallen am Gleisdreieck – fünf Minuten vom Potsdamer Platz, bestens mit Bahn und Rad erreichbar.",
|
||||||
|
address: "Luckenwalder Straße 4–6, 10963 Berlin",
|
||||||
|
image: unsplash("1523580494863-6f3031224c94", 1600),
|
||||||
|
},
|
||||||
|
partners: { title: "Partner", items: [{ name: "Voltworks" }, { name: "Northlane" }, { name: "Studio Neon" }, { name: "Future Lab" }, { name: "Brightfield" }] },
|
||||||
|
faq: {
|
||||||
|
items: [
|
||||||
|
{ question: "Gibt es Gruppenrabatte?", answer: "Ab fünf Tickets erhaltet ihr 15 % Rabatt. Schreibt uns einfach eine kurze Nachricht." },
|
||||||
|
{ question: "Werden die Talks aufgezeichnet?", answer: "Ja, alle Keynotes gibt es für Ticketinhaber:innen zwei Wochen nach dem Event im Stream." },
|
||||||
|
{ question: "Kann ich mein Ticket übertragen?", answer: "Klar – bis zu 48 Stunden vor Beginn kostenlos über euer Ticketkonto." },
|
||||||
|
{ question: "Ist die Location barrierefrei?", answer: "Alle Hallen sind ebenerdig oder per Aufzug erreichbar. Sagt uns Bescheid, wenn ihr Unterstützung braucht." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const event = defineTemplate({
|
||||||
|
key: "event",
|
||||||
|
name: "Summit",
|
||||||
|
industry: "Event & Launch",
|
||||||
|
description: "Kinetisch und laut: Titel aus einklappenden Buchstaben, Countdown mit rollenden Ziffern, Speaker, Programm mit Zeitleiste, Tickets mit Restkontingent und Location.",
|
||||||
|
cover: unsplash("1587825140708-dfaf72ae4b04", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Hallo, wir würden gern mit zwölf Personen aus unserem Unternehmen zur Konferenz kommen. Gibt es Gruppenrabatte, und können wir eine Rechnung auf die Firma bekommen? Viele Grüße, Katrin Meier",
|
||||||
|
topic: "Speaker-Reveal: Dr. Mira Okafor eröffnet die Konferenz",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "neon", label: "Neon", dark: true, bg: "#08070b", fg: "#f4f2fb", muted: "#8e8a9e", surface: "#131119", accent: "#ff3d81" },
|
||||||
|
{ key: "acid", label: "Acid", dark: true, bg: "#0a0b07", fg: "#f2f5ea", muted: "#8f9584", surface: "#14160f", accent: "#d4ff3a" },
|
||||||
|
{ key: "papier", label: "Papier", bg: "#f0eee7", fg: "#121212", muted: "#64625c", surface: "#e3e0d6", accent: "#ff4f00" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "techno", label: "Bebas Neue & Space Mono" },
|
||||||
|
{ key: "modern", label: "Tilt Warp & Onest" },
|
||||||
|
],
|
||||||
|
sections: describeSections(defaults, {
|
||||||
|
brand: "Event",
|
||||||
|
hero: "Startbereich",
|
||||||
|
marquee: { label: "Laufband", optional: true, items: { items: "Thema" } },
|
||||||
|
speakers: { label: "Speaker", optional: true, items: { items: "Speaker" } },
|
||||||
|
agenda: { label: "Programm", optional: true, items: { items: "Programmpunkt" } },
|
||||||
|
tickets: { label: "Tickets", items: { items: "Ticket" } },
|
||||||
|
venue: { label: "Location", optional: true },
|
||||||
|
partners: { label: "Partner", optional: true, items: { items: "Partner" } },
|
||||||
|
faq: { label: "FAQ", optional: true, items: { items: "Frage" } },
|
||||||
|
}),
|
||||||
|
defaults,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type EventData = typeof event.defaults
|
||||||
13
modules/templates/catalog/event/fonts.ts
Normal file
13
modules/templates/catalog/event/fonts.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Bebas_Neue, Onest, Space_Mono, Tilt_Warp } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts
|
||||||
|
const bebas = Bebas_Neue({ subsets: ["latin"], weight: "400", variable: "--font-event-bebas", preload: false })
|
||||||
|
const spaceMono = Space_Mono({ subsets: ["latin"], weight: ["400", "700"], variable: "--font-event-mono", preload: false })
|
||||||
|
const tilt = Tilt_Warp({ subsets: ["latin"], variable: "--font-event-tilt", preload: false })
|
||||||
|
const onest = Onest({ subsets: ["latin"], variable: "--font-event-onest", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
techno: { className: `${bebas.variable} ${spaceMono.variable}`, display: "var(--font-event-bebas)", body: "var(--font-event-mono)" },
|
||||||
|
modern: { className: `${tilt.variable} ${onest.variable}`, display: "var(--font-event-tilt)", body: "var(--font-event-onest)" },
|
||||||
|
}
|
||||||
363
modules/templates/catalog/event/template.tsx
Normal file
363
modules/templates/catalog/event/template.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { Fragment, useState } from "react"
|
||||||
|
import { ArrowUpRightIcon, CalendarIcon, CheckIcon, MapPinIcon, TicketIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { splitList, useCountdown } from "../shared/hooks"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { Accordion, Marquee, ParallaxImage, Reveal, RevealWords, ease, parseRich, useScrollNav } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { event, type EventData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1400px] px-5 md:px-10"
|
||||||
|
// Zeilenhöhe als Arbitrary Property: tailwind-merge würde leading-* neben text-[…] verwerfen
|
||||||
|
const display = "font-t-display uppercase [line-height:0.85] tracking-[-0.01em]"
|
||||||
|
const label = "text-xs uppercase tracking-[0.2em]"
|
||||||
|
|
||||||
|
function Eyebrow({ children }: { children: string }) {
|
||||||
|
return <p className={cn(label, "mb-5 text-t-accent")}>[ {children} ]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buchstaben klappen nacheinander auf; der Startindex jedes Worts bestimmt die Verzögerung
|
||||||
|
function KineticTitle({ text }: { text: string }) {
|
||||||
|
const lines: { word: string; em: boolean; start: number }[][] = []
|
||||||
|
let count = 0
|
||||||
|
for (const tokens of parseRich(text)) {
|
||||||
|
const line = []
|
||||||
|
for (const token of tokens) {
|
||||||
|
line.push({ ...token, start: count })
|
||||||
|
count += token.word.length
|
||||||
|
}
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h1 className={cn(display, "text-[clamp(5rem,19vw,20rem)] [perspective:800px]")} aria-label={text.replace(/\*/g, "").replace(/\n/g, " ")}>
|
||||||
|
{lines.map((tokens, l) => (
|
||||||
|
<span key={l} aria-hidden className="block">
|
||||||
|
{tokens.map((token, t) => (
|
||||||
|
<Fragment key={t}>
|
||||||
|
{t > 0 && " "}
|
||||||
|
<span className={cn("inline-block whitespace-nowrap", token.em && em)}>
|
||||||
|
{[...token.word].map((char, c) => (
|
||||||
|
<motion.span
|
||||||
|
key={c}
|
||||||
|
className="inline-block origin-bottom"
|
||||||
|
initial={{ opacity: 0, rotateX: -90, y: "30%" }}
|
||||||
|
animate={{ opacity: 1, rotateX: 0, y: "0%" }}
|
||||||
|
transition={{ duration: 0.9, ease, delay: 0.3 + (token.start + c) * 0.06 }}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</h1>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Digit({ value }: { value: string }) {
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex h-[1em] w-[0.6em] justify-center overflow-hidden">
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
<motion.span key={value} initial={{ y: "-100%" }} animate={{ y: "0%" }} exit={{ y: "100%" }} transition={{ duration: 0.35, ease }} className="absolute">
|
||||||
|
{value}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Countdown({ date, className }: { date: string; className?: string }) {
|
||||||
|
const time = useCountdown(date)
|
||||||
|
const parts = [
|
||||||
|
{ value: time?.days, label: "Tage" },
|
||||||
|
{ value: time?.hours, label: "Stunden" },
|
||||||
|
{ value: time?.minutes, label: "Minuten" },
|
||||||
|
{ value: time?.seconds, label: "Sekunden" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex gap-5 md:gap-10", className)}>
|
||||||
|
{parts.map((part) => (
|
||||||
|
<div key={part.label}>
|
||||||
|
<p className={cn(display, "flex text-5xl tabular-nums md:text-7xl")}>
|
||||||
|
{(part.value === undefined ? "--" : String(part.value).padStart(2, "0")).split("").map((char, i) => (
|
||||||
|
<Digit key={i} value={char} />
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
<p className={cn(label, "mt-2 text-t-muted")}>{part.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventTemplate({ content }: TemplateProps<EventData>) {
|
||||||
|
const { brand, hero, marquee, speakers, agenda, tickets, venue, partners, faq } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const links = [
|
||||||
|
{ href: "#speaker", label: "Speaker", visible: show("speakers") },
|
||||||
|
{ href: "#programm", label: "Programm", visible: show("agenda") },
|
||||||
|
{ href: "#tickets", label: "Tickets", visible: true },
|
||||||
|
{ href: "#location", label: "Location", visible: show("venue") },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={event} content={content} fonts={fonts[content.fonts] ?? fonts.techno}>
|
||||||
|
<Nav brand={brand} hero={hero} links={links} />
|
||||||
|
|
||||||
|
<section id="top" className="relative overflow-hidden pt-32 pb-16 md:pt-40">
|
||||||
|
<div className="absolute inset-0 opacity-25">
|
||||||
|
<TImage src={hero.image} eager className="grayscale" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-t-bg/60 via-t-bg/80 to-t-bg" />
|
||||||
|
<motion.div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute top-1/4 -left-40 size-[36rem] rounded-full bg-t-accent/30 blur-[120px]"
|
||||||
|
animate={{ x: [0, 120, 0], y: [0, 60, 0] }}
|
||||||
|
transition={{ duration: 14, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute -right-40 bottom-0 size-[30rem] rounded-full bg-t-accent/20 blur-[120px]"
|
||||||
|
animate={{ x: [0, -100, 0], y: [0, -80, 0] }}
|
||||||
|
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
<div className={cn(container, "relative")}>
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className={cn(label, "flex flex-wrap gap-x-8 gap-y-2 text-t-muted")}>
|
||||||
|
<span className="text-t-accent">● {hero.eyebrow}</span>
|
||||||
|
<span className="flex items-center gap-2"><CalendarIcon className="size-3.5" /> {hero.dateLabel}</span>
|
||||||
|
<span className="flex items-center gap-2"><MapPinIcon className="size-3.5" /> {hero.location}</span>
|
||||||
|
</motion.div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<KineticTitle text={hero.title} />
|
||||||
|
</div>
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 1.1 }} className="mt-12 grid gap-10 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||||
|
<div>
|
||||||
|
<p className="max-w-xl text-lg leading-relaxed text-t-fg/80">{hero.text}</p>
|
||||||
|
<a href="#tickets" className="group mt-8 inline-flex items-center gap-3 bg-t-accent px-8 py-4 font-bold text-t-accent-fg uppercase transition-transform hover:-translate-y-1">
|
||||||
|
<TicketIcon className="size-5" /> {hero.cta}
|
||||||
|
<ArrowUpRightIcon className="size-5 transition-transform group-hover:rotate-45" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Countdown date={hero.date} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("marquee") && (
|
||||||
|
<section aria-label="Themen" className="space-y-3 py-12">
|
||||||
|
<div className="-rotate-1 bg-t-accent py-3 text-t-accent-fg">
|
||||||
|
<Marquee duration={25}>
|
||||||
|
{marquee.items.map((item, i) => (
|
||||||
|
<span key={i} className={cn(display, "px-6 text-4xl md:text-6xl")}>{item.text} ✦</span>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</div>
|
||||||
|
<div className="rotate-1 border-y border-t-fg/15 py-3">
|
||||||
|
<Marquee duration={30} reverse>
|
||||||
|
{marquee.items.map((item, i) => (
|
||||||
|
<span key={i} className={cn(display, "px-6 text-4xl text-transparent [-webkit-text-stroke:1px_var(--t-fg)] md:text-6xl")}>{item.text} ✦</span>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("speakers") && (
|
||||||
|
<section id="speaker" className={cn(container, "py-24 md:py-32")}>
|
||||||
|
<Reveal><Eyebrow>{speakers.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={speakers.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,7.5rem)]")} />
|
||||||
|
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{speakers.items.map((speaker, i) => (
|
||||||
|
<Reveal key={i} delay={(i % 3) * 0.08}>
|
||||||
|
<article className="group">
|
||||||
|
<div className="relative aspect-[4/5] overflow-hidden bg-t-surface">
|
||||||
|
<TImage src={speaker.image} alt={speaker.name} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="grayscale transition duration-700 group-hover:scale-105 group-hover:grayscale-0" />
|
||||||
|
<div className="absolute inset-0 bg-t-accent opacity-0 mix-blend-multiply transition-opacity duration-500 group-hover:opacity-40" />
|
||||||
|
<span className={cn(label, "absolute top-4 left-4 bg-t-bg/80 px-2 py-1 text-t-fg backdrop-blur")}>{String(i + 1).padStart(2, "0")}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className={cn(display, "mt-4 text-4xl")}>{speaker.name}</h3>
|
||||||
|
<p className={cn(label, "mt-1 text-t-muted")}>{speaker.role} · {speaker.company}</p>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("agenda") && <Agenda agenda={agenda} />}
|
||||||
|
|
||||||
|
<section id="tickets" className={cn(container, "py-24 md:py-32")}>
|
||||||
|
<Reveal><Eyebrow>{tickets.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={tickets.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,7.5rem)]")} />
|
||||||
|
<div className="mt-14 grid gap-4 lg:grid-cols-3">
|
||||||
|
{tickets.items.map((ticket, i) => {
|
||||||
|
const featured = Boolean(ticket.highlight.trim())
|
||||||
|
const left = Number(ticket.left)
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
|
<article className={cn("relative flex h-full flex-col border p-8", featured ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/15 bg-t-surface")}>
|
||||||
|
<p className={cn(label, featured ? "opacity-80" : "text-t-muted")}>{ticket.text}</p>
|
||||||
|
<h3 className={cn(display, "mt-3 text-5xl")}>{ticket.name}</h3>
|
||||||
|
<p className={cn(display, "mt-6 text-7xl")}>{ticket.price} €</p>
|
||||||
|
<ul className="mt-8 flex-1 space-y-2.5 text-sm">
|
||||||
|
{splitList(ticket.features, "\n").map((feature) => (
|
||||||
|
<li key={feature} className="flex items-start gap-2.5">
|
||||||
|
<CheckIcon className={cn("mt-0.5 size-4 shrink-0", !featured && "text-t-accent")} /> {feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{left > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<p className={cn(label, "mb-2")}>Nur noch {left} verfügbar</p>
|
||||||
|
<div className={cn("h-1.5 overflow-hidden", featured ? "bg-t-accent-fg/20" : "bg-t-fg/10")}>
|
||||||
|
<motion.div
|
||||||
|
className={cn("h-full", featured ? "bg-t-accent-fg" : "bg-t-accent")}
|
||||||
|
initial={{ width: "0%" }}
|
||||||
|
whileInView={{ width: `${Math.max(8, Math.min(96, 100 - left))}%` }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 1.2, ease }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<a href="#" className={cn("mt-8 block py-4 text-center font-bold uppercase transition-opacity hover:opacity-85", featured ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}>
|
||||||
|
Ticket kaufen
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("venue") && (
|
||||||
|
<section id="location" className={cn(container, "grid items-center gap-10 pb-24 md:pb-32 lg:grid-cols-[1.3fr_1fr]")}>
|
||||||
|
<ParallaxImage src={venue.image} offset={50} className="aspect-[16/10]" sizes="(min-width: 1024px) 55vw, 100vw" />
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{venue.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={venue.title} emClassName={em} className={cn(display, "text-[clamp(3rem,7vw,6rem)]")} />
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<p className="mt-6 text-lg leading-relaxed text-t-muted">{venue.text}</p>
|
||||||
|
<p className="mt-6 flex items-center gap-2 font-bold"><MapPinIcon className="size-4 text-t-accent" /> {venue.address}</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("partners") && (
|
||||||
|
<section className="border-y border-t-fg/10 py-12">
|
||||||
|
<div className={cn(container, "flex flex-col items-center gap-8 md:flex-row md:justify-between")}>
|
||||||
|
<p className={cn(label, "text-t-muted")}>{partners.title}</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-12 gap-y-4">
|
||||||
|
{partners.items.map((partner, i) => (
|
||||||
|
<span key={i} className={cn(display, "text-3xl text-t-fg/50")}>{partner.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("faq") && (
|
||||||
|
<section className={cn(container, "max-w-4xl py-24")}>
|
||||||
|
<Accordion items={faq.items} className="divide-y divide-t-fg/15 border-y border-t-fg/15" questionClassName="py-6 text-lg font-bold" answerClassName="pb-6 leading-relaxed text-t-muted" />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="overflow-hidden border-t border-t-fg/10 py-16">
|
||||||
|
<div className={cn(container, "text-center")}>
|
||||||
|
<p className={cn(display, "text-[clamp(3rem,10vw,9rem)]")}>Wir sehen <span className={em}>uns.</span></p>
|
||||||
|
<Countdown date={hero.date} className="mt-8 justify-center" />
|
||||||
|
<div className={cn(label, "mt-14 flex flex-wrap justify-center gap-6 text-t-muted")}>
|
||||||
|
<span>{brand.name} · {brand.suffix}</span>
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ brand, hero, links }: { brand: EventData["brand"]; hero: EventData["hero"]; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||||
|
transition={{ duration: 0.45, ease }}
|
||||||
|
className={cn("fixed inset-x-0 top-0 z-50 border-b transition-colors duration-500", scrolled ? "border-t-fg/10 bg-t-bg/85 backdrop-blur-xl" : "border-transparent")}
|
||||||
|
>
|
||||||
|
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
|
||||||
|
<a href="#top" className={cn(display, "text-3xl")}>{brand.name}</a>
|
||||||
|
<div className={cn(label, "hidden items-center gap-8 md:flex")}>
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-accent">{link.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={cn(label, "hidden border border-t-fg/20 px-3 py-2 lg:block")}>{hero.dateLabel}</span>
|
||||||
|
<a href="#tickets" className="bg-t-accent px-5 py-2.5 text-sm font-bold text-t-accent-fg uppercase">{hero.cta}</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Agenda({ agenda }: { agenda: EventData["agenda"] }) {
|
||||||
|
const days = [...new Set(agenda.items.map((item) => item.day))]
|
||||||
|
const [day, setDay] = useState(days[0] ?? "")
|
||||||
|
const current = days.includes(day) ? day : days[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="programm" className="bg-t-surface py-24 md:py-32">
|
||||||
|
<div className={container}>
|
||||||
|
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{agenda.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={agenda.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,7.5rem)]")} />
|
||||||
|
</div>
|
||||||
|
<div role="tablist" className="flex gap-2">
|
||||||
|
{days.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={d === current}
|
||||||
|
onClick={() => setDay(d)}
|
||||||
|
className={cn("relative cursor-pointer px-6 py-3 font-bold uppercase", d === current ? "text-t-accent-fg" : "text-t-muted hover:text-t-fg")}
|
||||||
|
>
|
||||||
|
{d === current && <motion.span layoutId="event-day" className="absolute inset-0 bg-t-accent" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
|
||||||
|
<span className="relative">{d}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.ol key={current} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.35, ease }} className="relative mt-14 ml-3 border-l-2 border-t-fg/15">
|
||||||
|
{agenda.items.filter((item) => item.day === current).map((item, i) => (
|
||||||
|
<li key={`${item.time}-${i}`} className="relative grid gap-2 pb-10 pl-8 md:grid-cols-[8rem_1fr_auto] md:items-baseline md:gap-8">
|
||||||
|
<span className="absolute top-2 -left-[9px] size-4 rounded-full border-4 border-t-surface bg-t-accent" />
|
||||||
|
<span className={cn(display, "text-4xl text-t-accent")}>{item.time}</span>
|
||||||
|
<div>
|
||||||
|
<p className={cn(display, "text-3xl md:text-4xl")}>{item.title}</p>
|
||||||
|
{item.speaker && <p className="mt-1 text-t-muted">{item.speaker}</p>}
|
||||||
|
</div>
|
||||||
|
<span className={cn(label, "w-fit border border-t-fg/20 px-3 py-1.5")}>{item.room}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</motion.ol>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
modules/templates/catalog/fitness/definition.ts
Normal file
121
modules/templates/catalog/fitness/definition.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
brand: { name: "PULSE", suffix: "Training Club", phone: "030 123 456 78", address: "Torstraße 140, 10119 Berlin" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Training Club · Berlin Mitte",
|
||||||
|
title: "Stärker\nals *gestern.*",
|
||||||
|
text: "Functional Training, Kraft und Yoga in kleinen Gruppen – mit Coaches, die dich wirklich sehen.",
|
||||||
|
cta: "Probetraining sichern",
|
||||||
|
ctaSecondary: "Kursplan ansehen",
|
||||||
|
image: unsplash("1605296867304-46d5465a13f1"),
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
items: [
|
||||||
|
{ value: "40", suffix: "+", label: "Kurse pro Woche" },
|
||||||
|
{ value: "12", suffix: "", label: "Coaches" },
|
||||||
|
{ value: "1.800", suffix: "", label: "Mitglieder" },
|
||||||
|
{ value: "24", suffix: "/7", label: "Zugang" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
marquee: { items: [{ text: "HIIT" }, { text: "Kraft" }, { text: "Mobility" }, { text: "Yoga" }, { text: "Boxen" }, { text: "Cycling" }] },
|
||||||
|
programs: {
|
||||||
|
eyebrow: "Training",
|
||||||
|
title: "Vier Wege zu *mehr* Energie.",
|
||||||
|
items: [
|
||||||
|
{ title: "Kraft", text: "Langhantel, Kettlebell und saubere Technik – Schritt für Schritt stärker.", image: unsplash("1517963879433-6ad2b056d712", 1400) },
|
||||||
|
{ title: "Functional", text: "Ganzkörper-Workouts, die dich für den Alltag fit machen.", image: unsplash("1599058917212-d750089bc07e", 1400) },
|
||||||
|
{ title: "Yoga & Mobility", text: "Beweglichkeit, Atmung und Ruhe für Körper und Kopf.", image: unsplash("1544367567-0f2fcb009e0b", 1400) },
|
||||||
|
{ title: "Boxen", text: "Technik, Ausdauer und Kopf frei – ganz ohne Sparring-Zwang.", image: unsplash("1549060279-7e168fcee0c2", 1400) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
classes: {
|
||||||
|
eyebrow: "Kursplan",
|
||||||
|
title: "Finde deinen *Flow.*",
|
||||||
|
items: [
|
||||||
|
{ day: "Mo", time: "07:00", name: "Morning HIIT", trainer: "Lena", level: "3" },
|
||||||
|
{ day: "Mo", time: "12:15", name: "Lunch Mobility", trainer: "Aylin", level: "1" },
|
||||||
|
{ day: "Mo", time: "18:30", name: "Strength Basics", trainer: "Marco", level: "2" },
|
||||||
|
{ day: "Di", time: "07:00", name: "Functional Flow", trainer: "Lena", level: "2" },
|
||||||
|
{ day: "Di", time: "19:00", name: "Box Fit", trainer: "Jonas", level: "3" },
|
||||||
|
{ day: "Mi", time: "06:45", name: "Sunrise Yoga", trainer: "Aylin", level: "1" },
|
||||||
|
{ day: "Mi", time: "18:00", name: "Heavy Lifting", trainer: "Marco", level: "3" },
|
||||||
|
{ day: "Do", time: "12:15", name: "Core & Back", trainer: "Lena", level: "2" },
|
||||||
|
{ day: "Do", time: "19:30", name: "Box Technik", trainer: "Jonas", level: "2" },
|
||||||
|
{ day: "Fr", time: "07:00", name: "Morning HIIT", trainer: "Lena", level: "3" },
|
||||||
|
{ day: "Fr", time: "17:30", name: "Friday Burn", trainer: "Marco", level: "3" },
|
||||||
|
{ day: "Sa", time: "10:00", name: "Team Workout", trainer: "Alle", level: "2" },
|
||||||
|
{ day: "So", time: "11:00", name: "Slow Flow Yoga", trainer: "Aylin", level: "1" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
transformation: {
|
||||||
|
eyebrow: "Ergebnisse",
|
||||||
|
title: "Zwölf Wochen.\n*Echte* Veränderung.",
|
||||||
|
text: "Mit festen Trainingszeiten, einem Plan, der zu dir passt, und Coaches, die dranbleiben, wenn du es mal nicht tust.",
|
||||||
|
image: unsplash("1574680096145-d05b474e2155", 1800),
|
||||||
|
before: "Woche 1",
|
||||||
|
after: "Woche 12",
|
||||||
|
quote: "Ich hätte nie gedacht, dass ich mich auf Montagmorgen freue. Minus acht Kilo, plus ganz viel Energie.",
|
||||||
|
author: "Mara, 34, Mitglied seit 2025",
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
eyebrow: "Mitgliedschaft",
|
||||||
|
title: "Kein Vertrag, der *bremst.*",
|
||||||
|
plans: [
|
||||||
|
{ name: "Flex", price: "59", period: "pro Monat", features: "Alle Kurse\nMonatlich kündbar\nBuchung per App", highlight: "" },
|
||||||
|
{ name: "Club", price: "79", period: "pro Monat", features: "Alle Kurse & Open Gym\n24/7 Zugang\nSauna & Recovery\n1 Personal Training im Quartal", highlight: "ja" },
|
||||||
|
{ name: "10er-Karte", price: "190", period: "einmalig", features: "10 Kurse nach Wahl\n6 Monate gültig\nIdeal zum Reinschnuppern", highlight: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
coaches: {
|
||||||
|
eyebrow: "Coaches",
|
||||||
|
title: "Dein *Team.*",
|
||||||
|
members: [
|
||||||
|
{ name: "Lena Kraus", role: "HIIT & Functional", image: unsplash("1546483875-ad9014c88eba", 900) },
|
||||||
|
{ name: "Marco Silva", role: "Kraft & Athletik", image: unsplash("1581009146145-b5ef050c2e1e", 900) },
|
||||||
|
{ name: "Aylin Demir", role: "Yoga & Mobility", image: unsplash("1552196563-55cd4e45efb3", 900) },
|
||||||
|
{ name: "Jonas Weber", role: "Boxen & Kondition", image: unsplash("1583454110551-21f2fa2afe61", 900) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
trial: {
|
||||||
|
title: "Dein erstes Training ist *gratis.*",
|
||||||
|
text: "Komm vorbei, lern das Team kennen und trainiere eine Stunde mit – ohne Verpflichtung.",
|
||||||
|
cta: "Probetraining sichern",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fitness = defineTemplate({
|
||||||
|
key: "fitness",
|
||||||
|
name: "Pulse",
|
||||||
|
industry: "Fitness, Sport & Yoga",
|
||||||
|
description: "Energiegeladen und dunkel: kinetische Typografie, aufklappende Trainingsbereiche, Kursplan nach Wochentagen, Vorher/Nachher-Regler und Mitgliedschaften.",
|
||||||
|
cover: unsplash("1517836357463-d25dfeac3438", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Hey, ich bin 31 und möchte nach längerer Pause wieder anfangen – vor allem wegen Rückenschmerzen. Was empfehlt ihr mir, und was kostet eine Mitgliedschaft mit Personal Training? Gruß, Felix",
|
||||||
|
topic: "Neuer Kurs: Mobility & Rücken ab Montag",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "volt", label: "Volt", dark: true, bg: "#0a0a0a", fg: "#f4f4ef", muted: "#8f8f88", surface: "#161616", accent: "#c6ff3d" },
|
||||||
|
{ key: "ember", label: "Ember", dark: true, bg: "#0d0a09", fg: "#f7f1ec", muted: "#9a8f88", surface: "#1a1412", accent: "#ff5a1f" },
|
||||||
|
{ key: "kreide", label: "Kreide", bg: "#f1f1ee", fg: "#0c0c0c", muted: "#62625d", surface: "#e2e2dd", accent: "#3d5afe" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "power", label: "Unbounded & Space Grotesk" },
|
||||||
|
{ key: "athletic", label: "Anton & Work Sans" },
|
||||||
|
],
|
||||||
|
sections: describeSections(defaults, {
|
||||||
|
brand: "Studio & Kontakt",
|
||||||
|
hero: "Startbereich",
|
||||||
|
stats: { label: "Zahlen", optional: true, items: { items: "Kennzahl" } },
|
||||||
|
marquee: { label: "Laufband", optional: true, items: { items: "Begriff" } },
|
||||||
|
programs: { label: "Trainingsbereiche", optional: true, items: { items: "Bereich" } },
|
||||||
|
classes: { label: "Kursplan", optional: true, items: { items: "Kurs" } },
|
||||||
|
transformation: { label: "Vorher/Nachher", optional: true },
|
||||||
|
pricing: { label: "Mitgliedschaften", optional: true, items: { plans: "Tarif" } },
|
||||||
|
coaches: { label: "Coaches", optional: true, items: { members: "Coach" } },
|
||||||
|
trial: "Probetraining",
|
||||||
|
}),
|
||||||
|
defaults,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type FitnessData = typeof fitness.defaults
|
||||||
13
modules/templates/catalog/fitness/fonts.ts
Normal file
13
modules/templates/catalog/fitness/fonts.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Anton, Space_Grotesk, Unbounded, Work_Sans } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts
|
||||||
|
const unbounded = Unbounded({ subsets: ["latin"], variable: "--font-fitness-unbounded", preload: false })
|
||||||
|
const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-fitness-space", preload: false })
|
||||||
|
const anton = Anton({ subsets: ["latin"], weight: "400", variable: "--font-fitness-anton", preload: false })
|
||||||
|
const workSans = Work_Sans({ subsets: ["latin"], variable: "--font-fitness-work", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
power: { className: `${unbounded.variable} ${spaceGrotesk.variable}`, display: "var(--font-fitness-unbounded)", body: "var(--font-fitness-space)" },
|
||||||
|
athletic: { className: `${anton.variable} ${workSans.variable}`, display: "var(--font-fitness-anton)", body: "var(--font-fitness-work)" },
|
||||||
|
}
|
||||||
422
modules/templates/catalog/fitness/template.tsx
Normal file
422
modules/templates/catalog/fitness/template.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ArrowUpRightIcon, CheckIcon, MoveHorizontalIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { DemoForm } from "../shared/demo-form"
|
||||||
|
import { TField, TSelect } from "../shared/fields"
|
||||||
|
import { splitList } from "../shared/hooks"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { Counter, Marquee, Reveal, RevealWords, ease, useScrollNav, wordmarkSize } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { fitness, type FitnessData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10"
|
||||||
|
const display = "font-t-display uppercase tracking-[-0.02em]"
|
||||||
|
const DAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
|
||||||
|
function Eyebrow({ children }: { children: string }) {
|
||||||
|
return <p className="mb-5 text-xs font-semibold uppercase tracking-[0.3em] text-t-accent">— {children}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkewButton({ href, children, ghost }: { href: string; children: string; ghost?: boolean }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"group inline-flex -skew-x-12 items-center px-7 py-4 transition-transform hover:-translate-y-0.5",
|
||||||
|
ghost ? "ring-1 ring-t-fg/30 hover:ring-t-accent" : "bg-t-accent text-t-accent-fg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex skew-x-12 items-center gap-2 text-sm font-bold uppercase tracking-wider">
|
||||||
|
{children}
|
||||||
|
<ArrowUpRightIcon className="size-4 transition-transform group-hover:rotate-45" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FitnessTemplate({ content }: TemplateProps<FitnessData>) {
|
||||||
|
const { brand, hero, stats, marquee, programs, classes, transformation, pricing, coaches, trial } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const links = [
|
||||||
|
{ href: "#training", label: "Training", visible: show("programs") },
|
||||||
|
{ href: "#kurse", label: "Kursplan", visible: show("classes") },
|
||||||
|
{ href: "#preise", label: "Preise", visible: show("pricing") },
|
||||||
|
{ href: "#team", label: "Coaches", visible: show("coaches") },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={fitness} content={content} fonts={fonts[content.fonts] ?? fonts.power}>
|
||||||
|
<Nav name={brand.name} cta={trial.cta} links={links} />
|
||||||
|
|
||||||
|
<section id="top" className="relative flex min-h-svh flex-col justify-end overflow-hidden">
|
||||||
|
<motion.div className="absolute inset-0" initial={{ scale: 1.2 }} animate={{ scale: 1 }} transition={{ duration: 2.2, ease }}>
|
||||||
|
<TImage src={hero.image} eager className="contrast-125 grayscale" />
|
||||||
|
</motion.div>
|
||||||
|
<div className="absolute inset-0 bg-t-accent/10 mix-blend-multiply" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-t-bg via-t-bg/50 to-t-bg/20" />
|
||||||
|
|
||||||
|
<div className={cn(container, "relative pt-36 pb-10")}>
|
||||||
|
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.8, ease, delay: 0.2 }}>
|
||||||
|
<Eyebrow>{hero.eyebrow}</Eyebrow>
|
||||||
|
</motion.div>
|
||||||
|
<RevealWords
|
||||||
|
as="h1"
|
||||||
|
onMount
|
||||||
|
delay={0.3}
|
||||||
|
text={hero.title}
|
||||||
|
emClassName={em}
|
||||||
|
className={cn(display, "text-[clamp(3.2rem,10.5vw,11rem)] leading-[0.88] font-black")}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease, delay: 0.9 }}
|
||||||
|
className="mt-10 flex flex-col gap-8 md:flex-row md:items-end md:justify-between"
|
||||||
|
>
|
||||||
|
<p className="max-w-md text-lg leading-relaxed text-t-fg/80">{hero.text}</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<SkewButton href="#probetraining">{hero.cta}</SkewButton>
|
||||||
|
{show("classes") && <SkewButton href="#kurse" ghost>{hero.ctaSecondary}</SkewButton>}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{show("stats") && (
|
||||||
|
<dl className="mt-14 grid grid-cols-2 border-t border-t-fg/15 md:grid-cols-4">
|
||||||
|
{stats.items.map((stat, i) => (
|
||||||
|
<div key={i} className={cn("py-6 pr-4", i > 0 && "md:border-l md:border-t-fg/15 md:pl-8")}>
|
||||||
|
<dd className={cn(display, "text-4xl md:text-5xl")}>
|
||||||
|
<Counter value={stat.value} />
|
||||||
|
<span className="text-t-accent">{stat.suffix}</span>
|
||||||
|
</dd>
|
||||||
|
<dt className="mt-2 text-xs uppercase tracking-[0.2em] text-t-muted">{stat.label}</dt>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("marquee") && (
|
||||||
|
<div className="-rotate-2 scale-105 bg-t-accent py-4 text-t-accent-fg">
|
||||||
|
<Marquee duration={18}>
|
||||||
|
{marquee.items.map((item, i) => (
|
||||||
|
<span key={i} className={cn(display, "flex items-center gap-8 px-4 text-3xl font-black md:text-5xl")}>
|
||||||
|
{item.text}
|
||||||
|
<span className="text-2xl">✱</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("programs") && <Programs programs={programs} />}
|
||||||
|
{show("classes") && <Schedule classes={classes} />}
|
||||||
|
{show("transformation") && <Transformation data={transformation} />}
|
||||||
|
|
||||||
|
{show("pricing") && (
|
||||||
|
<section id="preise" className={cn(container, "py-24 md:py-36")}>
|
||||||
|
<Reveal><Eyebrow>{pricing.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={pricing.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
|
||||||
|
<div className="mt-14 grid gap-5 lg:grid-cols-3">
|
||||||
|
{pricing.plans.map((plan, i) => {
|
||||||
|
const featured = Boolean(plan.highlight.trim())
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
|
<article className={cn("relative flex h-full flex-col p-8 md:p-10", featured ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
|
||||||
|
{featured && <span className="absolute top-6 right-6 bg-t-bg px-3 py-1 text-xs font-bold uppercase tracking-wider text-t-fg">Beliebt</span>}
|
||||||
|
<h3 className={cn(display, "text-2xl")}>{plan.name}</h3>
|
||||||
|
<p className="mt-6 flex items-end gap-2">
|
||||||
|
<span className={cn(display, "text-6xl md:text-7xl")}>{plan.price}€</span>
|
||||||
|
<span className={cn("pb-2 text-sm", featured ? "opacity-70" : "text-t-muted")}>{plan.period}</span>
|
||||||
|
</p>
|
||||||
|
<ul className="mt-8 flex-1 space-y-3">
|
||||||
|
{splitList(plan.features, "\n").map((feature) => (
|
||||||
|
<li key={feature} className="flex items-start gap-3">
|
||||||
|
<CheckIcon className={cn("mt-0.5 size-4 shrink-0", !featured && "text-t-accent")} />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<a
|
||||||
|
href="#probetraining"
|
||||||
|
className={cn("mt-10 block py-4 text-center text-sm font-bold uppercase tracking-wider transition-opacity hover:opacity-85", featured ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}
|
||||||
|
>
|
||||||
|
Jetzt starten
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("coaches") && (
|
||||||
|
<section id="team" className={cn(container, "pb-24 md:pb-36")}>
|
||||||
|
<Reveal><Eyebrow>{coaches.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={coaches.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
|
||||||
|
<div className="mt-12 grid grid-cols-2 gap-3 md:grid-cols-4 md:gap-5">
|
||||||
|
{coaches.members.map((member, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.08}>
|
||||||
|
<div className="group relative aspect-[3/4] overflow-hidden bg-t-surface">
|
||||||
|
<TImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="grayscale transition duration-700 group-hover:scale-105 group-hover:grayscale-0" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 translate-y-[calc(100%-4.5rem)] bg-t-accent p-5 text-t-accent-fg transition-transform duration-500 group-hover:translate-y-0">
|
||||||
|
<p className={cn(display, "text-lg leading-tight")}>{member.name}</p>
|
||||||
|
<p className="mt-3 text-sm opacity-80">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section id="probetraining" className="relative overflow-hidden bg-t-surface py-24 md:py-36">
|
||||||
|
<div className={cn(container, "grid gap-14 lg:grid-cols-2")}>
|
||||||
|
<div>
|
||||||
|
<RevealWords text={trial.title} emClassName={em} className={cn(display, "text-[clamp(2.75rem,7vw,6.5rem)] leading-[0.9] font-black")} />
|
||||||
|
<Reveal delay={0.15}>
|
||||||
|
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{trial.text}</p>
|
||||||
|
<p className="mt-8 text-sm text-t-muted">{brand.address} · {brand.phone}</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<DemoForm
|
||||||
|
className="grid gap-6 bg-t-bg p-7 md:p-10"
|
||||||
|
success={
|
||||||
|
<div className="py-8">
|
||||||
|
<p className={cn(display, "text-4xl text-t-accent")}>Let's go!</p>
|
||||||
|
<p className="mt-3 text-t-muted">Wir melden uns heute noch mit deinem Termin fürs Probetraining.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TField label="Name" name="name" required autoComplete="name" variant="line" />
|
||||||
|
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="line" />
|
||||||
|
<TSelect label="Was willst du testen?" name="class" variant="line" options={[...new Set(classes.items.map((item) => item.name))]} />
|
||||||
|
<button type="submit" className="mt-2 -skew-x-12 cursor-pointer bg-t-accent py-4 text-t-accent-fg transition-transform hover:-translate-y-0.5">
|
||||||
|
<span className="block skew-x-12 text-sm font-bold uppercase tracking-wider">{trial.cta}</span>
|
||||||
|
</button>
|
||||||
|
</DemoForm>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="overflow-hidden pt-16">
|
||||||
|
<div className={cn(container, "flex flex-col gap-6 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||||
|
<p>{brand.name} {brand.suffix} · {brand.address}</p>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
aria-hidden
|
||||||
|
style={{ fontSize: wordmarkSize(brand.name, 30) }}
|
||||||
|
className={cn(display, "mt-8 text-center leading-[0.8] font-black whitespace-nowrap text-transparent [-webkit-text-stroke:1px_var(--t-accent)]")}
|
||||||
|
>
|
||||||
|
{brand.name}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||||
|
transition={{ duration: 0.45, ease }}
|
||||||
|
className={cn("fixed inset-x-0 top-0 z-50 transition-colors duration-500", scrolled && "bg-t-bg/85 backdrop-blur-xl")}
|
||||||
|
>
|
||||||
|
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
|
||||||
|
<a href="#top" className={cn(display, "text-2xl font-black")}>
|
||||||
|
{name}<span className="text-t-accent">.</span>
|
||||||
|
</a>
|
||||||
|
<div className="hidden items-center gap-8 md:flex">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="text-xs font-semibold uppercase tracking-[0.2em] text-t-fg/70 transition-colors hover:text-t-accent">
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a href="#probetraining" className="-skew-x-12 bg-t-accent px-5 py-2.5 text-t-accent-fg">
|
||||||
|
<span className="block skew-x-12 text-xs font-bold uppercase tracking-wider">{cta}</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Programs({ programs }: { programs: FitnessData["programs"] }) {
|
||||||
|
const [active, setActive] = useState(0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="training" className={cn(container, "py-24 md:py-36")}>
|
||||||
|
<Reveal><Eyebrow>{programs.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={programs.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
|
||||||
|
<div className="mt-14 flex flex-col gap-3 md:h-[34rem] md:flex-row">
|
||||||
|
{programs.items.map((item, i) => {
|
||||||
|
const open = active === i
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setActive(i)}
|
||||||
|
onFocus={() => setActive(i)}
|
||||||
|
onClick={() => setActive(i)}
|
||||||
|
className={cn(
|
||||||
|
"group relative h-72 cursor-pointer overflow-hidden text-left transition-[flex-grow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] md:h-auto",
|
||||||
|
open ? "md:flex-[3.2]" : "md:flex-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TImage src={item.image} sizes="(min-width: 768px) 50vw, 100vw" className={cn("transition duration-700", open ? "scale-100" : "scale-110 grayscale")} />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
|
||||||
|
<span className={cn(display, "absolute top-5 left-5 text-sm text-t-accent")}>{String(i + 1).padStart(2, "0")}</span>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 p-6 text-white md:p-8">
|
||||||
|
<h3 className={cn(display, "text-3xl font-black md:text-4xl", !open && "md:origin-bottom-left md:-rotate-90 md:translate-x-8 md:whitespace-nowrap")}>
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5, ease, delay: 0.2 }}
|
||||||
|
className="mt-3 max-w-sm text-white/80"
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Schedule({ classes }: { classes: FitnessData["classes"] }) {
|
||||||
|
const days = DAYS.filter((day) => classes.items.some((item) => item.day === day))
|
||||||
|
const [day, setDay] = useState(days[0] ?? "Mo")
|
||||||
|
const [booked, setBooked] = useState<string[]>([])
|
||||||
|
const current = days.includes(day) ? day : days[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="kurse" className="bg-t-surface py-24 md:py-36">
|
||||||
|
<div className={container}>
|
||||||
|
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{classes.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={classes.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
|
||||||
|
</div>
|
||||||
|
<div role="tablist" className="flex flex-wrap gap-1">
|
||||||
|
{days.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={d === current}
|
||||||
|
onClick={() => setDay(d)}
|
||||||
|
className={cn("relative cursor-pointer px-4 py-2.5 text-sm font-bold uppercase", d === current ? "text-t-accent-fg" : "text-t-muted hover:text-t-fg")}
|
||||||
|
>
|
||||||
|
{d === current && <motion.span layoutId="fitness-day" className="absolute inset-0 bg-t-accent" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
|
||||||
|
<span className="relative">{d}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.ul key={current} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.35, ease }} className="mt-12 border-t border-t-fg/10">
|
||||||
|
{classes.items.filter((item) => item.day === current).map((item) => {
|
||||||
|
const id = `${item.day}-${item.time}-${item.name}`
|
||||||
|
const isBooked = booked.includes(id)
|
||||||
|
const level = Math.min(3, Math.max(1, Number(item.level) || 1))
|
||||||
|
return (
|
||||||
|
<li key={id} className="grid grid-cols-[5rem_1fr_auto] items-center gap-4 border-b border-t-fg/10 py-6 md:grid-cols-[8rem_1fr_10rem_8rem_auto]">
|
||||||
|
<span className={cn(display, "text-2xl text-t-accent md:text-3xl")}>{item.time}</span>
|
||||||
|
<div>
|
||||||
|
<p className={cn(display, "text-lg md:text-2xl")}>{item.name}</p>
|
||||||
|
<p className="text-sm text-t-muted md:hidden">mit {item.trainer}</p>
|
||||||
|
</div>
|
||||||
|
<p className="hidden text-sm text-t-muted md:block">mit {item.trainer}</p>
|
||||||
|
<span className="hidden items-end gap-1 md:flex" aria-label={`Intensität ${level} von 3`}>
|
||||||
|
{[1, 2, 3].map((bar) => (
|
||||||
|
<span key={bar} className={cn("w-2 rounded-sm", bar <= level ? "bg-t-accent" : "bg-t-fg/15")} style={{ height: 6 + bar * 6 }} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBooked((list) => (isBooked ? list.filter((entry) => entry !== id) : [...list, id]))}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider transition-colors",
|
||||||
|
isBooked ? "bg-t-accent text-t-accent-fg" : "ring-1 ring-t-fg/20 hover:ring-t-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBooked && <CheckIcon className="size-3.5" />}
|
||||||
|
{isBooked ? "Gebucht" : "Platz sichern"}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.ul>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Transformation({ data }: { data: FitnessData["transformation"] }) {
|
||||||
|
const [position, setPosition] = useState(50)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cn(container, "grid items-center gap-14 py-24 md:py-36 lg:grid-cols-[1fr_1.2fr]")}>
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{data.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={data.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5.5vw,5rem)] leading-[0.92] font-black")} />
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<p className="mt-6 max-w-md text-lg leading-relaxed text-t-muted">{data.text}</p>
|
||||||
|
<blockquote className="mt-10 border-l-4 border-t-accent pl-6">
|
||||||
|
<p className="text-xl leading-snug">„{data.quote}“</p>
|
||||||
|
<footer className="mt-3 text-sm text-t-muted">{data.author}</footer>
|
||||||
|
</blockquote>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden bg-t-surface select-none">
|
||||||
|
<TImage src={data.image} sizes="(min-width: 1024px) 55vw, 100vw" />
|
||||||
|
<div className="absolute inset-0" style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}>
|
||||||
|
<TImage src={data.image} sizes="(min-width: 1024px) 55vw, 100vw" className="brightness-50 grayscale" />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 w-0.5 bg-t-accent" style={{ left: `${position}%` }}>
|
||||||
|
<span className="absolute top-1/2 left-1/2 grid size-12 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-t-accent text-t-accent-fg shadow-xl">
|
||||||
|
<MoveHorizontalIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="pointer-events-none absolute top-4 left-4 bg-black/70 px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">{data.before}</span>
|
||||||
|
<span className="pointer-events-none absolute top-4 right-4 bg-t-accent px-3 py-1 text-xs font-bold uppercase tracking-wider text-t-accent-fg">{data.after}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={position}
|
||||||
|
onChange={(event) => setPosition(Number(event.target.value))}
|
||||||
|
aria-label="Vorher/Nachher vergleichen"
|
||||||
|
className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
modules/templates/catalog/handwerk/definition.ts
Normal file
227
modules/templates/catalog/handwerk/definition.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import { defineTemplate, RICH_HINT, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
export const handwerk = defineTemplate({
|
||||||
|
key: "handwerk",
|
||||||
|
name: "Werkstatt",
|
||||||
|
industry: "Handwerk & Bau",
|
||||||
|
description: "Kräftig und grafisch: große Typografie, Leistungen mit Hover-Effekt, Zahlen zum Hochzählen, Projekt-Raster und Anfrageformular.",
|
||||||
|
cover: unsplash("1600566753190-17f0baa2a6c3", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Guten Tag, wir möchten unser Badezimmer (ca. 8 m², Baujahr 1985) komplett sanieren: neue Fliesen, bodengleiche Dusche statt Wanne, neues WC und Waschtisch. Können Sie uns ein Angebot machen? Gruß, Thomas Krüger",
|
||||||
|
topic: "Vorher-nachher: Badsanierung in vier Wochen",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "signal", label: "Signal", bg: "#ebe9e4", fg: "#121212", muted: "#5f5d58", surface: "#dedbd4", accent: "#ff5a1f" },
|
||||||
|
{ key: "nacht", label: "Nacht", dark: true, bg: "#0e0f10", fg: "#f2f1ed", muted: "#8c8e91", surface: "#18191b", accent: "#ffc400" },
|
||||||
|
{ key: "werkstatt", label: "Werkstatt", bg: "#f2f0eb", fg: "#14233a", muted: "#5b6676", surface: "#e3e1db", accent: "#2563eb" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "kraeftig", label: "Barlow Condensed & Barlow" },
|
||||||
|
{ key: "technisch", label: "Bricolage Grotesque & Inter Tight" },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
key: "brand",
|
||||||
|
label: "Marke",
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "suffix", label: "Zusatz" },
|
||||||
|
{ key: "claim", label: "Hinweis in der Kopfzeile" },
|
||||||
|
{ key: "phone", label: "Telefon" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hero",
|
||||||
|
label: "Startbereich",
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "image", label: "Bild", type: "image" },
|
||||||
|
{ key: "cta", label: "Button" },
|
||||||
|
{ key: "ctaSecondary", label: "Zweiter Link" },
|
||||||
|
{ key: "badgeValue", label: "Kennzahl" },
|
||||||
|
{ key: "badgeSuffix", label: "Kennzahl-Zusatz (z. B. +)" },
|
||||||
|
{ key: "badgeLabel", label: "Kennzahl-Beschriftung" },
|
||||||
|
{ key: "trust", label: "Vertrauenszeile" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "marquee",
|
||||||
|
label: "Laufband",
|
||||||
|
optional: true,
|
||||||
|
fields: [{ key: "items", label: "Begriffe", type: "list", itemLabel: "Begriff", fields: [{ key: "text", label: "Text" }] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "services",
|
||||||
|
label: "Leistungen",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
label: "Leistungen",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Leistung",
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "Titel" },
|
||||||
|
{ key: "text", label: "Beschreibung", type: "textarea" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stats",
|
||||||
|
label: "Zahlen",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
label: "Kennzahlen",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Kennzahl",
|
||||||
|
fields: [
|
||||||
|
{ key: "value", label: "Zahl (z. B. 1.200)" },
|
||||||
|
{ key: "suffix", label: "Zusatz (z. B. + oder %)" },
|
||||||
|
{ key: "label", label: "Beschriftung" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "projects",
|
||||||
|
label: "Projekte",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
label: "Projekte",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Projekt",
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "Titel" },
|
||||||
|
{ key: "meta", label: "Art & Jahr" },
|
||||||
|
{ key: "image", label: "Bild", type: "image" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "process",
|
||||||
|
label: "Ablauf",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{
|
||||||
|
key: "steps",
|
||||||
|
label: "Schritte",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Schritt",
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "Titel" },
|
||||||
|
{ key: "text", label: "Beschreibung", type: "textarea" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "testimonial",
|
||||||
|
label: "Kundenstimme",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "text", label: "Zitat", type: "textarea" },
|
||||||
|
{ key: "author", label: "Von" },
|
||||||
|
{ key: "role", label: "Projekt" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contact",
|
||||||
|
label: "Kontakt",
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "email", label: "E-Mail" },
|
||||||
|
{ key: "address", label: "Adresse" },
|
||||||
|
{ key: "hours", label: "Erreichbarkeit" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaults: {
|
||||||
|
brand: { name: "Brandt", suffix: "Bau & Sanierung", claim: "Meisterbetrieb in dritter Generation", phone: "07031 123 45 67" },
|
||||||
|
hero: {
|
||||||
|
title: "Wir bauen,\nwas *bleibt.*",
|
||||||
|
text: "Neubau, Sanierung und Innenausbau aus einer Hand. Mit festen Ansprechpartnern, ehrlichen Festpreisen und Handwerk, auf das Sie sich verlassen können.",
|
||||||
|
image: unsplash("1600566753190-17f0baa2a6c3", 1600),
|
||||||
|
cta: "Projekt anfragen",
|
||||||
|
ctaSecondary: "Unsere Projekte",
|
||||||
|
badgeValue: "35",
|
||||||
|
badgeSuffix: "+",
|
||||||
|
badgeLabel: "Jahre Erfahrung",
|
||||||
|
trust: "Meisterbetrieb · Seit 1989 · 4,9 ★ bei Google",
|
||||||
|
},
|
||||||
|
marquee: {
|
||||||
|
items: [{ text: "Rohbau" }, { text: "Sanierung" }, { text: "Innenausbau" }, { text: "Dach & Fassade" }, { text: "Energieberatung" }],
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
eyebrow: "Leistungen",
|
||||||
|
title: "Alles aus\n*einer Hand.*",
|
||||||
|
text: "Ein Ansprechpartner, ein Zeitplan, ein Festpreis. Wir koordinieren alle Gewerke – Sie behalten den Überblick.",
|
||||||
|
items: [
|
||||||
|
{ title: "Neubau & Rohbau", text: "Vom Fundament bis zum Dachstuhl – termintreu, sauber und mit festen Ansprechpartnern." },
|
||||||
|
{ title: "Sanierung", text: "Wir bringen Altbauten auf den neuesten Stand – energetisch, technisch und optisch." },
|
||||||
|
{ title: "Innenausbau", text: "Trockenbau, Böden, Bäder und Küchen. Präzise Arbeit bis ins letzte Detail." },
|
||||||
|
{ title: "Dach & Fassade", text: "Dämmung, Eindeckung und Fassadengestaltung, die Jahrzehnte hält." },
|
||||||
|
{ title: "Energieberatung", text: "Wir zeigen, welche Maßnahmen sich lohnen – inklusive Fördermittel-Check." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
items: [
|
||||||
|
{ value: "35", suffix: "+", label: "Jahre Erfahrung" },
|
||||||
|
{ value: "1.200", suffix: "", label: "Projekte abgeschlossen" },
|
||||||
|
{ value: "48", suffix: "", label: "Fachkräfte im Team" },
|
||||||
|
{ value: "98", suffix: " %", label: "Weiterempfehlung" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
eyebrow: "Projekte",
|
||||||
|
title: "Ausgewählte\n*Arbeiten.*",
|
||||||
|
items: [
|
||||||
|
{ title: "Haus am Hang", meta: "Neubau · 2025", image: unsplash("1600566753190-17f0baa2a6c3", 1600) },
|
||||||
|
{ title: "Altbau-Wohnung", meta: "Kernsanierung · 2024", image: unsplash("1600210492486-724fe5c67fb0", 1200) },
|
||||||
|
{ title: "Offene Küche", meta: "Innenausbau · 2024", image: unsplash("1484154218962-a197022b5858", 1200) },
|
||||||
|
{ title: "Familienbad", meta: "Badsanierung · 2023", image: unsplash("1552321554-5fefe8c9ef14", 1600) },
|
||||||
|
{ title: "Stadtvilla", meta: "Neubau · 2023", image: unsplash("1600047509807-ba8f99d2cdde", 2000) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
eyebrow: "Ablauf",
|
||||||
|
title: "So arbeiten *wir.*",
|
||||||
|
steps: [
|
||||||
|
{ title: "Erstgespräch", text: "Wir hören zu, schauen uns alles vor Ort an und klären Ihre Wünsche." },
|
||||||
|
{ title: "Angebot", text: "Sie erhalten ein transparentes Festpreisangebot mit klarem Zeitplan." },
|
||||||
|
{ title: "Umsetzung", text: "Ein fester Bauleiter koordiniert alle Gewerke und hält Sie auf dem Laufenden." },
|
||||||
|
{ title: "Übergabe", text: "Gemeinsame Abnahme, saubere Baustelle und fünf Jahre Gewährleistung." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testimonial: {
|
||||||
|
text: "Von der ersten Beratung bis zur Schlüsselübergabe lief alles wie abgesprochen. Termintreu, sauber und immer erreichbar – so wünscht man sich einen Handwerksbetrieb.",
|
||||||
|
author: "Familie Krämer",
|
||||||
|
role: "Neubau Einfamilienhaus",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
eyebrow: "Kontakt",
|
||||||
|
title: "Lassen Sie uns\n*loslegen.*",
|
||||||
|
text: "Erzählen Sie uns von Ihrem Vorhaben. Wir melden uns innerhalb von 24 Stunden mit einem Terminvorschlag.",
|
||||||
|
email: "info@brandt-bau.de",
|
||||||
|
address: "Industriestraße 8, 71034 Böblingen",
|
||||||
|
hours: "Mo – Fr · 7 – 17 Uhr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type HandwerkData = typeof handwerk.defaults
|
||||||
21
modules/templates/catalog/handwerk/fonts.ts
Normal file
21
modules/templates/catalog/handwerk/fonts.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Barlow, Barlow_Condensed, Bricolage_Grotesque, Inter_Tight } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts
|
||||||
|
const barlowCondensed = Barlow_Condensed({ subsets: ["latin"], weight: ["600", "700", "800"], variable: "--font-barlow-condensed", preload: false })
|
||||||
|
const barlow = Barlow({ subsets: ["latin"], weight: ["400", "500", "600"], variable: "--font-barlow", preload: false })
|
||||||
|
const bricolage = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-bricolage", preload: false })
|
||||||
|
const interTight = Inter_Tight({ subsets: ["latin"], variable: "--font-inter-tight", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
kraeftig: {
|
||||||
|
className: `${barlowCondensed.variable} ${barlow.variable}`,
|
||||||
|
display: "var(--font-barlow-condensed)",
|
||||||
|
body: "var(--font-barlow)",
|
||||||
|
},
|
||||||
|
technisch: {
|
||||||
|
className: `${bricolage.variable} ${interTight.variable}`,
|
||||||
|
display: "var(--font-bricolage)",
|
||||||
|
body: "var(--font-inter-tight)",
|
||||||
|
},
|
||||||
|
}
|
||||||
441
modules/templates/catalog/handwerk/template.tsx
Normal file
441
modules/templates/catalog/handwerk/template.tsx
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { motion, useScroll } from "motion/react"
|
||||||
|
import { useRef, type InputHTMLAttributes } from "react"
|
||||||
|
import { ArrowUpRightIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { DemoForm } from "../shared/demo-form"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { Counter, Marquee, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { handwerk, type HandwerkData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10"
|
||||||
|
const display = "font-t-display font-extrabold uppercase"
|
||||||
|
const fadeUp = {
|
||||||
|
initial: { opacity: 0, y: 40 },
|
||||||
|
whileInView: { opacity: 1, y: 0 },
|
||||||
|
viewport: { once: true, margin: "0px 0px -10% 0px" },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Raster der Projekte: wechselnde Breiten und Formate statt gleichförmiger Kacheln
|
||||||
|
const projectLayout = [
|
||||||
|
{ col: "md:col-span-7", aspect: "aspect-[4/3]" },
|
||||||
|
{ col: "md:col-span-5", aspect: "aspect-[4/5]" },
|
||||||
|
{ col: "md:col-span-5", aspect: "aspect-[4/5]" },
|
||||||
|
{ col: "md:col-span-7", aspect: "aspect-[4/3]" },
|
||||||
|
{ col: "md:col-span-12", aspect: "aspect-[4/3] md:aspect-[21/9]" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Eyebrow({ children, inverted }: { children: string; inverted?: boolean }) {
|
||||||
|
return (
|
||||||
|
<p className={cn("mb-6 flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.25em]", inverted ? "text-t-bg/60" : "text-t-muted")}>
|
||||||
|
<span className="size-2.5 bg-t-accent" />
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockButton({ href, children, inverted }: { href: string; children: string; inverted?: boolean }) {
|
||||||
|
return (
|
||||||
|
<a href={href} className="group inline-flex items-stretch">
|
||||||
|
<span className={cn(display, "px-6 py-4 text-sm tracking-wider md:text-base", inverted ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
<span className="grid place-items-center bg-t-accent px-4 text-t-accent-fg">
|
||||||
|
<ArrowUpRightIcon className="size-5 transition-transform duration-500 group-hover:rotate-45" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HandwerkTemplate({ content }: TemplateProps<HandwerkData>) {
|
||||||
|
const { brand, hero, marquee, services, stats, projects, process, testimonial, contact } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const links = [
|
||||||
|
{ href: "#leistungen", label: "Leistungen", visible: show("services") },
|
||||||
|
{ href: "#projekte", label: "Projekte", visible: show("projects") },
|
||||||
|
{ href: "#ablauf", label: "Ablauf", visible: show("process") },
|
||||||
|
{ href: "#kontakt", label: "Kontakt", visible: true },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={handwerk} content={content} fonts={fonts[content.fonts] ?? fonts.kraeftig}>
|
||||||
|
<Header brand={brand} cta={hero.cta} links={links} />
|
||||||
|
|
||||||
|
<section id="top" className="relative overflow-hidden pt-36 pb-20 md:pt-48 md:pb-28">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 [mask-image:linear-gradient(to_bottom,black,transparent_85%)] bg-[size:72px_72px] bg-[linear-gradient(to_right,color-mix(in_oklab,var(--t-fg)_7%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--t-fg)_7%,transparent)_1px,transparent_1px)]"
|
||||||
|
/>
|
||||||
|
<div className={cn(container, "relative grid items-end gap-14 md:grid-cols-12 md:gap-10")}>
|
||||||
|
<div className="md:col-span-7">
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease, delay: 0.2 }}
|
||||||
|
className="mb-8 inline-flex items-center gap-3 border border-t-fg/15 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em]"
|
||||||
|
>
|
||||||
|
<span className="size-2 animate-pulse bg-t-accent" />
|
||||||
|
{hero.trust}
|
||||||
|
</motion.p>
|
||||||
|
<RevealWords
|
||||||
|
as="h1"
|
||||||
|
onMount
|
||||||
|
delay={0.3}
|
||||||
|
text={hero.title}
|
||||||
|
emClassName={em}
|
||||||
|
className={cn(display, "text-[clamp(3.6rem,11vw,11.5rem)] leading-[0.84] tracking-[-0.01em]")}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.9, ease, delay: 0.9 }}
|
||||||
|
>
|
||||||
|
<p className="mt-10 max-w-xl text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
||||||
|
<div className="mt-10 flex flex-wrap items-center gap-8">
|
||||||
|
<BlockButton href="#kontakt">{hero.cta}</BlockButton>
|
||||||
|
{show("projects") && (
|
||||||
|
<a href="#projekte" className="border-b-2 border-t-accent pb-1 text-sm font-semibold uppercase tracking-wider">
|
||||||
|
{hero.ctaSecondary}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative md:col-span-5">
|
||||||
|
<motion.div
|
||||||
|
initial={{ clipPath: "inset(100% 0% 0% 0%)" }}
|
||||||
|
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
|
||||||
|
transition={{ duration: 1.4, ease, delay: 0.3 }}
|
||||||
|
className="relative aspect-[4/5] overflow-hidden"
|
||||||
|
>
|
||||||
|
<motion.div className="absolute inset-0" initial={{ scale: 1.3 }} animate={{ scale: 1 }} transition={{ duration: 1.8, ease, delay: 0.3 }}>
|
||||||
|
<TImage src={hero.image} eager sizes="(min-width: 768px) 40vw, 100vw" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.9, ease, delay: 1.1 }}
|
||||||
|
className="absolute -bottom-8 -left-2 bg-t-accent p-6 text-t-accent-fg md:-left-16 md:p-8"
|
||||||
|
>
|
||||||
|
<p className={cn(display, "text-6xl leading-none md:text-7xl")}>
|
||||||
|
{hero.badgeValue}
|
||||||
|
{hero.badgeSuffix}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs font-semibold uppercase tracking-[0.2em]">{hero.badgeLabel}</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("marquee") && (
|
||||||
|
<section aria-label="Leistungsbereiche" className="py-10 md:py-16">
|
||||||
|
<div className="-rotate-[1.5deg] scale-[1.04] bg-t-fg py-5 text-t-bg md:py-7">
|
||||||
|
<Marquee duration={30}>
|
||||||
|
{marquee.items.map((item, i) => (
|
||||||
|
<span key={i} className="flex items-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
display,
|
||||||
|
"px-6 text-5xl md:px-10 md:text-8xl",
|
||||||
|
i % 2 === 1 && "text-transparent [-webkit-text-stroke:1.5px_var(--t-bg)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
<span className="size-3 bg-t-accent md:size-4" />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("services") && (
|
||||||
|
<section id="leistungen" className={cn(container, "py-24 md:py-36")}>
|
||||||
|
<div className="mb-16 grid gap-8 md:grid-cols-12">
|
||||||
|
<div className="md:col-span-6">
|
||||||
|
<Reveal><Eyebrow>{services.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={services.title} emClassName={em} className={cn(display, "text-[clamp(2.8rem,6.5vw,6.5rem)] leading-[0.88]")} />
|
||||||
|
</div>
|
||||||
|
<Reveal delay={0.15} className="self-end md:col-span-5 md:col-start-8">
|
||||||
|
<p className="text-lg leading-relaxed text-t-muted">{services.text}</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
<ul className="border-t-2 border-t-fg">
|
||||||
|
{services.items.map((item, i) => (
|
||||||
|
<motion.li
|
||||||
|
key={i}
|
||||||
|
{...fadeUp}
|
||||||
|
transition={{ duration: 0.8, ease, delay: i * 0.05 }}
|
||||||
|
className="group relative overflow-hidden border-b border-t-fg/15"
|
||||||
|
>
|
||||||
|
<span className="absolute inset-0 origin-bottom scale-y-0 bg-t-accent transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-y-100" />
|
||||||
|
<div className="relative grid gap-3 py-8 transition-colors duration-500 group-hover:text-t-accent-fg md:grid-cols-12 md:items-center md:gap-6 md:py-10">
|
||||||
|
<span className={cn(display, "text-lg md:col-span-1")}>{String(i + 1).padStart(2, "0")}</span>
|
||||||
|
<h3 className={cn(display, "text-3xl leading-none md:col-span-5 md:text-5xl")}>{item.title}</h3>
|
||||||
|
<p className="text-t-muted transition-colors duration-500 group-hover:text-current md:col-span-5">{item.text}</p>
|
||||||
|
<ArrowUpRightIcon className="hidden size-9 justify-self-end transition-transform duration-500 group-hover:rotate-45 md:col-span-1 md:block" />
|
||||||
|
</div>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("stats") && (
|
||||||
|
<section className="bg-t-fg py-20 text-t-bg md:py-28">
|
||||||
|
<div className={cn(container, "grid grid-cols-2 gap-y-14 md:grid-cols-4")}>
|
||||||
|
{stats.items.map((stat, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.1} className="border-l border-t-bg/20 pl-5 md:pl-8">
|
||||||
|
<p className={cn(display, "text-6xl leading-none md:text-8xl")}>
|
||||||
|
<Counter value={stat.value} />
|
||||||
|
<span className="text-t-accent">{stat.suffix}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/60">{stat.label}</p>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("projects") && (
|
||||||
|
<section id="projekte" className={cn(container, "py-24 md:py-36")}>
|
||||||
|
<Reveal><Eyebrow>{projects.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={projects.title} emClassName={em} className={cn(display, "mb-14 text-[clamp(2.8rem,6.5vw,6.5rem)] leading-[0.88] md:mb-20")} />
|
||||||
|
<div className="grid gap-x-6 gap-y-12 md:grid-cols-12">
|
||||||
|
{projects.items.map((project, i) => {
|
||||||
|
const layout = projectLayout[i % projectLayout.length]
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={(i % 2) * 0.12} className={layout.col}>
|
||||||
|
<a href="#kontakt" className="group block">
|
||||||
|
<div className={cn("relative overflow-hidden bg-t-surface", layout.aspect)}>
|
||||||
|
<TImage
|
||||||
|
src={project.image}
|
||||||
|
alt={project.title}
|
||||||
|
sizes="(min-width: 768px) 60vw, 100vw"
|
||||||
|
className="transition-transform duration-[1.2s] ease-out group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<span className="absolute top-4 right-4 grid size-14 scale-0 place-items-center bg-t-accent text-t-accent-fg transition-transform duration-500 group-hover:scale-100">
|
||||||
|
<ArrowUpRightIcon className="size-6" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1 border-b border-t-fg/15 pb-5">
|
||||||
|
<h3 className={cn(display, "text-2xl md:text-4xl")}>{project.title}</h3>
|
||||||
|
<span className="text-sm font-medium uppercase tracking-wider text-t-muted">{project.meta}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("process") && <Process process={process} />}
|
||||||
|
|
||||||
|
{show("testimonial") && (
|
||||||
|
<section className={cn(container, "grid gap-8 py-24 md:grid-cols-12 md:py-36")}>
|
||||||
|
<Reveal className="md:col-span-2">
|
||||||
|
<span aria-hidden className={cn(display, "block text-[9rem] leading-[0.7] text-t-accent")}>“</span>
|
||||||
|
</Reveal>
|
||||||
|
<div className="md:col-span-10">
|
||||||
|
<RevealWords
|
||||||
|
as="p"
|
||||||
|
text={testimonial.text}
|
||||||
|
className="font-t-display text-[clamp(1.75rem,3.6vw,3.4rem)] leading-[1.08] font-semibold tracking-[-0.01em]"
|
||||||
|
/>
|
||||||
|
<Reveal delay={0.3}>
|
||||||
|
<p className="mt-10 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
|
<span className="h-0.5 w-12 bg-t-accent" />
|
||||||
|
<span className="font-semibold uppercase tracking-wider">{testimonial.author}</span>
|
||||||
|
<span className="text-t-muted">{testimonial.role}</span>
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section id="kontakt" className="bg-t-fg text-t-bg">
|
||||||
|
<div className={cn(container, "grid gap-16 py-24 md:grid-cols-12 md:py-36")}>
|
||||||
|
<div className="md:col-span-5">
|
||||||
|
<Reveal><Eyebrow inverted>{contact.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={contact.title} emClassName={em} className={cn(display, "text-[clamp(2.8rem,6vw,6rem)] leading-[0.88]")} />
|
||||||
|
<Reveal delay={0.15}>
|
||||||
|
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-bg/70">{contact.text}</p>
|
||||||
|
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className={cn(display, "mt-12 block text-4xl transition-colors hover:text-t-accent md:text-5xl")}>
|
||||||
|
{brand.phone}
|
||||||
|
</a>
|
||||||
|
<dl className="mt-10 grid gap-5 text-sm">
|
||||||
|
{[
|
||||||
|
["E-Mail", contact.email],
|
||||||
|
["Adresse", contact.address],
|
||||||
|
["Erreichbar", contact.hours],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label} className="grid grid-cols-[7rem_1fr] gap-4 border-t border-t-bg/15 pt-5">
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/50">{label}</dt>
|
||||||
|
<dd>{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reveal delay={0.1} className="md:col-span-6 md:col-start-7">
|
||||||
|
<DemoForm
|
||||||
|
className="border border-t-bg/15 p-6 md:p-10"
|
||||||
|
success={
|
||||||
|
<div className="py-10">
|
||||||
|
<p className={cn(display, "text-5xl text-t-accent")}>Danke!</p>
|
||||||
|
<p className="mt-4 text-t-bg/70">Wir melden uns innerhalb von 24 Stunden mit einem Terminvorschlag.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={cn(display, "text-3xl")}>Projekt anfragen</p>
|
||||||
|
<div className="mt-8 grid gap-7 sm:grid-cols-2">
|
||||||
|
<Line label="Name" name="name" autoComplete="name" required />
|
||||||
|
<Line label="Telefon" name="phone" type="tel" autoComplete="tel" required />
|
||||||
|
<Line label="E-Mail" name="email" type="email" autoComplete="email" className="sm:col-span-2" />
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/50">Art des Projekts</span>
|
||||||
|
<select
|
||||||
|
name="type"
|
||||||
|
className="mt-2 w-full border-b border-t-bg/25 bg-transparent py-3 text-base outline-none focus:border-t-accent [&>option]:text-t-fg"
|
||||||
|
>
|
||||||
|
{services.items.map((item, i) => (
|
||||||
|
<option key={i}>{item.title}</option>
|
||||||
|
))}
|
||||||
|
<option>Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/50">Ihr Vorhaben</span>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
rows={4}
|
||||||
|
className="mt-2 w-full resize-none border-b border-t-bg/25 bg-transparent py-3 text-base outline-none focus:border-t-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="group mt-10 inline-flex w-full cursor-pointer items-stretch">
|
||||||
|
<span className={cn(display, "flex-1 bg-t-accent px-6 py-4 text-left tracking-wider text-t-accent-fg")}>Anfrage senden</span>
|
||||||
|
<span className="grid place-items-center bg-t-bg px-5 text-t-fg">
|
||||||
|
<ArrowUpRightIcon className="size-5 transition-transform duration-500 group-hover:rotate-45" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</DemoForm>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="border-t border-t-bg/15">
|
||||||
|
<div className={cn(container, "flex flex-col gap-6 py-10 text-sm text-t-bg/60 md:flex-row md:items-center md:justify-between")}>
|
||||||
|
<Logo brand={brand} />
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<a href="#" className="hover:text-t-bg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-bg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Logo({ brand }: { brand: HandwerkData["brand"] }) {
|
||||||
|
return (
|
||||||
|
<a href="#top" className="flex items-center gap-3">
|
||||||
|
<span className={cn(display, "grid size-10 place-items-center bg-t-accent text-xl text-t-accent-fg")}>
|
||||||
|
{brand.name.trim().charAt(0)}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col leading-none">
|
||||||
|
<span className={cn(display, "text-2xl text-current")}>{brand.name}</span>
|
||||||
|
<span className="mt-1 text-[10px] font-semibold uppercase tracking-[0.25em] opacity-60">{brand.suffix}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ brand, cta, links }: { brand: HandwerkData["brand"]; cta: string; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||||
|
transition={{ duration: 0.45, ease }}
|
||||||
|
className="fixed inset-x-0 top-0 z-50"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{ height: scrolled ? 0 : "auto" }}
|
||||||
|
transition={{ duration: 0.4, ease }}
|
||||||
|
className="overflow-hidden bg-t-accent text-t-accent-fg"
|
||||||
|
>
|
||||||
|
<div className={cn(container, "flex items-center justify-between py-2 text-xs font-semibold uppercase tracking-[0.18em]")}>
|
||||||
|
<span className="truncate">{brand.claim}</span>
|
||||||
|
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="shrink-0">{brand.phone}</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
<div className={cn("border-b transition-colors duration-500", scrolled ? "border-t-fg/10 bg-t-bg/90 backdrop-blur-xl" : "border-transparent")}>
|
||||||
|
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
|
||||||
|
<Logo brand={brand} />
|
||||||
|
<div className="hidden items-center gap-9 md:flex">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="group relative text-sm font-semibold uppercase tracking-wider">
|
||||||
|
{link.label}
|
||||||
|
<span className="absolute -bottom-1.5 left-0 h-0.5 w-full origin-right scale-x-0 bg-t-accent transition-transform duration-500 group-hover:origin-left group-hover:scale-x-100" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a href="#kontakt" className={cn(display, "hidden bg-t-fg px-5 py-3 text-sm tracking-wider text-t-bg transition-colors hover:bg-t-accent hover:text-t-accent-fg sm:block")}>
|
||||||
|
{cta}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Process({ process }: { process: HandwerkData["process"] }) {
|
||||||
|
const ref = useRef<HTMLElement>(null)
|
||||||
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.75", "end 0.75"] })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} id="ablauf" className="bg-t-surface py-24 md:py-36">
|
||||||
|
<div className={container}>
|
||||||
|
<Reveal><Eyebrow>{process.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={process.title} emClassName={em} className={cn(display, "text-[clamp(2.8rem,6.5vw,6.5rem)] leading-[0.88]")} />
|
||||||
|
<div className="relative mt-16 md:mt-24">
|
||||||
|
<div className="absolute top-[26px] right-0 left-0 hidden h-0.5 bg-t-fg/10 md:block" />
|
||||||
|
<motion.div style={{ scaleX: scrollYProgress }} className="absolute top-[26px] right-0 left-0 hidden h-0.5 origin-left bg-t-accent md:block" />
|
||||||
|
<ol className="grid gap-12 md:grid-cols-4 md:gap-8">
|
||||||
|
{process.steps.map((step, i) => (
|
||||||
|
<motion.li key={i} {...fadeUp} transition={{ duration: 0.8, ease, delay: i * 0.12 }}>
|
||||||
|
<span className={cn(display, "relative grid size-[54px] place-items-center bg-t-fg text-xl text-t-bg")}>{i + 1}</span>
|
||||||
|
<h3 className={cn(display, "mt-8 text-3xl")}>{step.title}</h3>
|
||||||
|
<p className="mt-3 leading-relaxed text-t-muted">{step.text}</p>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Line({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
||||||
|
return (
|
||||||
|
<label className={cn("block", className)}>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/50">{label}</span>
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
className="mt-2 w-full border-b border-t-bg/25 bg-transparent py-3 text-base outline-none transition-colors focus:border-t-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
modules/templates/catalog/immobilien/definition.ts
Normal file
91
modules/templates/catalog/immobilien/definition.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
brand: { name: "Kessler", suffix: "Immobilien", phone: "040 123 456 78", email: "kontakt@kessler-immobilien.de", address: "Neuer Wall 18, 20354 Hamburg" },
|
||||||
|
hero: { eyebrow: "Exklusive Immobilien in Hamburg seit 1994", title: "Räume,\ndie *bleiben.*", cta: "Objekte entdecken" },
|
||||||
|
slides: {
|
||||||
|
items: [
|
||||||
|
{ title: "Villa am Elbhang", location: "Hamburg-Blankenese", image: unsplash("1613490493576-7fde63acd811") },
|
||||||
|
{ title: "Penthouse mit Weitblick", location: "Hamburg-Hafencity", image: unsplash("1600573472550-8090b5e0745e") },
|
||||||
|
{ title: "Stadtvilla mit Garten", location: "Hamburg-Harvestehude", image: unsplash("1600607688969-a5bfcd646154") },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
listings: {
|
||||||
|
eyebrow: "Aktuelle Objekte",
|
||||||
|
title: "Ausgewählte *Angebote*",
|
||||||
|
items: [
|
||||||
|
{ title: "Moderne Villa mit Pool", location: "Blankenese", type: "Kaufen", price: "3.450.000 €", sqm: "320", rooms: "7", image: unsplash("1600596542815-ffad4c1539a9", 1200) },
|
||||||
|
{ title: "Lichtdurchflutetes Loft", location: "Ottensen", type: "Mieten", price: "3.200 € / Monat", sqm: "145", rooms: "4", image: unsplash("1600566753086-00f18fb6b3ea", 1200) },
|
||||||
|
{ title: "Penthouse mit Dachterrasse", location: "Hafencity", type: "Kaufen", price: "2.150.000 €", sqm: "180", rooms: "5", image: unsplash("1560448204-e02f11c3d0e2", 1200) },
|
||||||
|
{ title: "Familienhaus am Park", location: "Eppendorf", type: "Kaufen", price: "1.890.000 €", sqm: "210", rooms: "6", image: unsplash("1600047509358-9dc75507daeb", 1200) },
|
||||||
|
{ title: "Altbauwohnung mit Stuck", location: "Winterhude", type: "Mieten", price: "2.400 € / Monat", sqm: "118", rooms: "3,5", image: unsplash("1505691938895-1758d7feb511", 1200) },
|
||||||
|
{ title: "Neubauwohnung mit Loggia", location: "Uhlenhorst", type: "Mieten", price: "1.950 € / Monat", sqm: "92", rooms: "3", image: unsplash("1493809842364-78817add7ffb", 1200) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
eyebrow: "Über uns",
|
||||||
|
title: "Diskret. Persönlich.\n*Erfolgreich.*",
|
||||||
|
text: "Seit drei Jahrzehnten vermitteln wir besondere Immobilien in Hamburg und an der Küste – mit einem Netzwerk vorgemerkter Käufer:innen, ehrlichen Wertermittlungen und Exposés, die Lust auf Besichtigungen machen.",
|
||||||
|
image: unsplash("1600607687644-c7171b42498f", 1400),
|
||||||
|
facts: [
|
||||||
|
{ value: "30", label: "Jahre am Markt" },
|
||||||
|
{ value: "1.200+", label: "vermittelte Objekte" },
|
||||||
|
{ value: "8", label: "Wochen Ø Vermarktung" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
eyebrow: "Referenzen",
|
||||||
|
title: "Projekte, auf die wir *stolz* sind.",
|
||||||
|
items: [
|
||||||
|
{ title: "Haus am See", place: "Plön", year: "2025", image: unsplash("1613977257363-707ba9348227", 1200) },
|
||||||
|
{ title: "Wohnensemble Alster", place: "Hamburg", year: "2024", image: unsplash("1545324418-cc1a3fa10c00", 1200) },
|
||||||
|
{ title: "Villa Bianca", place: "Sylt", year: "2023", image: unsplash("1580587771525-78b9dba3b914", 1200) },
|
||||||
|
{ title: "Kontorhaus Neu", place: "Hamburg", year: "2022", image: unsplash("1487958449943-2429e8be8625", 1200) },
|
||||||
|
{ title: "Pavillon Süd", place: "Lübeck", year: "2021", image: unsplash("1512917774080-9991f1c4c750", 1200) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
valuation: {
|
||||||
|
eyebrow: "Wertermittlung",
|
||||||
|
title: "Was ist Ihre Immobilie *wert?*",
|
||||||
|
text: "Kostenlose und unverbindliche Einschätzung durch unsere Expert:innen – innerhalb von 48 Stunden.",
|
||||||
|
image: unsplash("1502005229762-cf1b2da7c5d6"),
|
||||||
|
},
|
||||||
|
testimonial: {
|
||||||
|
quote: "Unser Haus war in fünf Wochen verkauft – über dem Angebotspreis und an Menschen, die wirklich passten. Diskreter und professioneller geht es nicht.",
|
||||||
|
author: "Familie Albrecht, Verkauf in Blankenese",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const immobilien = defineTemplate({
|
||||||
|
key: "immobilien",
|
||||||
|
name: "Residenz",
|
||||||
|
industry: "Immobilien & Architektur",
|
||||||
|
description: "Luxuriös und ruhig: Vollbild-Slider mit Ken-Burns-Effekt, Suche mit filterbaren Objekten, Referenz-Index mit Bildvorschau und Wertermittlung.",
|
||||||
|
cover: unsplash("1613490493576-7fde63acd811", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Guten Tag, wir möchten unser Einfamilienhaus in Hamburg-Volksdorf verkaufen (Baujahr 1998, 160 m² Wohnfläche, 700 m² Grundstück). Können Sie den Wert einschätzen, und wie hoch wäre Ihre Provision? Freundliche Grüße, Familie Peters",
|
||||||
|
topic: "Neu im Angebot: Penthouse mit Elbblick in der Hafencity",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "onyx", label: "Onyx", dark: true, bg: "#0d0d0c", fg: "#f1ede6", muted: "#9a958c", surface: "#171715", accent: "#b8955f" },
|
||||||
|
{ key: "kalk", label: "Kalk", bg: "#f4f1ec", fg: "#161513", muted: "#6e6a63", surface: "#e9e4dc", accent: "#8c6a3f" },
|
||||||
|
{ key: "salbei", label: "Salbei", bg: "#eef0eb", fg: "#1b241f", muted: "#66716a", surface: "#e0e4dc", accent: "#5e7a5a" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "elegant", label: "Italiana & Montserrat" },
|
||||||
|
{ key: "modern", label: "Tenor Sans & Hanken Grotesk" },
|
||||||
|
],
|
||||||
|
sections: describeSections(defaults, {
|
||||||
|
brand: "Makler & Kontakt",
|
||||||
|
hero: "Startbereich",
|
||||||
|
slides: { label: "Slider", items: { items: "Slide" } },
|
||||||
|
listings: { label: "Objekte", optional: true, items: { items: "Objekt" } },
|
||||||
|
about: { label: "Über uns", optional: true, items: { facts: "Kennzahl" } },
|
||||||
|
projects: { label: "Referenzen", optional: true, items: { items: "Projekt" } },
|
||||||
|
valuation: "Wertermittlung",
|
||||||
|
testimonial: { label: "Kundenstimme", optional: true },
|
||||||
|
}),
|
||||||
|
defaults,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ImmobilienData = typeof immobilien.defaults
|
||||||
13
modules/templates/catalog/immobilien/fonts.ts
Normal file
13
modules/templates/catalog/immobilien/fonts.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Hanken_Grotesk, Italiana, Montserrat, Tenor_Sans } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts
|
||||||
|
const italiana = Italiana({ subsets: ["latin"], weight: "400", variable: "--font-immo-italiana", preload: false })
|
||||||
|
const montserrat = Montserrat({ subsets: ["latin"], variable: "--font-immo-montserrat", preload: false })
|
||||||
|
const tenor = Tenor_Sans({ subsets: ["latin"], weight: "400", variable: "--font-immo-tenor", preload: false })
|
||||||
|
const hanken = Hanken_Grotesk({ subsets: ["latin"], variable: "--font-immo-hanken", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
elegant: { className: `${italiana.variable} ${montserrat.variable}`, display: "var(--font-immo-italiana)", body: "var(--font-immo-montserrat)" },
|
||||||
|
modern: { className: `${tenor.variable} ${hanken.variable}`, display: "var(--font-immo-tenor)", body: "var(--font-immo-hanken)" },
|
||||||
|
}
|
||||||
353
modules/templates/catalog/immobilien/template.tsx
Normal file
353
modules/templates/catalog/immobilien/template.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { ArrowRightIcon, BedDoubleIcon, ChevronLeftIcon, ChevronRightIcon, MapPinIcon, RulerIcon, SearchIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { DemoForm } from "../shared/demo-form"
|
||||||
|
import { TField, TSelect } from "../shared/fields"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { ParallaxImage, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { immobilien, type ImmobilienData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "italic text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1400px] px-5 md:px-10"
|
||||||
|
const heading = "font-t-display font-normal tracking-[-0.01em]"
|
||||||
|
const label = "text-[11px] uppercase tracking-[0.3em]"
|
||||||
|
const TYPES = ["Alle", "Kaufen", "Mieten"]
|
||||||
|
const ALL_PLACES = "Alle Lagen"
|
||||||
|
|
||||||
|
function Eyebrow({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<p className={cn(label, "mb-6 flex items-center gap-4 text-t-muted")}>
|
||||||
|
<span className="h-px w-12 bg-t-accent" />
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImmobilienTemplate({ content }: TemplateProps<ImmobilienData>) {
|
||||||
|
const { brand, hero, slides, listings, about, projects, valuation, testimonial } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const [type, setType] = useState("Alle")
|
||||||
|
const [place, setPlace] = useState(ALL_PLACES)
|
||||||
|
const places = [ALL_PLACES, ...new Set(listings.items.map((item) => item.location))]
|
||||||
|
const links = [
|
||||||
|
{ href: "#objekte", label: "Objekte", visible: show("listings") },
|
||||||
|
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
|
||||||
|
{ href: "#referenzen", label: "Referenzen", visible: show("projects") },
|
||||||
|
{ href: "#bewertung", label: "Wertermittlung", visible: true },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={immobilien} content={content} fonts={fonts[content.fonts] ?? fonts.elegant}>
|
||||||
|
<Nav brand={brand} links={links} />
|
||||||
|
<HeroSlider hero={hero} slides={slides.items} />
|
||||||
|
|
||||||
|
{show("listings") && (
|
||||||
|
<div className={cn(container, "relative z-10 -mt-12")}>
|
||||||
|
<Reveal>
|
||||||
|
<div className="grid gap-4 bg-t-bg p-5 shadow-[0_30px_80px_-30px_rgba(0,0,0,0.45)] ring-1 ring-t-fg/10 md:grid-cols-[auto_1fr_auto] md:items-end md:p-6">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TYPES.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={type === option}
|
||||||
|
onClick={() => setType(option)}
|
||||||
|
className={cn("cursor-pointer px-4 py-3 text-sm transition-colors", type === option ? "bg-t-fg text-t-bg" : "hover:bg-t-surface")}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<TSelect label="Lage" variant="line" value={place} onChange={(event) => setPlace(event.target.value)} options={places} />
|
||||||
|
<a href="#objekte" className="flex items-center justify-center gap-2 bg-t-accent px-6 py-3.5 text-sm text-t-accent-fg">
|
||||||
|
<SearchIcon className="size-4" /> Objekte anzeigen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("listings") && <Listings listings={listings} type={type} place={place} onType={setType} onReset={() => setPlace(ALL_PLACES)} />}
|
||||||
|
|
||||||
|
{show("about") && (
|
||||||
|
<section id="ueber-uns" className={cn(container, "grid items-center gap-14 py-24 md:grid-cols-2 md:py-36")}>
|
||||||
|
<ParallaxImage src={about.image} offset={50} className="aspect-[4/5]" sizes="(min-width: 768px) 45vw, 100vw" />
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.75rem)] leading-[1.02]")} />
|
||||||
|
<Reveal delay={0.1}><p className="mt-8 text-lg leading-relaxed text-t-muted">{about.text}</p></Reveal>
|
||||||
|
<dl className="mt-12 grid grid-cols-3 border-t border-t-fg/15">
|
||||||
|
{about.facts.map((fact, i) => (
|
||||||
|
<Reveal key={i} delay={0.15 + i * 0.08} className={cn("pt-6", i > 0 && "border-l border-t-fg/15 pl-5")}>
|
||||||
|
<dd className={cn(heading, "text-4xl text-t-accent md:text-5xl")}>{fact.value}</dd>
|
||||||
|
<dt className="mt-2 text-xs text-t-muted">{fact.label}</dt>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("projects") && <ProjectIndex projects={projects} />}
|
||||||
|
|
||||||
|
<section id="bewertung" className="relative overflow-hidden py-24 md:py-36">
|
||||||
|
<TImage src={valuation.image} sizes="100vw" />
|
||||||
|
<div className="absolute inset-0 bg-black/60" />
|
||||||
|
<div className={cn(container, "relative grid items-center gap-12 text-white lg:grid-cols-2")}>
|
||||||
|
<div>
|
||||||
|
<p className={cn(label, "mb-6 flex items-center gap-4 text-white/70")}><span className="h-px w-12 bg-t-accent" />{valuation.eyebrow}</p>
|
||||||
|
<RevealWords text={valuation.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5.5vw,5rem)] leading-[1.02]")} />
|
||||||
|
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg leading-relaxed text-white/80">{valuation.text}</p></Reveal>
|
||||||
|
</div>
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<DemoForm
|
||||||
|
className="grid gap-5 bg-t-bg p-7 text-t-fg sm:grid-cols-2 md:p-10"
|
||||||
|
success={
|
||||||
|
<div className="py-8">
|
||||||
|
<p className={cn(heading, "text-4xl")}>Vielen <span className={em}>Dank.</span></p>
|
||||||
|
<p className="mt-3 text-t-muted">Wir melden uns innerhalb von 48 Stunden mit Ihrer Einschätzung.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TSelect label="Objektart" name="kind" variant="line" options={["Wohnung", "Haus", "Grundstück", "Gewerbe"]} />
|
||||||
|
<TField label="PLZ" name="zip" inputMode="numeric" required variant="line" />
|
||||||
|
<TField label="Wohnfläche in m²" name="area" inputMode="numeric" variant="line" />
|
||||||
|
<TField label="Baujahr" name="year" inputMode="numeric" variant="line" />
|
||||||
|
<TField label="Name" name="name" required autoComplete="name" variant="line" />
|
||||||
|
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="line" />
|
||||||
|
<button type="submit" className="flex cursor-pointer items-center justify-center gap-2 bg-t-fg py-4 text-sm text-t-bg transition-colors hover:bg-t-accent hover:text-t-accent-fg sm:col-span-2">
|
||||||
|
Kostenlos bewerten lassen <ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</DemoForm>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("testimonial") && (
|
||||||
|
<section className="mx-auto max-w-[1000px] px-5 py-24 text-center md:py-36">
|
||||||
|
<RevealWords as="p" text={testimonial.quote} className={cn(heading, "text-[clamp(1.75rem,3.6vw,3.25rem)] leading-[1.2]")} />
|
||||||
|
<Reveal delay={0.2}><p className={cn(label, "mt-10 text-t-muted")}>{testimonial.author}</p></Reveal>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="border-t border-t-fg/10">
|
||||||
|
<div className={cn(container, "grid gap-10 py-14 text-sm text-t-muted md:grid-cols-4")}>
|
||||||
|
<div>
|
||||||
|
<p className={cn(heading, "text-2xl tracking-[0.3em] text-t-fg uppercase")}>{brand.name}</p>
|
||||||
|
<p className={cn(label, "mt-2")}>{brand.suffix}</p>
|
||||||
|
</div>
|
||||||
|
<p>{brand.address}</p>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="hover:text-t-fg">{brand.phone}</a>
|
||||||
|
<a href={`mailto:${brand.email}`} className="hover:text-t-fg">{brand.email}</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 md:justify-end">
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ brand, links }: { brand: ImmobilienData["brand"]; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||||
|
transition={{ duration: 0.45, ease }}
|
||||||
|
className={cn("fixed inset-x-0 top-0 z-50 transition-colors duration-500", scrolled ? "bg-t-bg/90 text-t-fg backdrop-blur-xl" : "text-white")}
|
||||||
|
>
|
||||||
|
<nav className={cn(container, "flex h-24 items-center justify-between gap-8")}>
|
||||||
|
<a href="#top" className="leading-none">
|
||||||
|
<span className={cn(heading, "block text-2xl tracking-[0.35em] uppercase")}>{brand.name}</span>
|
||||||
|
<span className={cn(label, "mt-1.5 block opacity-70")}>{brand.suffix}</span>
|
||||||
|
</a>
|
||||||
|
<div className={cn(label, "hidden items-center gap-9 lg:flex")}>
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="opacity-80 transition-opacity hover:opacity-100">{link.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="hidden border border-current/40 px-5 py-3 text-sm transition-colors hover:border-t-accent hover:bg-t-accent hover:text-t-accent-fg sm:block">
|
||||||
|
{brand.phone}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroSlider({ hero, slides }: { hero: ImmobilienData["hero"]; slides: ImmobilienData["slides"]["items"] }) {
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
const count = Math.max(slides.length, 1)
|
||||||
|
const current = index % count
|
||||||
|
const slide = slides[current]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (count < 2) return
|
||||||
|
const timer = setTimeout(() => setIndex((i) => (i + 1) % count), 6500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [index, count])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="top" className="relative h-svh min-h-[640px] overflow-hidden bg-black text-white">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
<motion.div key={current} className="absolute inset-0" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 1.4, ease: "easeInOut" }}>
|
||||||
|
<motion.div className="absolute inset-0" initial={{ scale: 1.12 }} animate={{ scale: 1 }} transition={{ duration: 7.5, ease: "linear" }}>
|
||||||
|
<TImage src={slide?.image ?? ""} eager={current === 0} sizes="100vw" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/25 to-black/45" />
|
||||||
|
|
||||||
|
<div className={cn(container, "relative flex h-full flex-col justify-end pb-28 md:pb-32")}>
|
||||||
|
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.3 }} className={cn(label, "mb-6 text-white/75")}>
|
||||||
|
{hero.eyebrow}
|
||||||
|
</motion.p>
|
||||||
|
<RevealWords as="h1" onMount delay={0.4} text={hero.title} emClassName="italic text-t-accent" className={cn(heading, "text-[clamp(3.2rem,9vw,9.5rem)] leading-[0.95]")} />
|
||||||
|
<div className="mt-12 flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
|
||||||
|
<a href="#objekte" className="group flex w-fit items-center gap-4 text-sm tracking-wide">
|
||||||
|
<span className="grid size-14 place-items-center rounded-full border border-white/40 transition-colors group-hover:border-t-accent group-hover:bg-t-accent group-hover:text-t-accent-fg">
|
||||||
|
<ArrowRightIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
{hero.cta}
|
||||||
|
</a>
|
||||||
|
{slide && (
|
||||||
|
<div className="flex items-end gap-8">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div key={current} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.6, ease }} className="text-right">
|
||||||
|
<p className={cn(label, "text-white/60")}>{slide.location}</p>
|
||||||
|
<p className={cn(heading, "mt-1 text-2xl md:text-3xl")}>{slide.title}</p>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" aria-label="Vorheriges Objekt" onClick={() => setIndex((current - 1 + count) % count)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-white/30 hover:bg-white/10">
|
||||||
|
<ChevronLeftIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label="Nächstes Objekt" onClick={() => setIndex((current + 1) % count)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-white/30 hover:bg-white/10">
|
||||||
|
<ChevronRightIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 flex gap-2">
|
||||||
|
{slides.map((_, i) => (
|
||||||
|
<span key={i} className="h-0.5 flex-1 overflow-hidden bg-white/25">
|
||||||
|
{i === current && <motion.span key={index} className="block h-full bg-t-accent" initial={{ width: "0%" }} animate={{ width: "100%" }} transition={{ duration: 6.5, ease: "linear" }} />}
|
||||||
|
{i < current && <span className="block h-full w-full bg-white/70" />}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Listings({ listings, type, place, onType, onReset }: {
|
||||||
|
listings: ImmobilienData["listings"]
|
||||||
|
type: string
|
||||||
|
place: string
|
||||||
|
onType: (type: string) => void
|
||||||
|
onReset: () => void
|
||||||
|
}) {
|
||||||
|
const visible = listings.items.filter((item) => (type === "Alle" || item.type === type) && (place === ALL_PLACES || item.location === place))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="objekte" className={cn(container, "py-24 md:py-32")}>
|
||||||
|
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{listings.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={listings.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.75rem)] leading-[1.02]")} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{TYPES.map((option) => (
|
||||||
|
<button key={option} type="button" aria-pressed={type === option} onClick={() => onType(option)} className={cn(label, "cursor-pointer border px-4 py-2.5 transition-colors", type === option ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/20 hover:border-t-fg")}>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<motion.div layout className="mt-14 grid gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{visible.map((item) => (
|
||||||
|
<motion.article
|
||||||
|
layout
|
||||||
|
key={item.title}
|
||||||
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.5, ease }}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden bg-t-surface">
|
||||||
|
<TImage src={item.image} alt={item.title} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="transition-transform duration-[1.2s] ease-out group-hover:scale-105" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||||
|
<span className={cn(label, "absolute top-4 left-4 bg-t-bg/90 px-3 py-1.5 text-t-fg")}>{item.type}</span>
|
||||||
|
<p className={cn(heading, "absolute bottom-4 left-4 text-2xl text-white")}>{item.price}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className={cn(heading, "mt-5 text-2xl")}>{item.title}</h3>
|
||||||
|
<p className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-sm text-t-muted">
|
||||||
|
<span className="flex items-center gap-1.5"><RulerIcon className="size-4" /> {item.sqm} m²</span>
|
||||||
|
<span className="flex items-center gap-1.5"><BedDoubleIcon className="size-4" /> {item.rooms} Zimmer</span>
|
||||||
|
<span className="flex items-center gap-1.5"><MapPinIcon className="size-4" /> {item.location}</span>
|
||||||
|
</p>
|
||||||
|
</motion.article>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<p className="mt-10 text-t-muted">
|
||||||
|
Aktuell keine passenden Objekte.{" "}
|
||||||
|
<button type="button" onClick={onReset} className="cursor-pointer text-t-accent underline-offset-4 hover:underline">Alle Lagen zeigen</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectIndex({ projects }: { projects: ImmobilienData["projects"] }) {
|
||||||
|
const [active, setActive] = useState(0)
|
||||||
|
const current = projects.items[Math.min(active, projects.items.length - 1)]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="referenzen" className="bg-t-surface py-24 md:py-36">
|
||||||
|
<div className={container}>
|
||||||
|
<Reveal><Eyebrow>{projects.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={projects.title} emClassName={em} className={cn(heading, "max-w-3xl text-[clamp(2.5rem,5vw,4.75rem)] leading-[1.02]")} />
|
||||||
|
<div className="mt-14 grid gap-10 lg:grid-cols-[1fr_440px]">
|
||||||
|
<ul className="border-t border-t-fg/15">
|
||||||
|
{projects.items.map((project, i) => (
|
||||||
|
<li key={i} onMouseEnter={() => setActive(i)} className="group border-b border-t-fg/15">
|
||||||
|
<div className="flex items-baseline gap-6 py-6 transition-[padding] duration-500 md:group-hover:pl-4">
|
||||||
|
<span className={cn(label, "w-12 text-t-accent")}>{project.year}</span>
|
||||||
|
<span className={cn(heading, "flex-1 text-3xl transition-colors md:text-5xl", active === i ? "text-t-fg" : "text-t-fg/45")}>{project.title}</span>
|
||||||
|
<span className="hidden text-sm text-t-muted sm:block">{project.place}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative mb-6 aspect-[16/10] overflow-hidden lg:hidden">
|
||||||
|
<TImage src={project.image} alt={project.title} sizes="100vw" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<div className="sticky top-32 aspect-[4/5] overflow-hidden bg-t-bg">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
<motion.div key={active} className="absolute inset-0" initial={{ opacity: 0, scale: 1.06 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.7, ease }}>
|
||||||
|
<TImage src={current?.image ?? ""} alt={current?.title} sizes="440px" />
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
modules/templates/catalog/kampagne/definition.ts
Normal file
108
modules/templates/catalog/kampagne/definition.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
brand: { name: "SonnenWerk", suffix: "Solar & Speicher", phone: "0800 123 45 67" },
|
||||||
|
offer: { badge: "Herbstaktion", deadline: "2026-11-30", deadlineLabel: "Aktion endet in" },
|
||||||
|
hero: {
|
||||||
|
title: "Solaranlage zum\n*Aktionspreis.*",
|
||||||
|
text: "Bis zu 70 % weniger Stromkosten – mit Speicher, Montage und 25 Jahren Garantie. Nur für die ersten 50 Anfragen im Aktionszeitraum.",
|
||||||
|
bullets: "Kostenlose Vor-Ort-Beratung\nFestpreis inklusive Montage\nFinanzierung ab 0 % möglich",
|
||||||
|
image: unsplash("1613665813446-82a78c468a1d", 1400),
|
||||||
|
rating: "4,9",
|
||||||
|
reviews: "1.240 Bewertungen",
|
||||||
|
},
|
||||||
|
quiz: {
|
||||||
|
title: "Jetzt Angebot sichern",
|
||||||
|
text: "In 30 Sekunden zum persönlichen Angebot",
|
||||||
|
questions: [
|
||||||
|
{ question: "Welche Art von Gebäude?", options: "Einfamilienhaus | Doppelhaushälfte | Mehrfamilienhaus | Gewerbe" },
|
||||||
|
{ question: "Wie hoch ist Ihr Stromverbrauch?", options: "Unter 3.000 kWh | 3.000–5.000 kWh | Über 5.000 kWh | Weiß nicht" },
|
||||||
|
{ question: "Wann soll es losgehen?", options: "So schnell wie möglich | In 3–6 Monaten | Ich informiere mich nur" },
|
||||||
|
],
|
||||||
|
success: "Geschafft! Wir melden uns innerhalb von 24 Stunden mit Ihrem persönlichen Angebot.",
|
||||||
|
},
|
||||||
|
trust: { items: [{ label: "Geprüfter Meisterbetrieb" }, { label: "25 Jahre Garantie" }, { label: "Förderfähig" }, { label: "Montage in einem Tag" }] },
|
||||||
|
benefits: {
|
||||||
|
title: "Warum *SonnenWerk?*",
|
||||||
|
items: [
|
||||||
|
{ title: "Alles aus einer Hand", text: "Planung, Montage und Anmeldung beim Netzbetreiber – wir kümmern uns um alles." },
|
||||||
|
{ title: "Festpreis-Garantie", text: "Keine versteckten Kosten. Der Preis im Angebot ist der Preis, den Sie zahlen." },
|
||||||
|
{ title: "Montage in einem Tag", text: "Unser eigenes Team installiert Ihre Anlage in der Regel an nur einem Tag." },
|
||||||
|
{ title: "Regionaler Service", text: "Feste Ansprechpartner:innen aus Ihrer Region – auch nach der Installation." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
calculator: {
|
||||||
|
title: "Was können Sie *sparen?*",
|
||||||
|
text: "Ziehen Sie den Regler auf Ihre monatliche Stromrechnung.",
|
||||||
|
min: "50",
|
||||||
|
max: "400",
|
||||||
|
factor: "0,7",
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
title: "So *einfach* geht's",
|
||||||
|
items: [
|
||||||
|
{ title: "Anfrage", text: "30-Sekunden-Check ausfüllen." },
|
||||||
|
{ title: "Beratung", text: "Kostenloser Termin vor Ort oder per Video." },
|
||||||
|
{ title: "Montage", text: "Installation durch unser eigenes Team." },
|
||||||
|
{ title: "Sparen", text: "Ab dem ersten Sonnentag." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
title: "Über 1.200 *zufriedene* Kund:innen",
|
||||||
|
items: [
|
||||||
|
{ quote: "Von der Beratung bis zur Montage absolut professionell. Unsere Stromrechnung hat sich mehr als halbiert.", author: "Familie Hoffmann, Kassel" },
|
||||||
|
{ quote: "Schnell, sauber, freundlich. Die Montage hat genau einen Tag gedauert.", author: "Thomas R., Göttingen" },
|
||||||
|
{ quote: "Endlich ein Anbieter, der ehrlich berät und nichts aufschwatzt.", author: "Sabine M., Fulda" },
|
||||||
|
{ quote: "Die App zeigt live, wie viel Strom wir erzeugen – macht richtig Spaß.", author: "Jan K., Marburg" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
guarantee: {
|
||||||
|
title: "Unsere *Bestpreis*-Garantie",
|
||||||
|
text: "Finden Sie innerhalb von 14 Tagen ein vergleichbares Angebot günstiger, passen wir unseren Preis an.",
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
items: [
|
||||||
|
{ question: "Lohnt sich Solar für mein Haus?", answer: "In den allermeisten Fällen ja. Entscheidend sind Dachfläche, Ausrichtung und Verbrauch – genau das prüfen wir im kostenlosen Check." },
|
||||||
|
{ question: "Welche Förderungen gibt es?", answer: "Je nach Region gibt es Zuschüsse und günstige Kredite. Wir prüfen alle Fördertöpfe für Sie und übernehmen die Anträge." },
|
||||||
|
{ question: "Wie lange dauert es bis zur Installation?", answer: "Nach der Beratung meist vier bis acht Wochen. Die Montage selbst dauert in der Regel nur einen Tag." },
|
||||||
|
{ question: "Was passiert bei Defekten?", answer: "Sie erhalten 25 Jahre Garantie auf die Module und einen festen Service-Kontakt. Störungen beheben wir in der Regel innerhalb von 48 Stunden." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kampagne = defineTemplate({
|
||||||
|
key: "kampagne",
|
||||||
|
name: "Kampagne",
|
||||||
|
industry: "Kampagnen-Landingpage",
|
||||||
|
description: "Auf Conversion gebaut: Countdown zum Aktionsende, Quiz-Funnel zur Lead-Erfassung, Ersparnis-Rechner, Social Proof und mobile CTA-Leiste.",
|
||||||
|
cover: unsplash("1613665813446-82a78c468a1d", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Hallo, wir haben ein Einfamilienhaus mit Süddach (etwa 60 m²) und verbrauchen rund 4.500 kWh im Jahr. Was würde eine PV-Anlage mit Speicher bei Ihnen kosten, und gibt es Förderungen? Gruß, Familie Wolf",
|
||||||
|
topic: "Letzte Chance: Die Herbstaktion endet in sieben Tagen",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "solar", label: "Solar", bg: "#fffdf6", fg: "#132029", muted: "#5d6b74", surface: "#fff3d6", accent: "#ffb000" },
|
||||||
|
{ key: "gruen", label: "Grün", bg: "#f5faf6", fg: "#0f2417", muted: "#5b7062", surface: "#e2f1e6", accent: "#16a34a" },
|
||||||
|
{ key: "blau", label: "Blau", bg: "#f5f8ff", fg: "#0b1a38", muted: "#5a6680", surface: "#e3eafb", accent: "#2563eb" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "stark", label: "Archivo Black & Archivo" },
|
||||||
|
{ key: "rund", label: "Outfit" },
|
||||||
|
],
|
||||||
|
sections: describeSections(defaults, {
|
||||||
|
brand: "Anbieter",
|
||||||
|
offer: "Aktion & Countdown",
|
||||||
|
hero: "Startbereich",
|
||||||
|
quiz: { label: "Quiz-Funnel", items: { questions: "Frage" } },
|
||||||
|
trust: { label: "Vertrauens-Leiste", optional: true, items: { items: "Merkmal" } },
|
||||||
|
benefits: { label: "Vorteile", optional: true, items: { items: "Vorteil" } },
|
||||||
|
calculator: { label: "Ersparnis-Rechner", optional: true },
|
||||||
|
steps: { label: "Ablauf", optional: true, items: { items: "Schritt" } },
|
||||||
|
reviews: { label: "Bewertungen", optional: true, items: { items: "Bewertung" } },
|
||||||
|
guarantee: { label: "Garantie", optional: true },
|
||||||
|
faq: { label: "FAQ", optional: true, items: { items: "Frage" } },
|
||||||
|
}),
|
||||||
|
defaults,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type KampagneData = typeof kampagne.defaults
|
||||||
12
modules/templates/catalog/kampagne/fonts.ts
Normal file
12
modules/templates/catalog/kampagne/fonts.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Archivo, Archivo_Black, Outfit } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts
|
||||||
|
const archivoBlack = Archivo_Black({ subsets: ["latin"], weight: "400", variable: "--font-kampagne-black", preload: false })
|
||||||
|
const archivo = Archivo({ subsets: ["latin"], variable: "--font-kampagne-archivo", preload: false })
|
||||||
|
const outfit = Outfit({ subsets: ["latin"], variable: "--font-kampagne-outfit", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
stark: { className: `${archivoBlack.variable} ${archivo.variable}`, display: "var(--font-kampagne-black)", body: "var(--font-kampagne-archivo)" },
|
||||||
|
rund: { className: outfit.variable, display: "var(--font-kampagne-outfit)", body: "var(--font-kampagne-outfit)" },
|
||||||
|
}
|
||||||
412
modules/templates/catalog/kampagne/template.tsx
Normal file
412
modules/templates/catalog/kampagne/template.tsx
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
BadgeCheckIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CircleCheckIcon,
|
||||||
|
ClockIcon,
|
||||||
|
LeafIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
PiggyBankIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
StarIcon,
|
||||||
|
SunIcon,
|
||||||
|
WrenchIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { TField } from "../shared/fields"
|
||||||
|
import { splitList, useCountdown } from "../shared/hooks"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { Accordion, Marquee, Reveal, RevealWords, StepPanel, ease, useScrollNav } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { kampagne, type KampagneData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1240px] px-5 md:px-8"
|
||||||
|
const heading = "font-t-display tracking-[-0.03em]"
|
||||||
|
const trustIcons = [BadgeCheckIcon, ShieldCheckIcon, PiggyBankIcon, ClockIcon]
|
||||||
|
const benefitIcons = [WrenchIcon, BadgeCheckIcon, ClockIcon, LeafIcon]
|
||||||
|
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })
|
||||||
|
|
||||||
|
function Stars({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={cn("flex text-amber-400", className)}>
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<StarIcon key={i} className="size-4 fill-current" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Countdown({ deadline, compact }: { deadline: string; compact?: boolean }) {
|
||||||
|
const time = useCountdown(deadline)
|
||||||
|
const parts = [
|
||||||
|
{ value: time?.days, label: "Tage" },
|
||||||
|
{ value: time?.hours, label: "Std" },
|
||||||
|
{ value: time?.minutes, label: "Min" },
|
||||||
|
{ value: time?.seconds, label: "Sek" },
|
||||||
|
]
|
||||||
|
const pad = (value?: number) => (value === undefined ? "--" : String(value).padStart(2, "0"))
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return <span className="font-mono tabular-nums">{time?.done ? "beendet" : `${pad(time?.days)}T ${pad(time?.hours)}:${pad(time?.minutes)}:${pad(time?.seconds)}`}</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{parts.map((part) => (
|
||||||
|
<div key={part.label} className="w-16 rounded-2xl bg-t-fg py-3 text-center text-t-bg md:w-20">
|
||||||
|
<p className={cn(heading, "text-2xl tabular-nums md:text-3xl")}>{pad(part.value)}</p>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider opacity-60">{part.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KampagneTemplate({ content }: TemplateProps<KampagneData>) {
|
||||||
|
const { brand, offer, hero, quiz, trust, benefits, calculator, steps, reviews, guarantee, faq } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={kampagne} content={content} fonts={fonts[content.fonts] ?? fonts.stark} className="max-md:[--t-safe-bottom:4.75rem]">
|
||||||
|
<div className="bg-t-fg py-2.5 text-center text-sm text-t-bg">
|
||||||
|
<span className="font-semibold text-t-accent">{offer.badge}:</span> {offer.deadlineLabel} <Countdown deadline={offer.deadline} compact />
|
||||||
|
</div>
|
||||||
|
<Nav brand={brand} cta={quiz.title} />
|
||||||
|
|
||||||
|
<section id="top" className={cn(container, "grid items-start gap-12 pt-10 pb-16 md:pt-16 lg:grid-cols-[1.1fr_0.9fr]")}>
|
||||||
|
<div className="lg:pt-6">
|
||||||
|
<motion.span initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} className="inline-flex items-center gap-2 rounded-full bg-t-accent px-4 py-1.5 text-sm font-bold text-t-accent-fg">
|
||||||
|
<SunIcon className="size-4" /> {offer.badge} · nur bis {offer.deadline.split("-").reverse().join(".")}
|
||||||
|
</motion.span>
|
||||||
|
<RevealWords as="h1" onMount delay={0.1} text={hero.title} emClassName={em} className={cn(heading, "mt-6 text-[clamp(2.75rem,6.5vw,5.5rem)] leading-[0.98]")} />
|
||||||
|
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.5 }}>
|
||||||
|
<p className="mt-6 max-w-xl text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
||||||
|
<ul className="mt-6 space-y-2.5">
|
||||||
|
{splitList(hero.bullets, "\n").map((bullet) => (
|
||||||
|
<li key={bullet} className="flex items-center gap-3 font-medium">
|
||||||
|
<span className="grid size-6 place-items-center rounded-full bg-t-accent text-t-accent-fg">
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
{bullet}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-8 flex items-center gap-3 text-sm">
|
||||||
|
<Stars />
|
||||||
|
<span className="font-bold">{hero.rating}</span>
|
||||||
|
<span className="text-t-muted">aus {hero.reviews}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<p className="mb-3 text-sm font-semibold">{offer.deadlineLabel}</p>
|
||||||
|
<Countdown deadline={offer.deadline} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
<motion.div id="angebot" initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.9, ease, delay: 0.3 }} className="scroll-mt-6">
|
||||||
|
<Quiz quiz={quiz} image={hero.image} />
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("trust") && (
|
||||||
|
<section className="border-y border-t-fg/10 bg-t-surface">
|
||||||
|
<ul className={cn(container, "grid grid-cols-2 gap-4 py-6 md:grid-cols-4")}>
|
||||||
|
{trust.items.map((item, i) => {
|
||||||
|
const Icon = trustIcons[i % trustIcons.length]
|
||||||
|
return (
|
||||||
|
<li key={i} className="flex items-center gap-3 text-sm font-semibold">
|
||||||
|
<Icon className="size-6 shrink-0 text-t-accent" /> {item.label}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("benefits") && (
|
||||||
|
<section className={cn(container, "py-20 md:py-28")}>
|
||||||
|
<RevealWords text={benefits.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<div className="mt-12 grid gap-4 md:grid-cols-2">
|
||||||
|
{benefits.items.map((item, i) => {
|
||||||
|
const Icon = benefitIcons[i % benefitIcons.length]
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={(i % 2) * 0.08}>
|
||||||
|
<article className="flex h-full gap-5 rounded-3xl border border-t-fg/10 p-7">
|
||||||
|
<span className="grid size-14 shrink-0 place-items-center rounded-2xl bg-t-accent text-t-accent-fg">
|
||||||
|
<Icon className="size-6" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className={cn(heading, "text-xl")}>{item.title}</h3>
|
||||||
|
<p className="mt-2 leading-relaxed text-t-muted">{item.text}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("calculator") && <Calculator calculator={calculator} />}
|
||||||
|
|
||||||
|
{show("steps") && (
|
||||||
|
<section className={cn(container, "py-20 md:py-28")}>
|
||||||
|
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<ol className="mt-12 grid gap-4 md:grid-cols-4">
|
||||||
|
{steps.items.map((step, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
|
<li className="relative h-full rounded-3xl bg-t-surface p-6">
|
||||||
|
<span className={cn(heading, "text-5xl text-t-accent")}>{i + 1}</span>
|
||||||
|
<p className={cn(heading, "mt-4 text-xl")}>{step.title}</p>
|
||||||
|
<p className="mt-1 text-t-muted">{step.text}</p>
|
||||||
|
</li>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("reviews") && (
|
||||||
|
<section className="bg-t-surface py-20 md:py-28">
|
||||||
|
<div className={cn(container, "text-center")}>
|
||||||
|
<Stars className="justify-center" />
|
||||||
|
<RevealWords text={reviews.title} emClassName={em} className={cn(heading, "mt-4 text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<p className="mt-3 text-t-muted">Ø {hero.rating} von 5 Sternen · {hero.reviews}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12">
|
||||||
|
<Marquee duration={45}>
|
||||||
|
{reviews.items.map((review, i) => (
|
||||||
|
<figure key={i} className="mx-3 w-80 shrink-0 rounded-3xl bg-t-bg p-6 shadow-sm">
|
||||||
|
<Stars />
|
||||||
|
<blockquote className="mt-3 leading-relaxed">„{review.quote}“</blockquote>
|
||||||
|
<figcaption className="mt-4 text-sm font-semibold text-t-muted">{review.author}</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("guarantee") && (
|
||||||
|
<section className={cn(container, "py-16")}>
|
||||||
|
<Reveal>
|
||||||
|
<div className="flex flex-col items-center gap-6 rounded-[2rem] border-2 border-dashed border-t-accent p-8 text-center md:flex-row md:text-left">
|
||||||
|
<ShieldCheckIcon className="size-16 shrink-0 text-t-accent" />
|
||||||
|
<div>
|
||||||
|
<RevealWords text={guarantee.title} emClassName={em} className={cn(heading, "text-3xl")} />
|
||||||
|
<p className="mt-2 text-t-muted">{guarantee.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("faq") && (
|
||||||
|
<section className={cn(container, "max-w-3xl py-16 md:py-24")}>
|
||||||
|
<h2 className={cn(heading, "text-center text-[clamp(2rem,4vw,3rem)]")}>Häufige Fragen</h2>
|
||||||
|
<Accordion items={faq.items} className="mt-10 space-y-3" itemClassName="rounded-2xl bg-t-surface px-6" questionClassName="py-5 font-semibold" answerClassName="pb-6 leading-relaxed text-t-muted" />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className={cn(container, "pb-24")}>
|
||||||
|
<div className="rounded-[2.5rem] bg-t-fg px-6 py-16 text-center text-t-bg md:py-20">
|
||||||
|
<p className={cn(heading, "mx-auto max-w-2xl text-[clamp(2rem,4.5vw,3.5rem)] leading-[1.05]")}>Sichern Sie sich den Aktionspreis</p>
|
||||||
|
<p className="mt-4 opacity-70">{offer.deadlineLabel} <Countdown deadline={offer.deadline} compact /></p>
|
||||||
|
<a href="#angebot" className="mt-8 inline-flex items-center gap-2 rounded-full bg-t-accent px-8 py-4 font-bold text-t-accent-fg transition-transform hover:scale-105">
|
||||||
|
{quiz.title} <ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||||
|
<p>{brand.name} · {brand.suffix} · {brand.phone}</p>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-t-fg/10 bg-t-bg/95 p-3 backdrop-blur md:hidden">
|
||||||
|
<a href="#angebot" className="flex items-center justify-between rounded-full bg-t-accent px-5 py-3.5 font-bold text-t-accent-fg">
|
||||||
|
{quiz.title}
|
||||||
|
<span className="text-xs font-medium opacity-80"><Countdown deadline={offer.deadline} compact /></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ brand, cta }: { brand: KampagneData["brand"]; cta: string }) {
|
||||||
|
const { scrolled } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={cn("sticky top-0 z-50 border-b transition-colors duration-500", scrolled ? "border-t-fg/10 bg-t-bg/90 backdrop-blur-xl" : "border-transparent bg-t-bg")}>
|
||||||
|
<nav className={cn(container, "flex h-18 items-center justify-between gap-4 py-3")}>
|
||||||
|
<a href="#top" className="flex items-center gap-2.5">
|
||||||
|
<span className="grid size-10 place-items-center rounded-full bg-t-accent text-t-accent-fg">
|
||||||
|
<SunIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<span className="leading-tight">
|
||||||
|
<span className={cn(heading, "block text-lg")}>{brand.name}</span>
|
||||||
|
<span className="text-xs text-t-muted">{brand.suffix}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="hidden items-center gap-2 text-sm font-semibold md:flex">
|
||||||
|
<PhoneIcon className="size-4 text-t-accent" /> {brand.phone}
|
||||||
|
</a>
|
||||||
|
<a href="#angebot" className="hidden rounded-full bg-t-fg px-5 py-2.5 text-sm font-bold text-t-bg sm:block">{cta}</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Quiz({ quiz, image }: { quiz: KampagneData["quiz"]; image: string }) {
|
||||||
|
const questions = quiz.questions.filter((item) => item.question.trim())
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [answers, setAnswers] = useState<string[]>([])
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
const total = questions.length + 1
|
||||||
|
const progress = done ? 100 : (step / total) * 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-[2rem] bg-t-bg shadow-[0_40px_80px_-30px_rgba(0,0,0,0.35)] ring-1 ring-t-fg/10">
|
||||||
|
<div className="relative h-36 overflow-hidden">
|
||||||
|
<TImage src={image} eager sizes="(min-width: 1024px) 40vw, 100vw" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/75 to-black/10" />
|
||||||
|
<div className="absolute inset-x-6 bottom-4 text-white">
|
||||||
|
<p className={cn(heading, "text-2xl")}>{quiz.title}</p>
|
||||||
|
<p className="text-sm opacity-80">{quiz.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<div className="mb-2 flex justify-between text-xs font-semibold text-t-muted">
|
||||||
|
<span>{done ? "Fertig" : `Schritt ${step + 1} von ${total}`}</span>
|
||||||
|
<span>{Math.round(progress)} %</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-7 h-2 overflow-hidden rounded-full bg-t-surface">
|
||||||
|
<motion.div className="h-full rounded-full bg-t-accent" animate={{ width: `${progress}%` }} transition={{ duration: 0.5, ease }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{done ? (
|
||||||
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="py-6 text-center">
|
||||||
|
<CircleCheckIcon className="mx-auto size-16 text-t-accent" />
|
||||||
|
<p className={cn(heading, "mt-4 text-2xl")}>{quiz.success}</p>
|
||||||
|
<p className="mt-4 text-sm opacity-60">Demo-Hinweis: Live landet die Anfrage direkt im CRM bzw. Postfach.</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<StepPanel step={step}>
|
||||||
|
{step < questions.length ? (
|
||||||
|
<div>
|
||||||
|
<p className={cn(heading, "text-xl md:text-2xl")}>{questions[step].question}</p>
|
||||||
|
<div className="mt-5 grid gap-2.5 sm:grid-cols-2">
|
||||||
|
{splitList(questions[step].options, "|").map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAnswers((list) => [...list.slice(0, step), option])
|
||||||
|
setStep(step + 1)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer rounded-2xl border-2 px-4 py-4 text-left font-semibold transition-all hover:-translate-y-0.5 hover:border-t-accent",
|
||||||
|
answers[step] === option ? "border-t-accent bg-t-surface" : "border-t-fg/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setDone(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className={cn(heading, "text-xl md:text-2xl")}>Wohin dürfen wir Ihr Angebot schicken?</p>
|
||||||
|
<TField label="Name" name="name" required autoComplete="name" />
|
||||||
|
<div className="grid grid-cols-[7rem_1fr] gap-3">
|
||||||
|
<TField label="PLZ" name="zip" required inputMode="numeric" />
|
||||||
|
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="mt-1 inline-flex cursor-pointer items-center justify-center gap-2 rounded-full bg-t-accent py-4 font-bold text-t-accent-fg transition-transform hover:scale-[1.02]">
|
||||||
|
Kostenloses Angebot anfordern <ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-xs text-t-muted">100 % kostenlos und unverbindlich</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</StepPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!done && step > 0 && (
|
||||||
|
<button type="button" onClick={() => setStep(step - 1)} className="mt-5 flex cursor-pointer items-center gap-1.5 text-sm text-t-muted hover:text-t-fg">
|
||||||
|
<ArrowLeftIcon className="size-4" /> Zurück
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Calculator({ calculator }: { calculator: KampagneData["calculator"] }) {
|
||||||
|
const min = Number(calculator.min) || 50
|
||||||
|
const max = Math.max(min + 10, Number(calculator.max) || 400)
|
||||||
|
const factor = Number(calculator.factor.replace(",", ".")) || 0.7
|
||||||
|
const [bill, setBill] = useState(Math.round((min + max) / 2 / 10) * 10)
|
||||||
|
const yearly = bill * 12 * factor
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-t-fg py-20 text-t-bg md:py-28">
|
||||||
|
<div className={cn(container, "grid items-center gap-12 lg:grid-cols-2")}>
|
||||||
|
<div>
|
||||||
|
<RevealWords text={calculator.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<p className="mt-4 text-lg opacity-70">{calculator.text}</p>
|
||||||
|
<div className="mt-10">
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<span className="text-sm opacity-70">Stromrechnung pro Monat</span>
|
||||||
|
<span className={cn(heading, "text-4xl")}>{euro.format(bill)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={10}
|
||||||
|
value={bill}
|
||||||
|
onChange={(event) => setBill(Number(event.target.value))}
|
||||||
|
aria-label="Monatliche Stromrechnung"
|
||||||
|
className="mt-4 w-full cursor-pointer accent-[var(--t-accent)]"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-xs opacity-50">
|
||||||
|
<span>{euro.format(min)}</span>
|
||||||
|
<span>{euro.format(max)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
{ label: "Ersparnis pro Jahr", value: yearly },
|
||||||
|
{ label: "Ersparnis in 20 Jahren", value: yearly * 20 },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="rounded-3xl bg-t-bg/10 p-7">
|
||||||
|
<p className="text-sm opacity-70">{item.label}</p>
|
||||||
|
<motion.p key={Math.round(item.value)} initial={{ opacity: 0.4, y: 6 }} animate={{ opacity: 1, y: 0 }} className={cn(heading, "mt-2 text-4xl text-t-accent md:text-5xl")}>
|
||||||
|
{euro.format(item.value)}
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-xs opacity-50 sm:col-span-2">Unverbindliche Beispielrechnung. Die genaue Ersparnis ermitteln wir im kostenlosen Check.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
224
modules/templates/catalog/kanzlei/definition.ts
Normal file
224
modules/templates/catalog/kanzlei/definition.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import { defineTemplate, RICH_HINT, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
export const kanzlei = defineTemplate({
|
||||||
|
key: "kanzlei",
|
||||||
|
name: "Kanzlei",
|
||||||
|
industry: "Kanzlei, Beratung & Praxis",
|
||||||
|
description: "Ruhig und hochwertig: viel Weißraum, Serifenschrift, Rechtsgebiete als Akkordeon, Team, Ablauf und Terminanfrage.",
|
||||||
|
cover: unsplash("1505664194779-8beaceb93744", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Sehr geehrte Damen und Herren, mir wurde nach zwölf Jahren Betriebszugehörigkeit betriebsbedingt gekündigt. Steht mir eine Abfindung zu, und was kostet eine Erstberatung bei Ihnen? Mit freundlichen Grüßen, Michael Wagner",
|
||||||
|
topic: "Kündigung erhalten? Fünf Dinge, die Sie jetzt wissen sollten",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "papier", label: "Papier", bg: "#f7f5f0", fg: "#18212f", muted: "#666d7a", surface: "#eeeae2", accent: "#94744a" },
|
||||||
|
{ key: "tinte", label: "Tinte", dark: true, bg: "#0f1724", fg: "#eceef2", muted: "#8e98a9", surface: "#162032", accent: "#c8a774" },
|
||||||
|
{ key: "praxis", label: "Praxis", bg: "#f5f8f7", fg: "#12302d", muted: "#5a716e", surface: "#e6eeec", accent: "#2a8a78" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "klassisch", label: "Cormorant Garamond & Inter" },
|
||||||
|
{ key: "zeitgemaess", label: "Source Serif & Source Sans" },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
key: "brand",
|
||||||
|
label: "Marke",
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "suffix", label: "Zusatz" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hero",
|
||||||
|
label: "Startbereich",
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "cta", label: "Button" },
|
||||||
|
{ key: "image", label: "Bild", type: "image" },
|
||||||
|
{
|
||||||
|
key: "facts",
|
||||||
|
label: "Eckdaten",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Eckdatum",
|
||||||
|
fields: [
|
||||||
|
{ key: "value", label: "Wert" },
|
||||||
|
{ key: "label", label: "Beschriftung" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "areas",
|
||||||
|
label: "Kompetenzen",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
label: "Bereiche",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Bereich",
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "Titel" },
|
||||||
|
{ key: "text", label: "Beschreibung", type: "textarea" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "about",
|
||||||
|
label: "Über uns",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "lead", label: "Einleitung", type: "textarea" },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "image", label: "Bild", type: "image" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "team",
|
||||||
|
label: "Team",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{
|
||||||
|
key: "members",
|
||||||
|
label: "Personen",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Person",
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "role", label: "Funktion" },
|
||||||
|
{ key: "image", label: "Foto", type: "image" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "steps",
|
||||||
|
label: "Ablauf",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
label: "Schritte",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Schritt",
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "Titel" },
|
||||||
|
{ key: "text", label: "Beschreibung", type: "textarea" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "quotes",
|
||||||
|
label: "Stimmen",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
label: "Stimmen",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Stimme",
|
||||||
|
fields: [
|
||||||
|
{ key: "text", label: "Zitat", type: "textarea" },
|
||||||
|
{ key: "author", label: "Von" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contact",
|
||||||
|
label: "Kontakt",
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "address", label: "Adresse", type: "textarea" },
|
||||||
|
{ key: "phone", label: "Telefon" },
|
||||||
|
{ key: "email", label: "E-Mail" },
|
||||||
|
{ key: "hours", label: "Bürozeiten" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaults: {
|
||||||
|
brand: { name: "Hartmann & Partner", suffix: "Rechtsanwälte · Fachanwälte" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Arbeits-, Familien- und Erbrecht",
|
||||||
|
title: "Klarheit in\n*rechtlichen* Fragen.",
|
||||||
|
text: "Seit über 25 Jahren begleiten wir Privatpersonen und Unternehmen – mit fundierter Beratung, klarer Sprache und dem nötigen Gespür für das, was Ihnen wichtig ist.",
|
||||||
|
cta: "Termin vereinbaren",
|
||||||
|
image: unsplash("1505664194779-8beaceb93744", 1400),
|
||||||
|
facts: [
|
||||||
|
{ value: "1998", label: "gegründet" },
|
||||||
|
{ value: "12", label: "Fachanwältinnen & Fachanwälte" },
|
||||||
|
{ value: "3.500+", label: "betreute Mandate" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
areas: {
|
||||||
|
eyebrow: "Kompetenzen",
|
||||||
|
title: "Unsere\n*Rechtsgebiete*",
|
||||||
|
text: "Spezialisiert statt Bauchladen: Jedes Mandat betreut eine Fachanwältin oder ein Fachanwalt für das jeweilige Gebiet.",
|
||||||
|
items: [
|
||||||
|
{ title: "Arbeitsrecht", text: "Kündigung, Abfindung, Arbeits- oder Aufhebungsvertrag – wir vertreten Arbeitnehmer wie Arbeitgeber, außergerichtlich und vor Gericht." },
|
||||||
|
{ title: "Familienrecht", text: "Trennung, Scheidung, Unterhalt und Sorgerecht. Wir begleiten Sie sachlich und mit dem nötigen Feingefühl." },
|
||||||
|
{ title: "Erbrecht", text: "Testament, Erbvertrag, Pflichtteil und Nachlassstreit – vorausschauend gestalten statt später streiten." },
|
||||||
|
{ title: "Mietrecht", text: "Ob Mieter oder Vermieter: Wir klären Mietminderung, Nebenkosten, Eigenbedarf und Räumung." },
|
||||||
|
{ title: "Vertragsrecht", text: "Wir prüfen, gestalten und verhandeln Verträge, damit Sie Risiken früh erkennen." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
eyebrow: "Über uns",
|
||||||
|
title: "Recht ist\n*Vertrauenssache.*",
|
||||||
|
lead: "Wir nehmen uns Zeit, Ihren Fall wirklich zu verstehen. Erst dann entwickeln wir eine Strategie – ehrlich in der Einschätzung und konsequent in der Umsetzung.",
|
||||||
|
text: "Ob außergerichtliche Einigung oder Prozess: Sie wissen bei uns jederzeit, wo Ihr Fall steht und welche Kosten auf Sie zukommen. Kurze Wege, persönliche Erreichbarkeit und verständliche Sprache sind für uns selbstverständlich.",
|
||||||
|
image: unsplash("1497366811353-6870744d04b2", 1600),
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
eyebrow: "Team",
|
||||||
|
title: "Ihre *Ansprechpartner*",
|
||||||
|
members: [
|
||||||
|
{ name: "Dr. Katharina Hartmann", role: "Fachanwältin für Arbeitsrecht", image: unsplash("1573496359142-b8d87734a5a2", 900) },
|
||||||
|
{ name: "Michael Berger", role: "Fachanwalt für Familienrecht", image: unsplash("1560250097-0b93528c311a", 900) },
|
||||||
|
{ name: "Julia Neumann", role: "Fachanwältin für Erbrecht", image: unsplash("1438761681033-6461ffad8d80", 900) },
|
||||||
|
{ name: "Thomas Wagner", role: "Rechtsanwalt, Mietrecht", image: unsplash("1519085360753-af0119f7cbe7", 900) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
eyebrow: "Ihr Weg zu uns",
|
||||||
|
title: "In drei Schritten\nzur *Lösung.*",
|
||||||
|
items: [
|
||||||
|
{ title: "Erstberatung", text: "Sie schildern Ihr Anliegen, wir geben eine ehrliche Einschätzung – auch zu den Kosten." },
|
||||||
|
{ title: "Strategie", text: "Wir prüfen Ihre Unterlagen und entwickeln gemeinsam mit Ihnen das weitere Vorgehen." },
|
||||||
|
{ title: "Umsetzung", text: "Wir vertreten Ihre Interessen mit Nachdruck – außergerichtlich oder vor Gericht." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
quotes: {
|
||||||
|
items: [
|
||||||
|
{ text: "Kompetent, menschlich und immer erreichbar. Ich habe mich in einer schwierigen Zeit sehr gut aufgehoben gefühlt.", author: "Mandantin, Familienrecht" },
|
||||||
|
{ text: "Klare Einschätzung, realistische Erwartungen und am Ende ein besseres Ergebnis als erhofft.", author: "Mandant, Arbeitsrecht" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
eyebrow: "Kontakt",
|
||||||
|
title: "Vereinbaren Sie ein\n*Erstgespräch.*",
|
||||||
|
text: "Rufen Sie uns an oder schreiben Sie uns. Wir melden uns innerhalb eines Werktags.",
|
||||||
|
address: "Theaterstraße 15\n30159 Hannover",
|
||||||
|
phone: "0511 123 45 67",
|
||||||
|
email: "kanzlei@hartmann-partner.de",
|
||||||
|
hours: "Mo – Fr · 8:30 – 18 Uhr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type KanzleiData = typeof kanzlei.defaults
|
||||||
20
modules/templates/catalog/kanzlei/fonts.ts
Normal file
20
modules/templates/catalog/kanzlei/fonts.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Cormorant_Garamond, Source_Sans_3, Source_Serif_4 } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts. "Klassisch" nutzt für Fließtext das Inter des Portals.
|
||||||
|
const cormorant = Cormorant_Garamond({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-cormorant", preload: false })
|
||||||
|
const sourceSerif = Source_Serif_4({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-source-serif", preload: false })
|
||||||
|
const sourceSans = Source_Sans_3({ subsets: ["latin"], variable: "--font-source-sans", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
klassisch: {
|
||||||
|
className: cormorant.variable,
|
||||||
|
display: "var(--font-cormorant)",
|
||||||
|
body: "var(--font-inter)",
|
||||||
|
},
|
||||||
|
zeitgemaess: {
|
||||||
|
className: `${sourceSerif.variable} ${sourceSans.variable}`,
|
||||||
|
display: "var(--font-source-serif)",
|
||||||
|
body: "var(--font-source-sans)",
|
||||||
|
},
|
||||||
|
}
|
||||||
370
modules/templates/catalog/kanzlei/template.tsx
Normal file
370
modules/templates/catalog/kanzlei/template.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { useState, type InputHTMLAttributes } from "react"
|
||||||
|
import { ArrowRightIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { DemoForm } from "../shared/demo-form"
|
||||||
|
import { ParallaxImage, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { kanzlei, type KanzleiData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "italic text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1320px] px-6 md:px-10"
|
||||||
|
const heading = "font-t-display font-medium tracking-[-0.015em]"
|
||||||
|
|
||||||
|
function Eyebrow({ children, className }: { children: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<p className={cn("mb-7 flex items-center gap-4 text-[11px] font-medium uppercase tracking-[0.28em] text-t-muted", className)}>
|
||||||
|
<span className="h-px w-10 bg-t-accent" />
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanzleiTemplate({ content }: TemplateProps<KanzleiData>) {
|
||||||
|
const { brand, hero, areas, about, team, steps, quotes, contact } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const links = [
|
||||||
|
{ href: "#kompetenzen", label: "Kompetenzen", visible: show("areas") },
|
||||||
|
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
|
||||||
|
{ href: "#team", label: "Team", visible: show("team") },
|
||||||
|
{ href: "#kontakt", label: "Kontakt", visible: true },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={kanzlei} content={content} fonts={fonts[content.fonts] ?? fonts.klassisch}>
|
||||||
|
<Nav brand={brand} cta={hero.cta} links={links} />
|
||||||
|
|
||||||
|
<section id="top" className={cn(container, "pt-36 pb-20 md:pt-48 md:pb-28")}>
|
||||||
|
<div className="grid items-end gap-14 md:grid-cols-12 md:gap-10">
|
||||||
|
<div className="md:col-span-7">
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.2 }}>
|
||||||
|
<Eyebrow>{hero.eyebrow}</Eyebrow>
|
||||||
|
</motion.div>
|
||||||
|
<RevealWords
|
||||||
|
as="h1"
|
||||||
|
onMount
|
||||||
|
delay={0.3}
|
||||||
|
text={hero.title}
|
||||||
|
emClassName={em}
|
||||||
|
className={cn(heading, "text-[clamp(3.1rem,7.6vw,7.75rem)] leading-[0.98]")}
|
||||||
|
/>
|
||||||
|
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, ease, delay: 0.9 }}>
|
||||||
|
<p className="mt-10 max-w-xl text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
||||||
|
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-4">
|
||||||
|
<a
|
||||||
|
href="#kontakt"
|
||||||
|
className="group inline-flex items-center gap-3 bg-t-fg px-7 py-4 text-sm text-t-bg transition-colors hover:bg-t-accent hover:text-t-accent-fg"
|
||||||
|
>
|
||||||
|
{hero.cta}
|
||||||
|
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</a>
|
||||||
|
<a href={`tel:${contact.phone.replace(/\s/g, "")}`} className="text-sm text-t-muted transition-colors hover:text-t-fg">
|
||||||
|
oder anrufen: <span className="text-t-fg">{contact.phone}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 60 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 1.4, ease, delay: 0.4 }}
|
||||||
|
className="md:col-span-4 md:col-start-9"
|
||||||
|
>
|
||||||
|
<ParallaxImage src={hero.image} eager offset={40} className="aspect-[4/5] rounded-t-full" sizes="(min-width: 768px) 32vw, 100vw" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-20 grid border-t border-t-fg/15 sm:grid-cols-3 md:mt-28">
|
||||||
|
{hero.facts.map((fact, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.1} className={cn("pt-8 pb-2 sm:px-8 sm:first:pl-0", i > 0 && "sm:border-l sm:border-t-fg/15")}>
|
||||||
|
<dt className="sr-only">{fact.label}</dt>
|
||||||
|
<dd className={cn(heading, "text-5xl md:text-6xl")}>{fact.value}</dd>
|
||||||
|
<dd className="mt-2 text-sm text-t-muted">{fact.label}</dd>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("areas") && <Areas areas={areas} />}
|
||||||
|
|
||||||
|
{show("about") && (
|
||||||
|
<section id="ueber-uns" className={cn(container, "grid gap-14 py-24 md:grid-cols-12 md:gap-10 md:py-40")}>
|
||||||
|
<div className="md:col-span-5">
|
||||||
|
<div className="md:sticky md:top-36">
|
||||||
|
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.75rem)] leading-[1.02]")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-12 md:col-span-6 md:col-start-7">
|
||||||
|
<ParallaxImage src={about.image} offset={50} className="aspect-[4/3]" sizes="(min-width: 768px) 50vw, 100vw" />
|
||||||
|
<Reveal>
|
||||||
|
<p className={cn(heading, "text-2xl leading-snug md:text-[2rem]")}>{about.lead}</p>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<p className="text-lg leading-relaxed text-t-muted">{about.text}</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("team") && (
|
||||||
|
<section id="team" className="border-t border-t-fg/10 py-24 md:py-40">
|
||||||
|
<div className={container}>
|
||||||
|
<Reveal><Eyebrow>{team.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={team.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.75rem)] leading-[1.02]")} />
|
||||||
|
<div className="mt-16 grid gap-x-6 gap-y-14 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{team.members.map((member, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.08}>
|
||||||
|
<div className="group">
|
||||||
|
<div className="relative aspect-[4/5] overflow-hidden bg-t-surface">
|
||||||
|
<TImage
|
||||||
|
src={member.image}
|
||||||
|
alt={member.name}
|
||||||
|
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw"
|
||||||
|
className="grayscale transition duration-[900ms] group-hover:scale-[1.04] group-hover:grayscale-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mt-6 block h-px w-8 bg-t-accent transition-all duration-700 group-hover:w-full" />
|
||||||
|
<p className={cn(heading, "mt-5 text-2xl")}>{member.name}</p>
|
||||||
|
<p className="mt-1 text-sm text-t-muted">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("steps") && (
|
||||||
|
<section className="bg-t-fg py-24 text-t-bg md:py-36">
|
||||||
|
<div className={container}>
|
||||||
|
<Reveal><Eyebrow className="text-t-bg/60">{steps.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.75rem)] leading-[1.02]")} />
|
||||||
|
<ol className="mt-16 grid gap-px bg-t-bg/15 md:grid-cols-3">
|
||||||
|
{steps.items.map((step, i) => (
|
||||||
|
<motion.li
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
||||||
|
transition={{ duration: 0.9, ease, delay: i * 0.12 }}
|
||||||
|
className="bg-t-fg py-10 md:p-10 md:first:pl-0"
|
||||||
|
>
|
||||||
|
<span className="font-t-display text-6xl italic text-t-accent">{i + 1}</span>
|
||||||
|
<h3 className={cn(heading, "mt-8 text-3xl")}>{step.title}</h3>
|
||||||
|
<p className="mt-4 max-w-sm leading-relaxed text-t-bg/65">{step.text}</p>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("quotes") && (
|
||||||
|
<section className={cn(container, "grid gap-14 py-24 md:grid-cols-2 md:gap-20 md:py-36")}>
|
||||||
|
{quotes.items.map((quote, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.15}>
|
||||||
|
<figure className="border-t border-t-fg/15 pt-10">
|
||||||
|
<blockquote className={cn(heading, "text-[clamp(1.6rem,2.7vw,2.4rem)] italic leading-[1.25]")}>„{quote.text}“</blockquote>
|
||||||
|
<figcaption className="mt-8 text-[11px] uppercase tracking-[0.28em] text-t-muted">{quote.author}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section id="kontakt" className="bg-t-surface py-24 md:py-36">
|
||||||
|
<div className={cn(container, "grid gap-16 md:grid-cols-12 md:gap-10")}>
|
||||||
|
<div className="md:col-span-5">
|
||||||
|
<Reveal><Eyebrow>{contact.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.5rem)] leading-[1.02]")} />
|
||||||
|
<Reveal delay={0.15}>
|
||||||
|
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{contact.text}</p>
|
||||||
|
<dl className="mt-12 grid gap-7">
|
||||||
|
{[
|
||||||
|
["Adresse", contact.address],
|
||||||
|
["Telefon", contact.phone],
|
||||||
|
["E-Mail", contact.email],
|
||||||
|
["Bürozeiten", contact.hours],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<dt className="text-[11px] uppercase tracking-[0.28em] text-t-muted">{label}</dt>
|
||||||
|
<dd className={cn(heading, "mt-1.5 text-xl whitespace-pre-line")}>{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reveal delay={0.1} className="md:col-span-6 md:col-start-7">
|
||||||
|
<DemoForm
|
||||||
|
className="bg-t-bg p-7 shadow-[0_40px_100px_-50px_rgba(0,0,0,0.35)] md:p-12"
|
||||||
|
success={
|
||||||
|
<div className="py-10">
|
||||||
|
<p className={cn(heading, "text-5xl")}>Vielen <span className={em}>Dank.</span></p>
|
||||||
|
<p className="mt-4 text-t-muted">Wir melden uns innerhalb eines Werktags bei Ihnen.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={cn(heading, "text-3xl")}>Ihre Anfrage</p>
|
||||||
|
<div className="mt-8 grid gap-5 sm:grid-cols-2">
|
||||||
|
<Box label="Name" name="name" autoComplete="name" required className="sm:col-span-2" />
|
||||||
|
<Box label="E-Mail" name="email" type="email" autoComplete="email" required />
|
||||||
|
<Box label="Telefon" name="phone" type="tel" autoComplete="tel" />
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.24em] text-t-muted">Anliegen</span>
|
||||||
|
<select name="area" className="mt-2 w-full border border-t-fg/15 bg-transparent px-4 py-3 outline-none focus:border-t-accent">
|
||||||
|
{areas.items.map((item, i) => (
|
||||||
|
<option key={i}>{item.title}</option>
|
||||||
|
))}
|
||||||
|
<option>Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block sm:col-span-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.24em] text-t-muted">Ihre Nachricht</span>
|
||||||
|
<textarea name="message" rows={4} className="mt-2 w-full resize-none border border-t-fg/15 bg-transparent px-4 py-3 outline-none focus:border-t-accent" />
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-3 text-sm text-t-muted sm:col-span-2">
|
||||||
|
<input type="checkbox" required className="mt-1 size-4 accent-[var(--t-accent)]" />
|
||||||
|
Ich habe die Datenschutzerklärung gelesen und bin mit der Verarbeitung meiner Angaben einverstanden.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="group mt-9 inline-flex w-full cursor-pointer items-center justify-center gap-3 bg-t-fg py-4 text-sm text-t-bg transition-colors hover:bg-t-accent hover:text-t-accent-fg"
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</button>
|
||||||
|
</DemoForm>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="border-t border-t-fg/10">
|
||||||
|
<div className={cn(container, "grid gap-10 py-14 text-sm text-t-muted md:grid-cols-4")}>
|
||||||
|
<div>
|
||||||
|
<p className={cn(heading, "text-2xl text-t-fg")}>{brand.name}</p>
|
||||||
|
<p className="mt-1">{brand.suffix}</p>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-line">{contact.address}</p>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span>{contact.phone}</span>
|
||||||
|
<span>{contact.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 md:justify-end">
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ brand, cta, links }: { brand: KanzleiData["brand"]; cta: string; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||||
|
transition={{ duration: 0.45, ease }}
|
||||||
|
className={cn("fixed inset-x-0 top-0 z-50 transition-colors duration-500", scrolled && "bg-t-bg/85 backdrop-blur-xl")}
|
||||||
|
>
|
||||||
|
<nav className={cn(container, "flex h-24 items-center justify-between gap-8")}>
|
||||||
|
<a href="#top" className="flex flex-col">
|
||||||
|
<span className={cn(heading, "text-2xl leading-none")}>{brand.name}</span>
|
||||||
|
<span className="mt-1.5 text-[10px] uppercase tracking-[0.28em] text-t-muted">{brand.suffix}</span>
|
||||||
|
</a>
|
||||||
|
<div className="hidden items-center gap-10 text-sm lg:flex">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-accent">
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a href="#kontakt" className="hidden border border-t-fg/30 px-5 py-3 text-sm transition-colors hover:border-t-fg hover:bg-t-fg hover:text-t-bg sm:block">
|
||||||
|
{cta}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<motion.span
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: 1 }}
|
||||||
|
transition={{ duration: 1.6, ease, delay: 0.2 }}
|
||||||
|
className="absolute inset-x-0 bottom-0 h-px origin-left bg-t-fg/12"
|
||||||
|
/>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Areas({ areas }: { areas: KanzleiData["areas"] }) {
|
||||||
|
const [open, setOpen] = useState<number | null>(0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="kompetenzen" className="bg-t-surface py-24 md:py-40">
|
||||||
|
<div className={cn(container, "grid gap-14 md:grid-cols-12 md:gap-10")}>
|
||||||
|
<div className="md:col-span-4">
|
||||||
|
<div className="md:sticky md:top-36">
|
||||||
|
<Reveal><Eyebrow>{areas.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={areas.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.75rem)] leading-[1.02]")} />
|
||||||
|
<Reveal delay={0.15}>
|
||||||
|
<p className="mt-8 max-w-sm leading-relaxed text-t-muted">{areas.text}</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="border-t border-t-fg/15 md:col-span-7 md:col-start-6">
|
||||||
|
{areas.items.map((item, i) => {
|
||||||
|
const isOpen = open === i
|
||||||
|
return (
|
||||||
|
<li key={i} className="border-b border-t-fg/15">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
|
className="group flex w-full cursor-pointer items-baseline gap-6 py-7 text-left md:gap-10 md:py-8"
|
||||||
|
>
|
||||||
|
<span className="w-6 shrink-0 font-t-display text-sm text-t-accent tabular-nums">{String(i + 1).padStart(2, "0")}</span>
|
||||||
|
<span className={cn(heading, "flex-1 text-3xl transition-colors duration-300 group-hover:text-t-accent md:text-4xl")}>{item.title}</span>
|
||||||
|
<span aria-hidden className="relative size-4 shrink-0 self-center">
|
||||||
|
<span className="absolute top-1/2 left-0 h-px w-full bg-current" />
|
||||||
|
<span className={cn("absolute top-1/2 left-0 h-px w-full bg-current transition-transform duration-500", !isOpen && "rotate-90")} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5, ease }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<p className="max-w-xl pb-8 pl-12 leading-relaxed text-t-muted md:pl-16">{item.text}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Box({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
||||||
|
return (
|
||||||
|
<label className={cn("block", className)}>
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.24em] text-t-muted">{label}</span>
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
className="mt-2 w-full border border-t-fg/15 bg-transparent px-4 py-3 outline-none transition-colors focus:border-t-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
modules/templates/catalog/karriere/definition.ts
Normal file
105
modules/templates/catalog/karriere/definition.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
brand: { name: "Weber Elektro", suffix: "Elektrotechnik seit 1978", phone: "0221 123 456 78", email: "jobs@weber-elektro.de" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Wir stellen ein · Köln & Umgebung",
|
||||||
|
title: "Kein Job.\nDein *Ding.*",
|
||||||
|
roles: "Elektroniker:in, Azubi, Meister:in, Projektleiter:in, Servicetechniker:in",
|
||||||
|
text: "Bei uns zählt, was du kannst – nicht, was im Zeugnis steht. Faire Bezahlung, moderne Ausstattung und ein Team, das zusammenhält.",
|
||||||
|
cta: "Offene Stellen",
|
||||||
|
ctaSecondary: "In 60 Sekunden bewerben",
|
||||||
|
image: unsplash("1621905252507-b35492cc74b4", 1200),
|
||||||
|
imageSecondary: unsplash("1504328345606-18bbc8c9d7d1", 900),
|
||||||
|
imageThird: unsplash("1543269865-cbf427effbad", 900),
|
||||||
|
badge: "Über Tarif bezahlt",
|
||||||
|
},
|
||||||
|
benefits: {
|
||||||
|
eyebrow: "Benefits",
|
||||||
|
title: "Mehr als nur *Gehalt.*",
|
||||||
|
items: [
|
||||||
|
{ title: "Über Tarif", text: "Faire Löhne plus Zuschläge, Urlaubs- und Weihnachtsgeld." },
|
||||||
|
{ title: "4-Tage-Woche möglich", text: "Du entscheidest mit, wie deine Woche aussieht." },
|
||||||
|
{ title: "Eigener E-Transporter", text: "Voll ausgestattet – auch für den Heimweg." },
|
||||||
|
{ title: "Weiterbildung", text: "Meisterkurs, Techniker oder Smart-Home-Zertifikat? Zahlen wir." },
|
||||||
|
{ title: "Team-Events", text: "Sommerfest, Kartbahn und das legendäre Grillen am Freitag." },
|
||||||
|
{ title: "Moderne Tools", text: "Tablet, App und Top-Werkzeug statt Zettelwirtschaft." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
facts: {
|
||||||
|
items: [
|
||||||
|
{ value: "85", suffix: "", label: "Kolleg:innen" },
|
||||||
|
{ value: "46", suffix: "", label: "Jahre Familienbetrieb" },
|
||||||
|
{ value: "12", suffix: "", label: "Azubis aktuell" },
|
||||||
|
{ value: "4,8", suffix: " ★", label: "Mitarbeiter-Bewertung" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
eyebrow: "Offene Stellen",
|
||||||
|
title: "Finde deinen *Platz.*",
|
||||||
|
items: [
|
||||||
|
{ title: "Elektroniker:in Energie- und Gebäudetechnik", area: "Montage", type: "Vollzeit", location: "Köln", tasks: "Installation in Neubau und Sanierung\nSmart-Home- und PV-Anlagen\nArbeiten im festen Zweierteam" },
|
||||||
|
{ title: "Ausbildung Elektroniker:in (Start 2027)", area: "Ausbildung", type: "Ausbildung", location: "Köln", tasks: "3,5 Jahre Ausbildung mit Übernahmegarantie\nEigene:r Ausbildungspat:in\nTablet ab dem ersten Tag" },
|
||||||
|
{ title: "Projektleiter:in Elektrotechnik", area: "Büro", type: "Vollzeit", location: "Köln", tasks: "Planung und Kalkulation von Projekten\nKoordination von Teams und Gewerken\nDirekter Draht zu Bauherr:innen" },
|
||||||
|
{ title: "Servicetechniker:in", area: "Service", type: "Teilzeit", location: "Bonn", tasks: "Wartung und Störungsbehebung beim Kunden\nFlexible Einsatzplanung per App\n20 bis 30 Stunden pro Woche" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
voices: {
|
||||||
|
eyebrow: "Team",
|
||||||
|
title: "Das sagen die *Kolleg:innen.*",
|
||||||
|
items: [
|
||||||
|
{ name: "Kemal, 29", role: "Elektroniker seit 2019", quote: "Hier fragt der Chef nach deiner Meinung – und hört auch zu. Ich habe in fünf Jahren mehr gelernt als je zuvor.", image: unsplash("1607990281513-2c110a25bd8c", 800) },
|
||||||
|
{ name: "Jana, 24", role: "Azubi im 3. Lehrjahr", quote: "Ich dachte, Handwerk heißt Kaffee kochen. Stattdessen habe ich im zweiten Monat meine erste Wallbox installiert.", image: unsplash("1590650153855-d9e808231d41", 800) },
|
||||||
|
{ name: "Peter, 52", role: "Meister & Ausbilder", quote: "Wir arbeiten mit moderner Technik, aber der Zusammenhalt ist wie früher. Das gibt es selten.", image: unsplash("1574757987642-5755f0839101", 800) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
eyebrow: "Bewerbung",
|
||||||
|
title: "So einfach *geht's.*",
|
||||||
|
items: [
|
||||||
|
{ title: "Bewerben", text: "60 Sekunden, ohne Anschreiben und Lebenslauf." },
|
||||||
|
{ title: "Kennenlernen", text: "Wir rufen dich innerhalb von 48 Stunden an." },
|
||||||
|
{ title: "Probetag", text: "Lern das Team und die Baustelle kennen – bezahlt." },
|
||||||
|
{ title: "Loslegen", text: "Vertrag, Ausrüstung, Transporter: Willkommen!" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
apply: {
|
||||||
|
title: "Bewirb dich in *60 Sekunden.*",
|
||||||
|
text: "Kein Lebenslauf nötig – den kannst du später nachreichen.",
|
||||||
|
experience: "Berufseinsteiger:in, 1–3 Jahre, Mehr als 3 Jahre, Meister:in / Techniker:in",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const karriere = defineTemplate({
|
||||||
|
key: "karriere",
|
||||||
|
name: "Karriere",
|
||||||
|
industry: "Recruiting & Karriereseite",
|
||||||
|
description: "Nahbar und mutig: wechselnde Jobtitel im Hero, Benefits, filterbare Stellen, Bewerbung in 60 Sekunden in drei Schritten und Stimmen aus dem Team.",
|
||||||
|
cover: unsplash("1621905252507-b35492cc74b4", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Hallo, ich bin gelernter Elektroniker mit fünf Jahren Berufserfahrung und interessiere mich für die Stelle als Servicetechniker. Ist auch eine 4-Tage-Woche möglich, und mit welchem Gehalt kann ich rechnen? Viele Grüße, Daniel Schulz",
|
||||||
|
topic: "Ausbildungsstart 2027: Werde Elektroniker:in bei uns",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "sonne", label: "Sonne", bg: "#fffaf0", fg: "#1b1a17", muted: "#6d675c", surface: "#f6ecd6", accent: "#ffb703" },
|
||||||
|
{ key: "petrol", label: "Petrol", bg: "#f1f7f7", fg: "#0c2a2e", muted: "#557073", surface: "#ddeceb", accent: "#ff6b4a" },
|
||||||
|
{ key: "nacht", label: "Nacht", dark: true, bg: "#111214", fg: "#f4f4f5", muted: "#9b9ca3", surface: "#1b1c20", accent: "#a3e635" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "freundlich", label: "Gabarito & Onest" },
|
||||||
|
{ key: "klar", label: "Schibsted Grotesk" },
|
||||||
|
],
|
||||||
|
sections: describeSections(defaults, {
|
||||||
|
brand: "Unternehmen",
|
||||||
|
hero: "Startbereich",
|
||||||
|
benefits: { label: "Benefits", optional: true, items: { items: "Benefit" } },
|
||||||
|
facts: { label: "Zahlen", optional: true, items: { items: "Kennzahl" } },
|
||||||
|
jobs: { label: "Stellen", items: { items: "Stelle" } },
|
||||||
|
voices: { label: "Team-Stimmen", optional: true, items: { items: "Stimme" } },
|
||||||
|
process: { label: "Ablauf", optional: true, items: { items: "Schritt" } },
|
||||||
|
apply: "Schnellbewerbung",
|
||||||
|
}),
|
||||||
|
defaults,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type KarriereData = typeof karriere.defaults
|
||||||
12
modules/templates/catalog/karriere/fonts.ts
Normal file
12
modules/templates/catalog/karriere/fonts.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Gabarito, Onest, Schibsted_Grotesk } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts
|
||||||
|
const gabarito = Gabarito({ subsets: ["latin"], variable: "--font-karriere-gabarito", preload: false })
|
||||||
|
const onest = Onest({ subsets: ["latin"], variable: "--font-karriere-onest", preload: false })
|
||||||
|
const schibsted = Schibsted_Grotesk({ subsets: ["latin"], variable: "--font-karriere-schibsted", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
freundlich: { className: `${gabarito.variable} ${onest.variable}`, display: "var(--font-karriere-gabarito)", body: "var(--font-karriere-onest)" },
|
||||||
|
klar: { className: schibsted.variable, display: "var(--font-karriere-schibsted)", body: "var(--font-karriere-schibsted)" },
|
||||||
|
}
|
||||||
443
modules/templates/catalog/karriere/template.tsx
Normal file
443
modules/templates/catalog/karriere/template.tsx
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
BanknoteIcon,
|
||||||
|
BriefcaseIcon,
|
||||||
|
CalendarDaysIcon,
|
||||||
|
CheckIcon,
|
||||||
|
GraduationCapIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
PartyPopperIcon,
|
||||||
|
PlayIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TabletSmartphoneIcon,
|
||||||
|
TruckIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { TField, TSelect } from "../shared/fields"
|
||||||
|
import { splitList } from "../shared/hooks"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { Counter, Reveal, RevealWords, StepPanel, ease, useScrollNav } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { karriere, type KarriereData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1320px] px-5 md:px-8"
|
||||||
|
const heading = "font-t-display font-bold tracking-[-0.03em]"
|
||||||
|
const icons = [BanknoteIcon, CalendarDaysIcon, TruckIcon, GraduationCapIcon, PartyPopperIcon, TabletSmartphoneIcon]
|
||||||
|
const INITIATIVE = "Initiativ bewerben"
|
||||||
|
|
||||||
|
function Eyebrow({ children }: { children: string }) {
|
||||||
|
return <p className="mb-4 inline-block -rotate-1 rounded-lg bg-t-accent px-3 py-1 text-sm font-bold text-t-accent-fg">{children}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KarriereTemplate({ content }: TemplateProps<KarriereData>) {
|
||||||
|
const { brand, hero, benefits, facts, jobs, voices, process, apply } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const [job, setJob] = useState<string | null>(null)
|
||||||
|
const links = [
|
||||||
|
{ href: "#benefits", label: "Benefits", visible: show("benefits") },
|
||||||
|
{ href: "#stellen", label: "Stellen", visible: true },
|
||||||
|
{ href: "#team", label: "Team", visible: show("voices") },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={karriere} content={content} fonts={fonts[content.fonts] ?? fonts.freundlich}>
|
||||||
|
<Nav name={brand.name} links={links} />
|
||||||
|
<Hero hero={hero} openJobs={jobs.items.length} />
|
||||||
|
|
||||||
|
{show("benefits") && (
|
||||||
|
<section id="benefits" className={cn(container, "py-20 md:py-28")}>
|
||||||
|
<Reveal><Eyebrow>{benefits.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={benefits.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
|
||||||
|
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{benefits.items.map((item, i) => {
|
||||||
|
const Icon = icons[i % icons.length]
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={(i % 3) * 0.08}>
|
||||||
|
<motion.article
|
||||||
|
whileHover={{ rotate: i % 2 ? 1.5 : -1.5, y: -4 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 18 }}
|
||||||
|
className="h-full rounded-3xl border-2 border-t-fg/10 bg-t-bg p-7 transition-colors hover:border-t-accent"
|
||||||
|
>
|
||||||
|
<span className="grid size-12 place-items-center rounded-2xl bg-t-accent text-t-accent-fg">
|
||||||
|
<Icon className="size-6" />
|
||||||
|
</span>
|
||||||
|
<h3 className={cn(heading, "mt-5 text-xl")}>{item.title}</h3>
|
||||||
|
<p className="mt-2 text-t-muted">{item.text}</p>
|
||||||
|
</motion.article>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("facts") && (
|
||||||
|
<section className="bg-t-fg py-14 text-t-bg">
|
||||||
|
<dl className={cn(container, "grid grid-cols-2 gap-8 md:grid-cols-4")}>
|
||||||
|
{facts.items.map((fact, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dd className={cn(heading, "text-5xl md:text-6xl")}>
|
||||||
|
<Counter value={fact.value} />
|
||||||
|
<span className="text-t-accent">{fact.suffix}</span>
|
||||||
|
</dd>
|
||||||
|
<dt className="mt-1 text-sm opacity-70">{fact.label}</dt>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Jobs jobs={jobs} onApply={setJob} />
|
||||||
|
|
||||||
|
{show("voices") && (
|
||||||
|
<section id="team" className="bg-t-surface py-20 md:py-28">
|
||||||
|
<div className={container}>
|
||||||
|
<Reveal><Eyebrow>{voices.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={voices.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
|
||||||
|
<div className="mt-12 grid gap-5 md:grid-cols-3">
|
||||||
|
{voices.items.map((voice, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
|
<figure className="group h-full overflow-hidden rounded-3xl bg-t-bg">
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden">
|
||||||
|
<TImage src={voice.image} alt={voice.name} sizes="(min-width: 768px) 33vw, 100vw" className="transition-transform duration-700 group-hover:scale-105" />
|
||||||
|
<span className="absolute bottom-4 left-4 grid size-12 place-items-center rounded-full bg-t-accent text-t-accent-fg shadow-lg transition-transform group-hover:scale-110">
|
||||||
|
<PlayIcon className="ml-0.5 size-5 fill-current" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<blockquote className="p-6 text-lg leading-snug">„{voice.quote}“</blockquote>
|
||||||
|
<figcaption className="px-6 pb-6 text-sm">
|
||||||
|
<span className="font-bold">{voice.name}</span> <span className="text-t-muted">· {voice.role}</span>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("process") && (
|
||||||
|
<section className={cn(container, "py-20 md:py-28")}>
|
||||||
|
<Reveal><Eyebrow>{process.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={process.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
|
||||||
|
<ol className="relative mt-14 grid gap-10 md:grid-cols-4 md:gap-6">
|
||||||
|
<span aria-hidden className="absolute top-7 right-[12%] left-[12%] hidden border-t-2 border-dashed border-t-fg/20 md:block" />
|
||||||
|
{process.items.map((step, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.12}>
|
||||||
|
<li className="relative md:text-center">
|
||||||
|
<span className={cn(heading, "relative grid size-14 place-items-center rounded-full bg-t-accent text-xl text-t-accent-fg md:mx-auto")}>{i + 1}</span>
|
||||||
|
<p className={cn(heading, "mt-5 text-xl")}>{step.title}</p>
|
||||||
|
<p className="mt-1 text-t-muted">{step.text}</p>
|
||||||
|
</li>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Apply apply={apply} jobs={jobs.items} job={job} onJob={setJob} />
|
||||||
|
|
||||||
|
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||||
|
<p>{brand.name} · {brand.suffix} · {brand.email}</p>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ name, links }: { name: string; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||||
|
transition={{ duration: 0.45, ease }}
|
||||||
|
className={cn("fixed inset-x-0 top-0 z-50 border-b transition-colors duration-500", scrolled ? "border-t-fg/10 bg-t-bg/90 backdrop-blur-xl" : "border-transparent")}
|
||||||
|
>
|
||||||
|
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
|
||||||
|
<a href="#top" className="flex items-center gap-2">
|
||||||
|
<span className={cn(heading, "text-xl")}>{name}</span>
|
||||||
|
<span className="rounded-full bg-t-accent px-2.5 py-0.5 text-xs font-bold text-t-accent-fg">Karriere</span>
|
||||||
|
</a>
|
||||||
|
<div className="hidden items-center gap-8 font-medium md:flex">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-fg">{link.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a href="#bewerben" className="rounded-full bg-t-fg px-5 py-2.5 text-sm font-bold text-t-bg transition-transform hover:scale-105">Jetzt bewerben</a>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hero({ hero, openJobs }: { hero: KarriereData["hero"]; openJobs: number }) {
|
||||||
|
const roles = splitList(hero.roles)
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (roles.length < 2) return
|
||||||
|
const timer = setInterval(() => setIndex((i) => i + 1), 2200)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [roles.length])
|
||||||
|
|
||||||
|
const role = roles.length ? roles[index % roles.length] : ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="top" className={cn(container, "grid items-center gap-14 pt-32 pb-20 lg:grid-cols-[1.1fr_1fr] lg:pt-40")}>
|
||||||
|
<div>
|
||||||
|
<motion.p initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} className="mb-6 inline-flex items-center gap-2 rounded-full border-2 border-t-fg/10 px-4 py-1.5 text-sm font-medium">
|
||||||
|
<span className="size-2 animate-pulse rounded-full bg-emerald-500" /> {hero.eyebrow}
|
||||||
|
</motion.p>
|
||||||
|
<RevealWords as="h1" onMount delay={0.15} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(3.25rem,8vw,7.5rem)] leading-[0.92]")} />
|
||||||
|
{role && (
|
||||||
|
<div className={cn(heading, "mt-6 flex flex-wrap items-center gap-3 text-2xl md:text-3xl")}>
|
||||||
|
<span className="text-t-muted">Wir suchen</span>
|
||||||
|
<span className="relative inline-flex h-[1.4em] overflow-hidden rounded-xl bg-t-accent px-3 text-t-accent-fg">
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
<motion.span key={role} initial={{ y: "100%" }} animate={{ y: "0%" }} exit={{ y: "-100%" }} transition={{ duration: 0.45, ease }} className="leading-[1.4em]">
|
||||||
|
{role}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.6 }}>
|
||||||
|
<p className="mt-8 max-w-lg text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
||||||
|
<div className="mt-9 flex flex-wrap gap-3">
|
||||||
|
<a href="#stellen" className="inline-flex items-center gap-2 rounded-full bg-t-fg px-7 py-4 font-bold text-t-bg transition-transform hover:scale-105">
|
||||||
|
{hero.cta} <span className="rounded-full bg-t-accent px-2 text-sm text-t-accent-fg">{openJobs}</span>
|
||||||
|
</a>
|
||||||
|
<a href="#bewerben" className="inline-flex items-center gap-2 rounded-full border-2 border-t-fg px-7 py-4 font-bold transition-colors hover:bg-t-fg hover:text-t-bg">
|
||||||
|
{hero.ctaSecondary} <ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto aspect-square w-full max-w-[560px]">
|
||||||
|
{[
|
||||||
|
{ src: hero.imageSecondary, className: "top-0 left-0 w-[46%] -rotate-6", delay: 0.4 },
|
||||||
|
{ src: hero.image, className: "top-[12%] right-0 w-[58%] rotate-3", delay: 0.25 },
|
||||||
|
{ src: hero.imageThird, className: "bottom-0 left-[14%] w-[52%] -rotate-2", delay: 0.55 },
|
||||||
|
].map((photo, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 40, rotate: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.9, ease, delay: photo.delay }}
|
||||||
|
className={cn("absolute aspect-[4/5] overflow-hidden rounded-3xl border-[6px] border-t-bg bg-t-surface shadow-xl", photo.className)}
|
||||||
|
>
|
||||||
|
<TImage src={photo.src} eager={i === 1} sizes="(min-width: 1024px) 30vw, 60vw" />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1, rotate: [0, 360] }}
|
||||||
|
transition={{ scale: { type: "spring", bounce: 0.4, delay: 1 }, rotate: { duration: 22, repeat: Infinity, ease: "linear" } }}
|
||||||
|
className="absolute right-[6%] bottom-[8%] grid size-28 place-items-center rounded-full bg-t-accent p-3 text-center text-sm leading-tight font-black text-t-accent-fg shadow-xl md:size-32"
|
||||||
|
>
|
||||||
|
{hero.badge}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Jobs({ jobs, onApply }: { jobs: KarriereData["jobs"]; onApply: (job: string) => void }) {
|
||||||
|
const types = ["Alle", ...new Set(jobs.items.map((item) => item.type))]
|
||||||
|
const [filter, setFilter] = useState("Alle")
|
||||||
|
const [open, setOpen] = useState<number | null>(0)
|
||||||
|
const visible = jobs.items.map((item, i) => ({ item, i })).filter(({ item }) => filter === "Alle" || item.type === filter)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="stellen" className={cn(container, "py-20 md:py-28")}>
|
||||||
|
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{jobs.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={jobs.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{types.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={filter === type}
|
||||||
|
onClick={() => setFilter(type)}
|
||||||
|
className={cn("cursor-pointer rounded-full border-2 px-4 py-2 text-sm font-bold transition-colors", filter === type ? "border-t-fg bg-t-fg text-t-bg" : "border-t-fg/15 hover:border-t-fg")}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-10 space-y-3">
|
||||||
|
{visible.map(({ item, i }) => {
|
||||||
|
const isOpen = open === i
|
||||||
|
return (
|
||||||
|
<motion.li layout key={item.title} className={cn("overflow-hidden rounded-3xl border-2 transition-colors", isOpen ? "border-t-fg" : "border-t-fg/10")}>
|
||||||
|
<button type="button" aria-expanded={isOpen} onClick={() => setOpen(isOpen ? null : i)} className="flex w-full cursor-pointer items-center gap-4 p-5 text-left md:p-7">
|
||||||
|
<span className="hidden size-12 shrink-0 place-items-center rounded-2xl bg-t-surface sm:grid">
|
||||||
|
<BriefcaseIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className={cn(heading, "block text-lg md:text-2xl")}>{item.title}</span>
|
||||||
|
<span className="mt-2 flex flex-wrap gap-2 text-xs font-bold">
|
||||||
|
<span className="rounded-full bg-t-accent px-2.5 py-1 text-t-accent-fg">{item.type}</span>
|
||||||
|
<span className="rounded-full bg-t-surface px-2.5 py-1">{item.area}</span>
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-t-surface px-2.5 py-1"><MapPinIcon className="size-3" /> {item.location}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<PlusIcon className={cn("size-6 shrink-0 transition-transform duration-300", isOpen && "rotate-45")} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div initial={{ height: 0 }} animate={{ height: "auto" }} exit={{ height: 0 }} transition={{ duration: 0.4, ease }}>
|
||||||
|
<div className="flex flex-col gap-6 px-5 pb-6 md:flex-row md:items-end md:justify-between md:px-7 md:pb-7">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{splitList(item.tasks, "\n").map((task) => (
|
||||||
|
<li key={task} className="flex items-start gap-2.5">
|
||||||
|
<CheckIcon className="mt-1 size-4 shrink-0 text-t-accent" /> {task}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<a href="#bewerben" onClick={() => onApply(item.title)} className="inline-flex shrink-0 items-center gap-2 rounded-full bg-t-accent px-6 py-3.5 font-bold text-t-accent-fg">
|
||||||
|
Jetzt bewerben <ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Apply({ apply, jobs, job, onJob }: { apply: KarriereData["apply"]; jobs: KarriereData["jobs"]["items"]; job: string | null; onJob: (job: string | null) => void }) {
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [experience, setExperience] = useState<string | null>(null)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
const levels = splitList(apply.experience)
|
||||||
|
const current = job && step === 0 ? 1 : step
|
||||||
|
const progress = done ? 100 : ((current + 1) / 3) * 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="bewerben" className="bg-t-accent py-20 text-t-accent-fg md:py-28">
|
||||||
|
<div className={cn(container, "grid items-center gap-12 lg:grid-cols-2")}>
|
||||||
|
<div>
|
||||||
|
<RevealWords text={apply.title} emClassName="underline decoration-4 underline-offset-8" className={cn(heading, "text-[clamp(2.6rem,6vw,5.5rem)] leading-[0.95]")} />
|
||||||
|
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg opacity-80">{apply.text}</p></Reveal>
|
||||||
|
</div>
|
||||||
|
<div className="relative rounded-[2rem] bg-t-bg p-6 text-t-fg shadow-2xl md:p-9">
|
||||||
|
<div className="mb-8 h-2 overflow-hidden rounded-full bg-t-surface">
|
||||||
|
<motion.div className="h-full rounded-full bg-t-accent" animate={{ width: `${progress}%` }} transition={{ duration: 0.5, ease }} />
|
||||||
|
</div>
|
||||||
|
{done ? (
|
||||||
|
<div className="relative py-10 text-center">
|
||||||
|
{Array.from({ length: 18 }, (_, i) => {
|
||||||
|
const angle = (i / 18) * Math.PI * 2
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
key={i}
|
||||||
|
aria-hidden
|
||||||
|
className={cn("absolute top-1/2 left-1/2 size-2.5 rounded-sm", i % 3 === 0 ? "bg-t-accent" : i % 3 === 1 ? "bg-t-fg" : "bg-t-muted")}
|
||||||
|
initial={{ x: 0, y: 0, opacity: 1, rotate: 0 }}
|
||||||
|
animate={{ x: Math.cos(angle) * 160, y: Math.sin(angle) * 110, opacity: 0, rotate: 200 }}
|
||||||
|
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<p className={cn(heading, "text-4xl")}>Stark! 🎉</p>
|
||||||
|
<p className="mt-3 text-t-muted">Wir melden uns innerhalb von 48 Stunden bei dir.</p>
|
||||||
|
<p className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Bewerbung direkt im Postfach der Personalabteilung.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<StepPanel step={current}>
|
||||||
|
{current === 0 && (
|
||||||
|
<div>
|
||||||
|
<p className={cn(heading, "text-2xl")}>Welche Stelle?</p>
|
||||||
|
<div className="mt-5 grid gap-2">
|
||||||
|
{[...jobs.map((item) => item.title), INITIATIVE].map((title) => (
|
||||||
|
<button
|
||||||
|
key={title}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onJob(title)
|
||||||
|
setStep(1)
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center justify-between rounded-2xl border-2 border-t-fg/10 px-4 py-3.5 text-left font-medium transition-colors hover:border-t-accent"
|
||||||
|
>
|
||||||
|
{title} <ArrowRightIcon className="size-4 shrink-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{current === 1 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-t-muted">{job}</p>
|
||||||
|
<p className={cn(heading, "mt-1 text-2xl")}>Wie viel Erfahrung bringst du mit?</p>
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-2">
|
||||||
|
{levels.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setExperience(level)
|
||||||
|
setStep(2)
|
||||||
|
}}
|
||||||
|
className={cn("cursor-pointer rounded-2xl border-2 px-4 py-5 font-bold transition-colors", experience === level ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/10 hover:border-t-accent")}
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onJob(null)
|
||||||
|
setStep(0)
|
||||||
|
}}
|
||||||
|
className="mt-5 cursor-pointer text-sm text-t-muted hover:text-t-fg"
|
||||||
|
>
|
||||||
|
Andere Stelle wählen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{current === 2 && (
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setDone(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className={cn(heading, "text-2xl")}>Wie erreichen wir dich?</p>
|
||||||
|
<TField label="Vorname und Name" name="name" required autoComplete="name" variant="soft" />
|
||||||
|
<TField label="Handynummer" name="phone" type="tel" required autoComplete="tel" variant="soft" />
|
||||||
|
<TSelect label="Wann passt ein Anruf?" name="time" options={["Vormittags", "Mittags", "Nachmittags", "Abends"]} variant="soft" />
|
||||||
|
<button type="submit" className="mt-2 inline-flex cursor-pointer items-center justify-center gap-2 rounded-full bg-t-fg py-4 font-bold text-t-bg">
|
||||||
|
Bewerbung abschicken <ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setStep(1)} className="cursor-pointer text-sm text-t-muted hover:text-t-fg">Zurück</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</StepPanel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
modules/templates/catalog/praxis/definition.ts
Normal file
127
modules/templates/catalog/praxis/definition.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
brand: {
|
||||||
|
name: "Zahnwerk",
|
||||||
|
suffix: "Zahnarztpraxis Dr. Sommer",
|
||||||
|
phone: "089 123 456 78",
|
||||||
|
email: "praxis@zahnwerk-muenchen.de",
|
||||||
|
address: "Leopoldstraße 52, 80802 München",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Zahnarztpraxis in München-Schwabing",
|
||||||
|
title: "Gesunde Zähne.\n*Entspannte* Termine.",
|
||||||
|
text: "Moderne Zahnmedizin mit viel Zeit, Ruhe und verständlichen Erklärungen – auch für Angstpatient:innen. Termine buchen Sie bequem online.",
|
||||||
|
cta: "Termin online buchen",
|
||||||
|
image: unsplash("1629909613654-28e377c37b09", 1600),
|
||||||
|
rating: "4,9",
|
||||||
|
reviews: "320 Google-Bewertungen",
|
||||||
|
note: "Angstpatient:innen herzlich willkommen",
|
||||||
|
},
|
||||||
|
hours: {
|
||||||
|
items: [
|
||||||
|
{ day: "Montag", open: "08:00", close: "18:00" },
|
||||||
|
{ day: "Dienstag", open: "08:00", close: "18:00" },
|
||||||
|
{ day: "Mittwoch", open: "08:00", close: "14:00" },
|
||||||
|
{ day: "Donnerstag", open: "08:00", close: "19:00" },
|
||||||
|
{ day: "Freitag", open: "08:00", close: "14:00" },
|
||||||
|
{ day: "Samstag", open: "", close: "" },
|
||||||
|
{ day: "Sonntag", open: "", close: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
eyebrow: "Behandlungen",
|
||||||
|
title: "Alles für Ihr *Lächeln.*",
|
||||||
|
items: [
|
||||||
|
{ title: "Prophylaxe", text: "Professionelle Zahnreinigung für gesunde Zähne, festes Zahnfleisch und frischen Atem.", duration: "45 Min" },
|
||||||
|
{ title: "Ästhetik & Bleaching", text: "Hellere Zähne und harmonische Formen – schonend und mit natürlichem Ergebnis.", duration: "60 Min" },
|
||||||
|
{ title: "Implantate", text: "Fester Zahnersatz, der sich anfühlt wie eigene Zähne. Planung digital und in 3D.", duration: "Beratung 30 Min" },
|
||||||
|
{ title: "Unsichtbare Schienen", text: "Gerade Zähne ohne feste Spange – mit transparenten Alignern und digitaler Vorschau.", duration: "Beratung 30 Min" },
|
||||||
|
{ title: "Kinderzahnmedizin", text: "Spielerisch, geduldig und kindgerecht – damit der Zahnarztbesuch Spaß macht.", duration: "30 Min" },
|
||||||
|
{ title: "Angstpatient:innen", text: "Ruhige Atmosphäre, viel Zeit und auf Wunsch Behandlung im Dämmerschlaf.", duration: "nach Absprache" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
eyebrow: "Praxis",
|
||||||
|
title: "Eine Praxis zum *Wohlfühlen.*",
|
||||||
|
text: "Helle Räume, kurze Wartezeiten und ein Team, das sich Zeit nimmt. Wir erklären jeden Schritt, bevor wir ihn gehen – und arbeiten mit digitaler Technik, die Behandlungen kürzer und angenehmer macht.",
|
||||||
|
image: unsplash("1519494026892-80bbd2d6fd0d", 1400),
|
||||||
|
imageSecondary: unsplash("1598256989800-fe5f95da9787", 900),
|
||||||
|
facts: [
|
||||||
|
{ value: "15+", label: "Jahre Erfahrung" },
|
||||||
|
{ value: "6", label: "Behandlungsräume" },
|
||||||
|
{ value: "9.000", label: "zufriedene Patient:innen" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
booking: {
|
||||||
|
eyebrow: "Online-Termin",
|
||||||
|
title: "Ihr Termin in *drei* Schritten.",
|
||||||
|
text: "Behandlung wählen, Wunschzeit aussuchen, fertig. Wir bestätigen innerhalb weniger Stunden.",
|
||||||
|
slots: "08:30, 09:15, 10:00, 11:30, 14:00, 15:30, 16:15",
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
eyebrow: "Team",
|
||||||
|
title: "Menschen, die *zuhören.*",
|
||||||
|
members: [
|
||||||
|
{ name: "Dr. Julia Sommer", role: "Zahnärztin, Implantologie", image: unsplash("1559839734-2b71ea197ec2", 900) },
|
||||||
|
{ name: "Dr. Arjun Mehta", role: "Zahnarzt, Ästhetik", image: unsplash("1612349317150-e413f6a5b16d", 900) },
|
||||||
|
{ name: "Lisa Brandt", role: "Prophylaxe-Assistentin", image: unsplash("1594824476967-48c8b964273f", 900) },
|
||||||
|
{ name: "Tom Becker", role: "Zahnmedizinischer Fachangestellter", image: unsplash("1622253692010-333f2da6031d", 900) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testimonial: {
|
||||||
|
quote: "Ich hatte jahrelang Angst vor dem Zahnarzt. Hier wird jeder Schritt erklärt, niemand hetzt – zum ersten Mal gehe ich entspannt zur Kontrolle.",
|
||||||
|
author: "Sabine K., Patientin seit 2019",
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
eyebrow: "FAQ",
|
||||||
|
title: "Häufige *Fragen*",
|
||||||
|
items: [
|
||||||
|
{ question: "Nehmen Sie neue Patient:innen auf?", answer: "Ja, gern. Buchen Sie einfach online einen Termin für eine Erstuntersuchung oder rufen Sie uns an." },
|
||||||
|
{ question: "Behandeln Sie gesetzlich und privat Versicherte?", answer: "Wir behandeln alle Kassen- und Privatpatient:innen sowie Selbstzahler:innen. Kosten besprechen wir immer vorab." },
|
||||||
|
{ question: "Ich habe große Angst – was kann ich tun?", answer: "Sagen Sie es uns bei der Buchung. Wir planen mehr Zeit ein, erklären alles in Ruhe und bieten auf Wunsch Dämmerschlaf an." },
|
||||||
|
{ question: "Was mache ich bei akuten Schmerzen?", answer: "Rufen Sie uns während der Sprechzeiten an – wir halten täglich Notfalltermine frei. Außerhalb hilft der zahnärztliche Notdienst." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
eyebrow: "Anfahrt",
|
||||||
|
title: "So finden Sie *uns.*",
|
||||||
|
text: "Direkt an der U-Bahn Giselastraße (U3/U6). Parkhaus in zwei Minuten Entfernung, barrierefreier Zugang mit Aufzug.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const praxis = defineTemplate({
|
||||||
|
key: "praxis",
|
||||||
|
name: "Praxis",
|
||||||
|
industry: "Arzt, Zahnarzt & Physio",
|
||||||
|
description: "Freundlich und vertrauensvoll: Live-Öffnungsstatus, Behandlungen als Kacheln, Online-Terminbuchung in drei Schritten, Team und FAQ.",
|
||||||
|
cover: unsplash("1629909613654-28e377c37b09", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Hallo, ich habe seit ein paar Tagen Zahnschmerzen unten links und bin Angstpatientin. Haben Sie diese Woche noch einen Termin frei, und bieten Sie Behandlung im Dämmerschlaf an? Danke, Petra Lang",
|
||||||
|
topic: "Tipps für gesunde Kinderzähne zum Schulstart",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "minze", label: "Minze", bg: "#f3f8f6", fg: "#0f2a24", muted: "#5b716b", surface: "#e3efea", accent: "#13a37f" },
|
||||||
|
{ key: "himmel", label: "Himmel", bg: "#f4f7fb", fg: "#0d2140", muted: "#5b6b82", surface: "#e4ebf5", accent: "#2f6bff" },
|
||||||
|
{ key: "sand", label: "Sand", bg: "#faf6f0", fg: "#2a2118", muted: "#76695b", surface: "#f0e8dc", accent: "#c46a3a" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "klar", label: "Plus Jakarta Sans" },
|
||||||
|
{ key: "warm", label: "Newsreader & Figtree" },
|
||||||
|
],
|
||||||
|
sections: describeSections(defaults, {
|
||||||
|
brand: "Praxis & Kontakt",
|
||||||
|
hero: "Startbereich",
|
||||||
|
hours: { label: "Öffnungszeiten", items: { items: "Tag" } },
|
||||||
|
services: { label: "Behandlungen", optional: true, items: { items: "Behandlung" } },
|
||||||
|
about: { label: "Über die Praxis", optional: true, items: { facts: "Kennzahl" } },
|
||||||
|
booking: { label: "Online-Termin", optional: true },
|
||||||
|
team: { label: "Team", optional: true, items: { members: "Person" } },
|
||||||
|
testimonial: { label: "Patientenstimme", optional: true },
|
||||||
|
faq: { label: "FAQ", optional: true, items: { items: "Frage" } },
|
||||||
|
contact: "Anfahrt",
|
||||||
|
}),
|
||||||
|
defaults,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PraxisData = typeof praxis.defaults
|
||||||
12
modules/templates/catalog/praxis/fonts.ts
Normal file
12
modules/templates/catalog/praxis/fonts.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Figtree, Newsreader, Plus_Jakarta_Sans } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts
|
||||||
|
const jakarta = Plus_Jakarta_Sans({ subsets: ["latin"], variable: "--font-praxis-jakarta", preload: false })
|
||||||
|
const newsreader = Newsreader({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-praxis-newsreader", preload: false })
|
||||||
|
const figtree = Figtree({ subsets: ["latin"], variable: "--font-praxis-figtree", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
klar: { className: jakarta.variable, display: "var(--font-praxis-jakarta)", body: "var(--font-praxis-jakarta)" },
|
||||||
|
warm: { className: `${newsreader.variable} ${figtree.variable}`, display: "var(--font-praxis-newsreader)", body: "var(--font-praxis-figtree)" },
|
||||||
|
}
|
||||||
478
modules/templates/catalog/praxis/template.tsx
Normal file
478
modules/templates/catalog/praxis/template.tsx
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
BabyIcon,
|
||||||
|
CalendarCheckIcon,
|
||||||
|
CircleCheckIcon,
|
||||||
|
HeartHandshakeIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
ScanFaceIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
SmileIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
StarIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { TField, TSelect } from "../shared/fields"
|
||||||
|
import { minutesOf, splitList, useNow } from "../shared/hooks"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { Accordion, ParallaxImage, Reveal, RevealWords, StepPanel, ease, useScrollNav } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { praxis, type PraxisData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
type Hours = PraxisData["hours"]["items"]
|
||||||
|
|
||||||
|
const em = "text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1280px] px-5 md:px-8"
|
||||||
|
const heading = "font-t-display font-semibold tracking-[-0.025em]"
|
||||||
|
const icons = [SparklesIcon, SmileIcon, ShieldCheckIcon, ScanFaceIcon, BabyIcon, HeartHandshakeIcon]
|
||||||
|
const WEEKDAYS = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"]
|
||||||
|
|
||||||
|
function Eyebrow({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<p className="mb-5 inline-flex items-center gap-2 rounded-full bg-t-surface px-3 py-1.5 text-xs font-semibold text-t-accent">
|
||||||
|
<span className="size-1.5 rounded-full bg-t-accent" />
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useOpenStatus(hours: Hours) {
|
||||||
|
const now = useNow()
|
||||||
|
if (now === null) return null
|
||||||
|
|
||||||
|
const date = new Date(now)
|
||||||
|
const minutes = date.getHours() * 60 + date.getMinutes()
|
||||||
|
const today = WEEKDAYS[date.getDay()]
|
||||||
|
const entry = hours.find((h) => h.day === today)
|
||||||
|
const open = entry ? minutesOf(entry.open) : null
|
||||||
|
const close = entry ? minutesOf(entry.close) : null
|
||||||
|
if (entry && open !== null && close !== null && minutes >= open && minutes < close) {
|
||||||
|
return { open: true, today, label: `Jetzt geöffnet · bis ${entry.close} Uhr` }
|
||||||
|
}
|
||||||
|
for (let offset = 0; offset < 7; offset++) {
|
||||||
|
const name = WEEKDAYS[(date.getDay() + offset) % 7]
|
||||||
|
const next = hours.find((h) => h.day === name)
|
||||||
|
const opens = next ? minutesOf(next.open) : null
|
||||||
|
if (next && opens !== null && (offset > 0 || minutes < opens)) {
|
||||||
|
const when = offset === 0 ? "heute" : offset === 1 ? "morgen" : name
|
||||||
|
return { open: false, today, label: `Geschlossen · öffnet ${when} ${next.open} Uhr` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { open: false, today, label: "Derzeit geschlossen" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenPill({ hours, className }: { hours: Hours; className?: string }) {
|
||||||
|
const status = useOpenStatus(hours)
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex items-center gap-2 rounded-full bg-t-bg/90 px-3 py-1.5 text-xs font-medium shadow-sm ring-1 ring-t-fg/5", className)}>
|
||||||
|
<span className={cn("size-2 rounded-full", status?.open ? "animate-pulse bg-emerald-500" : "bg-t-muted/50")} />
|
||||||
|
{status?.label ?? "Öffnungszeiten"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PraxisTemplate({ content }: TemplateProps<PraxisData>) {
|
||||||
|
const { brand, hero, hours, services, about, booking, team, testimonial, faq, contact } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const bookingHref = show("booking") ? "#termin" : "#kontakt"
|
||||||
|
const links = [
|
||||||
|
{ href: "#leistungen", label: "Behandlungen", visible: show("services") },
|
||||||
|
{ href: "#team", label: "Team", visible: show("team") },
|
||||||
|
{ href: "#faq", label: "FAQ", visible: show("faq") },
|
||||||
|
{ href: "#kontakt", label: "Kontakt", visible: true },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={praxis} content={content} fonts={fonts[content.fonts] ?? fonts.klar}>
|
||||||
|
<Nav brand={brand} hours={hours.items} cta={hero.cta} href={bookingHref} links={links} />
|
||||||
|
|
||||||
|
<section id="top" className={cn(container, "grid items-center gap-14 pt-32 pb-20 md:grid-cols-2 md:pt-40 md:pb-28")}>
|
||||||
|
<div>
|
||||||
|
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease, delay: 0.1 }}>
|
||||||
|
<Eyebrow>{hero.eyebrow}</Eyebrow>
|
||||||
|
</motion.div>
|
||||||
|
<RevealWords as="h1" onMount delay={0.2} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(2.75rem,6vw,5.25rem)] leading-[1.02]")} />
|
||||||
|
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.7 }}>
|
||||||
|
<p className="mt-7 max-w-lg text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
||||||
|
<div className="mt-9 flex flex-wrap items-center gap-4">
|
||||||
|
<a href={bookingHref} className="group inline-flex items-center gap-3 rounded-full bg-t-accent px-7 py-4 font-semibold text-t-accent-fg shadow-[0_14px_40px_-12px_var(--t-accent)] transition-transform hover:-translate-y-0.5">
|
||||||
|
<CalendarCheckIcon className="size-5" /> {hero.cta}
|
||||||
|
</a>
|
||||||
|
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="inline-flex items-center gap-2 rounded-full px-5 py-4 font-medium ring-1 ring-t-fg/15 transition-colors hover:bg-t-surface">
|
||||||
|
<PhoneIcon className="size-4" /> {brand.phone}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-9 flex items-center gap-3 text-sm">
|
||||||
|
<span className="flex text-amber-400">
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<StarIcon key={i} className="size-4 fill-current" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{hero.rating}</span>
|
||||||
|
<span className="text-t-muted">· {hero.reviews}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div initial={{ opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 1.1, ease, delay: 0.2 }} className="relative">
|
||||||
|
<div className="relative aspect-[4/5] overflow-hidden rounded-[2.5rem] bg-t-surface">
|
||||||
|
<TImage src={hero.image} eager sizes="(min-width: 768px) 45vw, 100vw" />
|
||||||
|
</div>
|
||||||
|
<motion.div animate={{ y: [0, -10, 0] }} transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }} className="absolute top-8 -left-4 md:-left-10">
|
||||||
|
<OpenPill hours={hours.items} className="px-4 py-2.5 text-sm shadow-xl" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [0, 10, 0] }}
|
||||||
|
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
className="absolute -right-3 bottom-10 flex items-center gap-3 rounded-2xl bg-t-bg p-4 shadow-xl ring-1 ring-t-fg/5 md:-right-8"
|
||||||
|
>
|
||||||
|
<span className="grid size-11 place-items-center rounded-xl bg-t-accent text-t-accent-fg">
|
||||||
|
<HeartHandshakeIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<span className="max-w-40 text-sm font-medium leading-snug">{hero.note}</span>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("services") && (
|
||||||
|
<section id="leistungen" className={cn(container, "py-20 md:py-28")}>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<Reveal><Eyebrow>{services.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={services.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{services.items.map((item, i) => {
|
||||||
|
const Icon = icons[i % icons.length]
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={(i % 3) * 0.08}>
|
||||||
|
<article className="group h-full rounded-[1.75rem] bg-t-surface p-7 transition-all duration-500 hover:-translate-y-1 hover:bg-t-bg hover:shadow-[0_30px_60px_-30px_rgba(0,0,0,0.25)] hover:ring-1 hover:ring-t-fg/5">
|
||||||
|
<span className="grid size-12 place-items-center rounded-2xl bg-t-bg text-t-accent transition-colors duration-500 group-hover:bg-t-accent group-hover:text-t-accent-fg">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<h3 className={cn(heading, "mt-6 text-xl")}>{item.title}</h3>
|
||||||
|
<p className="mt-2 leading-relaxed text-t-muted">{item.text}</p>
|
||||||
|
<p className="mt-5 text-sm font-medium text-t-accent">{item.duration}</p>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("about") && (
|
||||||
|
<section className={cn(container, "grid items-center gap-14 py-20 md:grid-cols-2 md:py-28")}>
|
||||||
|
<div className="relative">
|
||||||
|
<ParallaxImage src={about.image} offset={40} className="aspect-[5/4] rounded-[2rem]" sizes="(min-width: 768px) 45vw, 100vw" />
|
||||||
|
<Reveal delay={0.2} className="absolute -right-2 -bottom-10 w-[42%] md:-right-10">
|
||||||
|
<div className="relative aspect-square overflow-hidden rounded-[1.5rem] border-8 border-t-bg">
|
||||||
|
<TImage src={about.imageSecondary} sizes="20vw" />
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<Reveal delay={0.1}><p className="mt-6 text-lg leading-relaxed text-t-muted">{about.text}</p></Reveal>
|
||||||
|
<dl className="mt-10 grid grid-cols-3 gap-4">
|
||||||
|
{about.facts.map((fact, i) => (
|
||||||
|
<Reveal key={i} delay={0.15 + i * 0.08} className="rounded-2xl bg-t-surface p-4">
|
||||||
|
<dd className={cn(heading, "text-3xl text-t-accent")}>{fact.value}</dd>
|
||||||
|
<dt className="mt-1 text-sm text-t-muted">{fact.label}</dt>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("booking") && <Booking booking={booking} services={services.items} hours={hours.items} />}
|
||||||
|
|
||||||
|
{show("team") && (
|
||||||
|
<section id="team" className={cn(container, "py-20 md:py-28")}>
|
||||||
|
<Reveal><Eyebrow>{team.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={team.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<div className="mt-12 grid grid-cols-2 gap-4 md:grid-cols-4 md:gap-6">
|
||||||
|
{team.members.map((member, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.08}>
|
||||||
|
<div className="group">
|
||||||
|
<div className="relative aspect-[4/5] overflow-hidden rounded-[1.75rem] bg-t-surface">
|
||||||
|
<TImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="transition-transform duration-700 group-hover:scale-105" />
|
||||||
|
</div>
|
||||||
|
<p className={cn(heading, "mt-4 text-lg")}>{member.name}</p>
|
||||||
|
<p className="text-sm text-t-muted">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("testimonial") && (
|
||||||
|
<section className={cn(container, "py-16 md:py-24")}>
|
||||||
|
<Reveal>
|
||||||
|
<figure className="relative overflow-hidden rounded-[2.5rem] bg-t-accent px-7 py-14 text-t-accent-fg md:px-16 md:py-20">
|
||||||
|
<span aria-hidden className="absolute -top-10 right-8 font-t-display text-[14rem] leading-none opacity-15">“</span>
|
||||||
|
<blockquote className={cn(heading, "relative max-w-4xl text-[clamp(1.6rem,3.2vw,2.75rem)] leading-[1.2]")}>{testimonial.quote}</blockquote>
|
||||||
|
<figcaption className="relative mt-8 text-sm font-medium opacity-80">{testimonial.author}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("faq") && (
|
||||||
|
<section id="faq" className={cn(container, "grid gap-10 py-20 md:grid-cols-[1fr_1.4fr] md:py-28")}>
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{faq.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={faq.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
</div>
|
||||||
|
<Accordion
|
||||||
|
items={faq.items}
|
||||||
|
className="space-y-3"
|
||||||
|
itemClassName="rounded-2xl bg-t-surface px-6"
|
||||||
|
questionClassName="py-5 font-semibold"
|
||||||
|
answerClassName="pb-6 leading-relaxed text-t-muted"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section id="kontakt" className={cn(container, "pb-20 md:pb-28")}>
|
||||||
|
<div className="grid gap-6 rounded-[2.5rem] bg-t-surface p-6 md:grid-cols-2 md:p-10">
|
||||||
|
<div className="p-2 md:p-4">
|
||||||
|
<Eyebrow>{contact.eyebrow}</Eyebrow>
|
||||||
|
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4vw,3.25rem)] leading-[1.05]")} />
|
||||||
|
<p className="mt-5 leading-relaxed text-t-muted">{contact.text}</p>
|
||||||
|
<div className="mt-8 space-y-3 text-sm">
|
||||||
|
<p className="flex items-center gap-3"><MapPinIcon className="size-4 text-t-accent" /> {brand.address}</p>
|
||||||
|
<p className="flex items-center gap-3"><PhoneIcon className="size-4 text-t-accent" /> {brand.phone}</p>
|
||||||
|
<p className="flex items-center gap-3"><CalendarCheckIcon className="size-4 text-t-accent" /> {brand.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HoursCard hours={hours.items} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||||
|
<p>© {brand.name} · {brand.suffix}</p>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ brand, hours, cta, href, links }: { brand: PraxisData["brand"]; hours: Hours; cta: string; href: string; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header animate={{ y: hidden ? "-130%" : "0%" }} transition={{ duration: 0.45, ease }} className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:pt-4">
|
||||||
|
<nav className={cn("mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 rounded-full px-3 transition-all duration-500 md:px-4", scrolled && "bg-t-bg/85 shadow-[0_10px_40px_-20px_rgba(0,0,0,0.3)] backdrop-blur-xl")}>
|
||||||
|
<a href="#top" className="flex items-center gap-2.5">
|
||||||
|
<span className="grid size-10 place-items-center rounded-full bg-t-accent text-t-accent-fg">
|
||||||
|
<SmileIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<span className="leading-tight">
|
||||||
|
<span className={cn(heading, "block text-lg")}>{brand.name}</span>
|
||||||
|
<span className="hidden text-xs text-t-muted sm:block">{brand.suffix}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div className="hidden items-center gap-7 text-sm font-medium lg:flex">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-accent">{link.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<OpenPill hours={hours} className="hidden md:inline-flex" />
|
||||||
|
<a href={href} className="rounded-full bg-t-accent px-5 py-2.5 text-sm font-semibold whitespace-nowrap text-t-accent-fg">{cta}</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoursCard({ hours }: { hours: Hours }) {
|
||||||
|
const status = useOpenStatus(hours)
|
||||||
|
return (
|
||||||
|
<div className="rounded-[2rem] bg-t-bg p-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className={cn(heading, "text-xl")}>Sprechzeiten</p>
|
||||||
|
<span className={cn("rounded-full px-3 py-1 text-xs font-medium", status?.open ? "bg-emerald-500/15 text-emerald-600" : "bg-t-surface text-t-muted")}>
|
||||||
|
{status?.open ? "Geöffnet" : "Geschlossen"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-6 divide-y divide-t-fg/5">
|
||||||
|
{hours.map((entry) => (
|
||||||
|
<div key={entry.day} className={cn("flex justify-between py-3 text-sm", status?.today === entry.day && "font-semibold text-t-accent")}>
|
||||||
|
<dt>{entry.day}</dt>
|
||||||
|
<dd>{minutesOf(entry.open) !== null ? `${entry.open} – ${entry.close} Uhr` : "geschlossen"}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextOpenDays(now: number, hours: Hours, count: number) {
|
||||||
|
const days: { key: string; weekday: string; date: string }[] = []
|
||||||
|
for (let offset = 1; days.length < count && offset < 21; offset++) {
|
||||||
|
const date = new Date(now + offset * 86_400_000)
|
||||||
|
const name = WEEKDAYS[date.getDay()]
|
||||||
|
const entry = hours.find((h) => h.day === name)
|
||||||
|
if (!entry || minutesOf(entry.open) === null) continue
|
||||||
|
days.push({
|
||||||
|
key: date.toDateString(),
|
||||||
|
weekday: name.slice(0, 2),
|
||||||
|
date: `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
function Booking({ booking, services, hours }: { booking: PraxisData["booking"]; services: PraxisData["services"]["items"]; hours: Hours }) {
|
||||||
|
const now = useNow()
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [service, setService] = useState<string | null>(null)
|
||||||
|
const [day, setDay] = useState<{ key: string; label: string } | null>(null)
|
||||||
|
const [slot, setSlot] = useState<string | null>(null)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
const days = now === null ? [] : nextOpenDays(now, hours, 6)
|
||||||
|
const slots = splitList(booking.slots)
|
||||||
|
const labels = ["Behandlung", "Termin", "Kontakt"]
|
||||||
|
const canContinue = step === 0 ? Boolean(service) : step === 1 ? Boolean(day && slot) : true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="termin" className="bg-t-surface py-20 md:py-28">
|
||||||
|
<div className={cn(container, "grid gap-12 lg:grid-cols-[0.8fr_1.2fr]")}>
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{booking.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={booking.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg leading-relaxed text-t-muted">{booking.text}</p></Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<div className="rounded-[2rem] bg-t-bg p-6 shadow-[0_40px_80px_-40px_rgba(0,0,0,0.25)] md:p-9">
|
||||||
|
{done ? (
|
||||||
|
<motion.div initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} className="py-8 text-center">
|
||||||
|
<span className="mx-auto grid size-16 place-items-center rounded-full bg-t-accent text-t-accent-fg">
|
||||||
|
<CircleCheckIcon className="size-8" />
|
||||||
|
</span>
|
||||||
|
<p className={cn(heading, "mt-6 text-2xl")}>Termin angefragt!</p>
|
||||||
|
<p className="mt-2 text-t-muted">{service} · {day?.label} um {slot} Uhr</p>
|
||||||
|
<p className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Buchung direkt im Praxiskalender.</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-8 grid grid-cols-3 gap-2">
|
||||||
|
{labels.map((label, i) => (
|
||||||
|
<div key={label}>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-t-surface">
|
||||||
|
<motion.div className="h-full bg-t-accent" initial={false} animate={{ width: i <= step ? "100%" : "0%" }} transition={{ duration: 0.5, ease }} />
|
||||||
|
</div>
|
||||||
|
<p className={cn("mt-2 text-xs font-medium", i <= step ? "text-t-fg" : "text-t-muted")}>{i + 1}. {label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepPanel step={step}>
|
||||||
|
{step === 0 && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{services.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.title}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setService(item.title)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer rounded-2xl p-4 text-left ring-1 transition-all",
|
||||||
|
service === item.title ? "bg-t-accent text-t-accent-fg ring-t-accent" : "ring-t-fg/10 hover:ring-t-accent/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block font-semibold">{item.title}</span>
|
||||||
|
<span className="mt-1 block text-xs opacity-70">{item.duration}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step === 1 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Tag wählen</p>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||||
|
{days.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDay({ key: d.key, label: `${d.weekday}, ${d.date}` })}
|
||||||
|
className={cn("cursor-pointer rounded-2xl py-3 text-center ring-1 transition-all", day?.key === d.key ? "bg-t-accent text-t-accent-fg ring-t-accent" : "ring-t-fg/10 hover:ring-t-accent/50")}
|
||||||
|
>
|
||||||
|
<span className="block text-xs opacity-70">{d.weekday}</span>
|
||||||
|
<span className="block font-semibold">{d.date}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-sm font-medium">Uhrzeit wählen</p>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||||
|
{slots.map((time) => (
|
||||||
|
<button
|
||||||
|
key={time}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSlot(time)}
|
||||||
|
className={cn("cursor-pointer rounded-xl py-2.5 text-sm font-medium ring-1 transition-all", slot === time ? "bg-t-accent text-t-accent-fg ring-t-accent" : "ring-t-fg/10 hover:ring-t-accent/50")}
|
||||||
|
>
|
||||||
|
{time}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<form
|
||||||
|
id="praxis-booking"
|
||||||
|
className="grid gap-4 sm:grid-cols-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setDone(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TField label="Vor- und Nachname" name="name" required autoComplete="name" variant="soft" className="sm:col-span-2" />
|
||||||
|
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="soft" />
|
||||||
|
<TField label="E-Mail" name="email" type="email" autoComplete="email" variant="soft" />
|
||||||
|
<TSelect label="Versicherung" name="insurance" options={["Gesetzlich versichert", "Privat versichert", "Selbstzahler:in"]} variant="soft" className="sm:col-span-2" />
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</StepPanel>
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center justify-between gap-4">
|
||||||
|
<button type="button" onClick={() => setStep((s) => Math.max(0, s - 1))} className={cn("cursor-pointer text-sm font-medium text-t-muted hover:text-t-fg", step === 0 && "invisible")}>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
{step < 2 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canContinue}
|
||||||
|
onClick={() => setStep((s) => s + 1)}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-2 rounded-full bg-t-accent px-6 py-3 font-semibold text-t-accent-fg transition-opacity disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Weiter <ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="submit" form="praxis-booking" className="inline-flex cursor-pointer items-center gap-2 rounded-full bg-t-accent px-6 py-3 font-semibold text-t-accent-fg">
|
||||||
|
Termin anfragen <ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
205
modules/templates/catalog/restaurant/definition.ts
Normal file
205
modules/templates/catalog/restaurant/definition.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { defineTemplate, RICH_HINT, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
export const restaurant = defineTemplate({
|
||||||
|
key: "restaurant",
|
||||||
|
name: "Osteria",
|
||||||
|
industry: "Restaurant & Café",
|
||||||
|
description: "Warm und atmosphärisch: großes Bild-Intro, Speisekarte mit Kategorien, Galerie zum Durchscrollen und Reservierung.",
|
||||||
|
cover: unsplash("1517248135467-4c7edcad34c4", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Hallo, wir möchten am 12. Dezember unsere Weihnachtsfeier bei Ihnen machen – etwa 25 Personen, gern mit Menü und Weinbegleitung. Geht das, und was würde es ungefähr kosten? Viele Grüße, Sandra Becker",
|
||||||
|
topic: "Neues Herbstmenü mit Trüffel ab nächster Woche",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "glut", label: "Glut", dark: true, bg: "#14110f", fg: "#f3ece2", muted: "#a89c8c", surface: "#1f1a17", accent: "#d9a25f" },
|
||||||
|
{ key: "leinen", label: "Leinen", bg: "#f4efe6", fg: "#1f1a16", muted: "#6f655b", surface: "#ebe3d6", accent: "#a3472d" },
|
||||||
|
{ key: "olive", label: "Olive", dark: true, bg: "#181d17", fg: "#ece8dc", muted: "#9aa08f", surface: "#222920", accent: "#c9b26b" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "klassisch", label: "Fraunces & Manrope" },
|
||||||
|
{ key: "modern", label: "Instrument Serif & Sans" },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
key: "brand",
|
||||||
|
label: "Marke",
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "tagline", label: "Untertitel" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hero",
|
||||||
|
label: "Startbereich",
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "image", label: "Bild", type: "image" },
|
||||||
|
{ key: "cta", label: "Button" },
|
||||||
|
{ key: "hours", label: "Öffnungszeiten (kurz)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "marquee",
|
||||||
|
label: "Laufband",
|
||||||
|
optional: true,
|
||||||
|
fields: [{ key: "items", label: "Begriffe", type: "list", itemLabel: "Begriff", fields: [{ key: "text", label: "Text" }] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "about",
|
||||||
|
label: "Über uns",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "signature", label: "Unterschrift" },
|
||||||
|
{ key: "image", label: "Bild groß", type: "image" },
|
||||||
|
{ key: "imageSecondary", label: "Bild klein", type: "image" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "menu",
|
||||||
|
label: "Speisekarte",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{
|
||||||
|
key: "dishes",
|
||||||
|
label: "Gerichte",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Gericht",
|
||||||
|
fields: [
|
||||||
|
{ key: "category", label: "Kategorie (wird zum Reiter)" },
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "description", label: "Beschreibung", type: "textarea" },
|
||||||
|
{ key: "price", label: "Preis" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gallery",
|
||||||
|
label: "Galerie",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{
|
||||||
|
key: "images",
|
||||||
|
label: "Bilder",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Bild",
|
||||||
|
fields: [
|
||||||
|
{ key: "src", label: "Bild", type: "image" },
|
||||||
|
{ key: "caption", label: "Bildunterschrift" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "quote",
|
||||||
|
label: "Gästestimme",
|
||||||
|
optional: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "text", label: "Zitat", type: "textarea" },
|
||||||
|
{ key: "author", label: "Von" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reservation",
|
||||||
|
label: "Reservierung & Kontakt",
|
||||||
|
fields: [
|
||||||
|
{ key: "eyebrow", label: "Überzeile" },
|
||||||
|
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
|
||||||
|
{ key: "text", label: "Text", type: "textarea" },
|
||||||
|
{ key: "address", label: "Adresse" },
|
||||||
|
{ key: "phone", label: "Telefon" },
|
||||||
|
{ key: "email", label: "E-Mail" },
|
||||||
|
{
|
||||||
|
key: "hours",
|
||||||
|
label: "Öffnungszeiten",
|
||||||
|
type: "list",
|
||||||
|
itemLabel: "Zeile",
|
||||||
|
fields: [
|
||||||
|
{ key: "days", label: "Tage" },
|
||||||
|
{ key: "time", label: "Uhrzeit" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaults: {
|
||||||
|
brand: { name: "Lumen", tagline: "Osteria & Weinbar" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Osteria & Weinbar · seit 2009",
|
||||||
|
title: "Wo Abende\n*länger* werden.",
|
||||||
|
text: "Saisonale italienische Küche, natürliche Weine und ein Raum, in dem man gern bleibt – mitten in der Altstadt.",
|
||||||
|
image: unsplash("1517248135467-4c7edcad34c4"),
|
||||||
|
cta: "Tisch reservieren",
|
||||||
|
hours: "Heute geöffnet · 17 – 23 Uhr",
|
||||||
|
},
|
||||||
|
marquee: {
|
||||||
|
items: [{ text: "Hausgemachte Pasta" }, { text: "Natürliche Weine" }, { text: "Regionale Zutaten" }, { text: "Offene Küche" }],
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
eyebrow: "Über uns",
|
||||||
|
title: "Wir kochen, was die Saison uns *schenkt.*",
|
||||||
|
text: "Jeden Morgen beginnt unser Tag auf dem Markt. Was dort am besten aussieht, landet abends auf dem Teller – ehrlich, langsam gekocht und mit viel Liebe zum Detail. Dazu Weine von kleinen Winzern, die wir persönlich kennen.",
|
||||||
|
signature: "Marco & Lena, Gastgeber",
|
||||||
|
image: unsplash("1577219491135-ce391730fb2c", 1400),
|
||||||
|
imageSecondary: unsplash("1551218808-94e220e084d2", 800),
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
eyebrow: "Speisekarte",
|
||||||
|
title: "Unsere *Karte*",
|
||||||
|
text: "Sie wechselt mit der Saison. Fragen Sie gern nach unseren Tagesempfehlungen.",
|
||||||
|
dishes: [
|
||||||
|
{ category: "Antipasti", name: "Vitello Tonnato", description: "Zart rosa Kalbsrücken, Thunfischcreme, Kapernäpfel", price: "16" },
|
||||||
|
{ category: "Antipasti", name: "Burrata", description: "Cremige Burrata, Erbstomaten, Basilikumöl", price: "14" },
|
||||||
|
{ category: "Antipasti", name: "Carpaccio di Manzo", description: "Rinderfilet, Rucola, 24 Monate gereifter Parmesan", price: "17" },
|
||||||
|
{ category: "Primi", name: "Tagliatelle al Ragù", description: "Hausgemachte Tagliatelle, sechs Stunden geschmortes Ragù", price: "19" },
|
||||||
|
{ category: "Primi", name: "Risotto ai Funghi", description: "Carnaroli-Reis, Steinpilze, frischer Thymian", price: "21" },
|
||||||
|
{ category: "Primi", name: "Ravioli di Ricotta", description: "Ricotta, braune Salbeibutter, Zitronenabrieb", price: "18" },
|
||||||
|
{ category: "Secondi", name: "Branzino", description: "Wolfsbarsch vom Grill, Fenchel, Blutorange", price: "29" },
|
||||||
|
{ category: "Secondi", name: "Tagliata di Manzo", description: "Rinderhüfte, Rosmarinkartoffeln, Rucola", price: "32" },
|
||||||
|
{ category: "Dolci", name: "Tiramisù", description: "Nach dem Rezept der Nonna", price: "9" },
|
||||||
|
{ category: "Dolci", name: "Panna Cotta", description: "Bourbon-Vanille, Waldbeeren", price: "8" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gallery: {
|
||||||
|
eyebrow: "Einblicke",
|
||||||
|
title: "Ein Abend\nbei *uns.*",
|
||||||
|
images: [
|
||||||
|
{ src: unsplash("1414235077428-338989a2e8c0", 1400), caption: "Der Gastraum" },
|
||||||
|
{ src: unsplash("1504674900247-0877df9cc836", 1400), caption: "Aus der Küche" },
|
||||||
|
{ src: unsplash("1510812431401-41d2bd2722f3", 1400), caption: "Natürliche Weine" },
|
||||||
|
{ src: unsplash("1555396273-367ea4eb4db5", 1400), caption: "Unsere Bar" },
|
||||||
|
{ src: unsplash("1424847651672-bf20a4b0982b", 1400), caption: "Teilen erwünscht" },
|
||||||
|
{ src: unsplash("1559339352-11d035aa65de", 1400), caption: "Die Sommerterrasse" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
quote: {
|
||||||
|
text: "Man kommt wegen der Pasta und bleibt wegen der Atmosphäre. Für uns der schönste Abend der Woche.",
|
||||||
|
author: "Julia & Tom, Stammgäste",
|
||||||
|
},
|
||||||
|
reservation: {
|
||||||
|
eyebrow: "Reservierung",
|
||||||
|
title: "Reservieren Sie\nIhren *Tisch.*",
|
||||||
|
text: "Für Gruppen ab acht Personen oder private Feiern rufen Sie uns am besten direkt an.",
|
||||||
|
address: "Weinsteige 12, 70180 Stuttgart",
|
||||||
|
phone: "0711 123 45 67",
|
||||||
|
email: "ciao@lumen-osteria.de",
|
||||||
|
hours: [
|
||||||
|
{ days: "Dienstag – Donnerstag", time: "17 – 23 Uhr" },
|
||||||
|
{ days: "Freitag & Samstag", time: "17 – 24 Uhr" },
|
||||||
|
{ days: "Sonntag & Montag", time: "Ruhetag" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RestaurantData = typeof restaurant.defaults
|
||||||
22
modules/templates/catalog/restaurant/fonts.ts
Normal file
22
modules/templates/catalog/restaurant/fonts.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Fraunces, Instrument_Sans, Instrument_Serif, Manrope } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – die Vorlagen-Seiten binden alle Vorlagen ein; der Browser lädt nur die Schriften,
|
||||||
|
// die die angezeigte Variante tatsächlich nutzt.
|
||||||
|
const fraunces = Fraunces({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-fraunces", preload: false })
|
||||||
|
const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope", preload: false })
|
||||||
|
const instrumentSerif = Instrument_Serif({ subsets: ["latin"], weight: "400", style: ["normal", "italic"], variable: "--font-instrument-serif", preload: false })
|
||||||
|
const instrumentSans = Instrument_Sans({ subsets: ["latin"], variable: "--font-instrument-sans", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
klassisch: {
|
||||||
|
className: `${fraunces.variable} ${manrope.variable}`,
|
||||||
|
display: "var(--font-fraunces)",
|
||||||
|
body: "var(--font-manrope)",
|
||||||
|
},
|
||||||
|
modern: {
|
||||||
|
className: `${instrumentSerif.variable} ${instrumentSans.variable}`,
|
||||||
|
display: "var(--font-instrument-serif)",
|
||||||
|
body: "var(--font-instrument-sans)",
|
||||||
|
},
|
||||||
|
}
|
||||||
386
modules/templates/catalog/restaurant/template.tsx
Normal file
386
modules/templates/catalog/restaurant/template.tsx
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react"
|
||||||
|
import { useRef, useState, type InputHTMLAttributes } from "react"
|
||||||
|
import { ArrowRightIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { DemoForm } from "../shared/demo-form"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { HorizontalScroll, Marquee, ParallaxImage, Reveal, RevealWords, Rich, ease, useScrollNav, wordmarkSize } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { restaurant, type RestaurantData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "italic text-t-accent"
|
||||||
|
const eyebrow = "mb-6 text-[11px] font-medium uppercase tracking-[0.32em] text-t-accent"
|
||||||
|
const container = "mx-auto w-full max-w-[1400px] px-6 md:px-10"
|
||||||
|
|
||||||
|
export function RestaurantTemplate({ content }: TemplateProps<RestaurantData>) {
|
||||||
|
const { brand, hero, marquee, about, menu, gallery, quote, reservation } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const links = [
|
||||||
|
{ href: "#karte", label: "Karte", visible: show("menu") },
|
||||||
|
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
|
||||||
|
{ href: "#galerie", label: "Einblicke", visible: show("gallery") },
|
||||||
|
{ href: "#reservieren", label: "Kontakt", visible: true },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={restaurant} content={content} fonts={fonts[content.fonts] ?? fonts.klassisch}>
|
||||||
|
<Nav name={brand.name} cta={hero.cta} links={links} />
|
||||||
|
<Hero hero={hero} />
|
||||||
|
|
||||||
|
{show("marquee") && (
|
||||||
|
<section className="border-y border-t-fg/10 py-8 md:py-10">
|
||||||
|
<Marquee duration={38}>
|
||||||
|
{marquee.items.map((item, i) => (
|
||||||
|
<span key={i} className="flex items-center font-t-display text-4xl italic md:text-6xl">
|
||||||
|
<span className="px-8 md:px-14">{item.text}</span>
|
||||||
|
<span className="text-2xl not-italic text-t-accent md:text-3xl">✦</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("about") && (
|
||||||
|
<section id="ueber-uns" className={cn(container, "grid gap-20 py-28 md:grid-cols-12 md:gap-10 md:py-44")}>
|
||||||
|
<div className="md:col-span-6 md:pt-20">
|
||||||
|
<Reveal><p className={eyebrow}>{about.eyebrow}</p></Reveal>
|
||||||
|
<RevealWords
|
||||||
|
text={about.title}
|
||||||
|
emClassName={em}
|
||||||
|
className="font-t-display text-[clamp(2.5rem,5.2vw,4.75rem)] leading-[1.02] tracking-[-0.015em]"
|
||||||
|
/>
|
||||||
|
<Reveal delay={0.15}>
|
||||||
|
<p className="mt-10 max-w-lg text-lg leading-relaxed text-t-muted">{about.text}</p>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={0.25}>
|
||||||
|
<p className="mt-10 font-t-display text-2xl italic">— {about.signature}</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
<div className="relative md:col-span-5 md:col-start-8">
|
||||||
|
<ParallaxImage src={about.image} offset={50} className="aspect-[3/4]" sizes="(min-width: 768px) 40vw, 100vw" />
|
||||||
|
<Reveal delay={0.2} className="absolute -bottom-12 -left-4 w-[46%] md:-left-28">
|
||||||
|
<div className="relative aspect-square overflow-hidden border-[10px] border-t-bg">
|
||||||
|
<TImage src={about.imageSecondary} sizes="(min-width: 768px) 20vw, 45vw" />
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("menu") && <Menu menu={menu} />}
|
||||||
|
|
||||||
|
{show("gallery") && (
|
||||||
|
<div id="galerie">
|
||||||
|
<HorizontalScroll
|
||||||
|
trackClassName="items-start gap-5 px-6 md:gap-8 md:px-10"
|
||||||
|
header={
|
||||||
|
<div className={cn(container, "mb-10 flex items-end justify-between md:mb-14")}>
|
||||||
|
<div>
|
||||||
|
<p className={eyebrow}>{gallery.eyebrow}</p>
|
||||||
|
<h2 className="font-t-display text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.98]">
|
||||||
|
<Rich text={gallery.title} emClassName={em} />
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="hidden text-sm text-t-muted md:block">Scrollen zum Entdecken →</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{gallery.images.map((image, i) => (
|
||||||
|
<figure key={i} className={cn("group shrink-0", i % 2 === 1 && "mt-12 md:mt-20")}>
|
||||||
|
<div className="relative h-[42vh] w-[74vw] overflow-hidden md:h-[50vh] md:w-[32vw]">
|
||||||
|
<TImage
|
||||||
|
src={image.src}
|
||||||
|
alt={image.caption}
|
||||||
|
sizes="(min-width: 768px) 32vw, 74vw"
|
||||||
|
className="transition-transform duration-[1.4s] ease-out group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<figcaption className="mt-4 flex items-baseline gap-3 text-sm text-t-muted">
|
||||||
|
<span className="font-t-display text-base italic text-t-accent">{String(i + 1).padStart(2, "0")}</span>
|
||||||
|
{image.caption}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</HorizontalScroll>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("quote") && (
|
||||||
|
<section className="mx-auto max-w-[1100px] px-6 py-28 text-center md:py-44">
|
||||||
|
<Reveal>
|
||||||
|
<span aria-hidden className="block font-t-display text-8xl leading-[0.5] text-t-accent">“</span>
|
||||||
|
</Reveal>
|
||||||
|
<RevealWords
|
||||||
|
as="p"
|
||||||
|
text={quote.text}
|
||||||
|
className="mt-8 font-t-display text-[clamp(1.9rem,4vw,3.5rem)] italic leading-[1.15] tracking-[-0.01em]"
|
||||||
|
/>
|
||||||
|
<Reveal delay={0.3}>
|
||||||
|
<p className="mt-10 text-[11px] uppercase tracking-[0.32em] text-t-muted">{quote.author}</p>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section id="reservieren" className="border-t border-t-fg/10">
|
||||||
|
<div className={cn(container, "grid gap-16 py-28 md:grid-cols-2 md:gap-24 md:py-40")}>
|
||||||
|
<div>
|
||||||
|
<Reveal><p className={eyebrow}>{reservation.eyebrow}</p></Reveal>
|
||||||
|
<RevealWords
|
||||||
|
text={reservation.title}
|
||||||
|
emClassName={em}
|
||||||
|
className="font-t-display text-[clamp(2.5rem,5vw,4.5rem)] leading-[1.0] tracking-[-0.015em]"
|
||||||
|
/>
|
||||||
|
<Reveal delay={0.15}>
|
||||||
|
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{reservation.text}</p>
|
||||||
|
<dl className="mt-12 divide-y divide-t-fg/10 border-y border-t-fg/10">
|
||||||
|
{reservation.hours.map((row, i) => (
|
||||||
|
<div key={i} className="flex items-baseline justify-between gap-6 py-4">
|
||||||
|
<dt className="text-t-muted">{row.days}</dt>
|
||||||
|
<dd className="font-t-display text-xl">{row.time}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<div className="mt-10 grid gap-1.5 text-sm">
|
||||||
|
<p className="text-t-muted">{reservation.address}</p>
|
||||||
|
<a href={`tel:${reservation.phone.replace(/\s/g, "")}`} className="w-fit hover:text-t-accent">{reservation.phone}</a>
|
||||||
|
<a href={`mailto:${reservation.email}`} className="w-fit hover:text-t-accent">{reservation.email}</a>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<DemoForm
|
||||||
|
className="bg-t-surface p-8 md:p-12"
|
||||||
|
success={
|
||||||
|
<div className="py-10">
|
||||||
|
<p className="font-t-display text-5xl italic text-t-accent">Grazie!</p>
|
||||||
|
<p className="mt-4 text-t-muted">Wir bestätigen Ihre Reservierung in Kürze per E-Mail.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="font-t-display text-3xl">Tischanfrage</p>
|
||||||
|
<div className="mt-8 grid gap-7 sm:grid-cols-2">
|
||||||
|
<Line label="Name" name="name" autoComplete="name" required className="sm:col-span-2" />
|
||||||
|
<Line label="Datum" name="date" type="date" required />
|
||||||
|
<Line label="Uhrzeit" name="time" type="time" defaultValue="19:00" required />
|
||||||
|
<Line label="Personen" name="guests" type="number" min={1} max={12} defaultValue={2} />
|
||||||
|
<Line label="Telefon" name="phone" type="tel" autoComplete="tel" />
|
||||||
|
<Line label="Anmerkungen" name="note" placeholder="Allergien, Anlass, Wunschplatz …" className="sm:col-span-2" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="group mt-10 flex w-full items-center justify-center gap-3 rounded-full bg-t-accent py-4 text-sm font-medium text-t-accent-fg transition-transform hover:scale-[1.01]"
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</button>
|
||||||
|
</DemoForm>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="overflow-hidden border-t border-t-fg/10 pt-16">
|
||||||
|
<div className={cn(container, "flex flex-col gap-8 text-sm text-t-muted md:flex-row md:items-center md:justify-between")}>
|
||||||
|
<p>{brand.name} · {brand.tagline} · {reservation.address}</p>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<a href="#" className="hover:text-t-fg">Instagram</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<motion.p
|
||||||
|
aria-hidden
|
||||||
|
initial={{ y: "35%", opacity: 0 }}
|
||||||
|
whileInView={{ y: "0%", opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 1.4, ease }}
|
||||||
|
style={{ fontSize: wordmarkSize(brand.name) }}
|
||||||
|
className="mt-10 select-none whitespace-nowrap text-center font-t-display italic leading-[0.8] tracking-[-0.03em] text-t-accent"
|
||||||
|
>
|
||||||
|
{brand.name}
|
||||||
|
</motion.p>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||||
|
transition={{ duration: 0.45, ease }}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 top-0 z-50 border-b transition-colors duration-500",
|
||||||
|
scrolled ? "border-t-fg/10 bg-t-bg/80 backdrop-blur-xl" : "border-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<nav className={cn(container, "flex h-20 items-center justify-between")}>
|
||||||
|
<a href="#top" className="font-t-display text-2xl italic tracking-tight">{name}</a>
|
||||||
|
<div className="hidden items-center gap-10 text-sm md:flex">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="group relative text-t-fg/75 transition-colors hover:text-t-fg">
|
||||||
|
{link.label}
|
||||||
|
<span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-0 bg-t-accent transition-transform duration-500 group-hover:scale-x-100" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="#reservieren"
|
||||||
|
className="rounded-full border border-t-fg/25 px-5 py-2.5 text-sm transition-colors hover:border-t-accent hover:bg-t-accent hover:text-t-accent-fg"
|
||||||
|
>
|
||||||
|
{cta}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hero({ hero }: { hero: RestaurantData["hero"] }) {
|
||||||
|
const ref = useRef<HTMLElement>(null)
|
||||||
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
|
||||||
|
const y = useTransform(scrollYProgress, [0, 1], ["0%", "28%"])
|
||||||
|
const opacity = useTransform(scrollYProgress, [0, 0.65], [1, 0])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} id="top" className="relative flex min-h-svh items-end overflow-hidden">
|
||||||
|
<motion.div className="absolute inset-0" style={{ y }}>
|
||||||
|
<motion.div className="absolute inset-0" initial={{ scale: 1.18 }} animate={{ scale: 1 }} transition={{ duration: 2.6, ease }}>
|
||||||
|
<TImage src={hero.image} eager />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-t-bg via-t-bg/30 to-t-bg/70" />
|
||||||
|
|
||||||
|
<motion.div style={{ opacity }} className={cn(container, "relative pt-40 pb-16 md:pb-24")}>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease, delay: 0.3 }}
|
||||||
|
className={eyebrow}
|
||||||
|
>
|
||||||
|
{hero.eyebrow}
|
||||||
|
</motion.p>
|
||||||
|
<RevealWords
|
||||||
|
as="h1"
|
||||||
|
onMount
|
||||||
|
delay={0.45}
|
||||||
|
text={hero.title}
|
||||||
|
emClassName={em}
|
||||||
|
className="font-t-display text-[clamp(3.4rem,10vw,10rem)] leading-[0.9] tracking-[-0.025em]"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.9, ease, delay: 1.1 }}
|
||||||
|
className="mt-10 flex flex-col gap-8 md:flex-row md:items-end md:justify-between"
|
||||||
|
>
|
||||||
|
<p className="max-w-md text-base leading-relaxed text-t-fg/80 md:text-lg">{hero.text}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<span className="flex items-center gap-2.5 text-sm text-t-fg/75">
|
||||||
|
<span className="size-2 rounded-full bg-emerald-400 shadow-[0_0_12px] shadow-emerald-400/70" />
|
||||||
|
{hero.hours}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="#reservieren"
|
||||||
|
className="group inline-flex items-center gap-3 rounded-full bg-t-accent px-7 py-4 text-sm font-medium text-t-accent-fg transition-transform hover:scale-[1.03]"
|
||||||
|
>
|
||||||
|
{hero.cta}
|
||||||
|
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Menu({ menu }: { menu: RestaurantData["menu"] }) {
|
||||||
|
const categoryOf = (dish: { category: string }) => dish.category.trim() || "Weitere"
|
||||||
|
const categories = [...new Set(menu.dishes.map(categoryOf))]
|
||||||
|
const [selected, setSelected] = useState(0)
|
||||||
|
const current = categories[Math.min(selected, categories.length - 1)]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="karte" className="bg-t-surface py-28 md:py-40">
|
||||||
|
<div className="mx-auto max-w-[1100px] px-6 md:px-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<Reveal><p className={eyebrow}>{menu.eyebrow}</p></Reveal>
|
||||||
|
<RevealWords
|
||||||
|
text={menu.title}
|
||||||
|
emClassName={em}
|
||||||
|
className="font-t-display text-[clamp(2.75rem,6vw,5.5rem)] leading-none tracking-[-0.02em]"
|
||||||
|
/>
|
||||||
|
<Reveal delay={0.15}>
|
||||||
|
<p className="mx-auto mt-6 max-w-md text-t-muted">{menu.text}</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reveal delay={0.2} className="mt-14 flex flex-wrap justify-center gap-2">
|
||||||
|
<div role="tablist" aria-label={menu.eyebrow} className="flex flex-wrap justify-center gap-2">
|
||||||
|
{categories.map((category, i) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={category === current}
|
||||||
|
onClick={() => setSelected(i)}
|
||||||
|
className={cn(
|
||||||
|
"relative cursor-pointer rounded-full px-5 py-2.5 text-sm transition-colors duration-300",
|
||||||
|
category === current ? "text-t-accent-fg" : "text-t-fg/65 hover:text-t-fg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{category === current && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="restaurant-menu-tab"
|
||||||
|
className="absolute inset-0 rounded-full bg-t-accent"
|
||||||
|
transition={{ type: "spring", bounce: 0.18, duration: 0.6 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative">{category}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.ul
|
||||||
|
key={current}
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
transition={{ duration: 0.5, ease }}
|
||||||
|
className="mt-16 grid gap-x-16 gap-y-11 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
{menu.dishes.filter((dish) => categoryOf(dish) === current).map((dish, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<div className="flex items-baseline gap-4">
|
||||||
|
<h3 className="font-t-display text-2xl leading-tight">{dish.name}</h3>
|
||||||
|
<span className="flex-1 -translate-y-1 border-b border-dotted border-t-fg/25" />
|
||||||
|
<span className="font-t-display text-xl text-t-accent">{dish.price}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-t-muted">{dish.description}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</motion.ul>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Line({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
||||||
|
return (
|
||||||
|
<label className={cn("block", className)}>
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.24em] text-t-muted">{label}</span>
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
className="mt-2 w-full border-b border-t-fg/20 bg-transparent py-2.5 text-base outline-none transition-colors placeholder:text-t-muted/60 focus:border-t-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
modules/templates/catalog/saas/definition.ts
Normal file
106
modules/templates/catalog/saas/definition.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
brand: { name: "Flowdesk", tagline: "Kundenservice, der mitdenkt" },
|
||||||
|
hero: {
|
||||||
|
badge: "Neu: KI-Antwortvorschläge",
|
||||||
|
title: "Support, der sich\n*von selbst* erledigt.",
|
||||||
|
text: "Flowdesk bündelt E-Mails, Chats und Tickets in einem Posteingang – und beantwortet Routinefragen automatisch, in eurem Ton.",
|
||||||
|
cta: "Kostenlos testen",
|
||||||
|
ctaSecondary: "Demo ansehen",
|
||||||
|
note: "14 Tage gratis · Keine Kreditkarte nötig",
|
||||||
|
},
|
||||||
|
logos: {
|
||||||
|
title: "Über 2.000 Teams arbeiten bereits mit Flowdesk",
|
||||||
|
items: [{ name: "Nordwerk" }, { name: "Lumio" }, { name: "Kraftbau" }, { name: "Velora" }, { name: "Brightfield" }, { name: "Okuma" }, { name: "Tessin & Co" }],
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
eyebrow: "Funktionen",
|
||||||
|
title: "Alles, was dein Team *schneller* macht.",
|
||||||
|
items: [
|
||||||
|
{ title: "Ein Posteingang für alles", text: "E-Mail, Website-Chat, WhatsApp und Telefonnotizen an einem Ort – mit Zuweisung und Status." },
|
||||||
|
{ title: "KI-Antworten", text: "Vorschläge in Sekunden, gelernt aus euren besten Antworten." },
|
||||||
|
{ title: "Automationen", text: "Regeln, die sortieren, zuweisen und nachfassen." },
|
||||||
|
{ title: "Live-Analysen", text: "Antwortzeiten, Zufriedenheit und Themen auf einen Blick – ohne Excel-Export." },
|
||||||
|
{ title: "Integrationen", text: "Shop, CRM und Kalender in wenigen Klicks verbunden." },
|
||||||
|
{ title: "DSGVO-konform", text: "Server in Frankfurt, AV-Vertrag inklusive." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
eyebrow: "So geht's",
|
||||||
|
title: "In drei Schritten *live.*",
|
||||||
|
items: [
|
||||||
|
{ title: "Verbinden", text: "E-Mail, Website-Chat und WhatsApp in wenigen Minuten anbinden." },
|
||||||
|
{ title: "Anlernen", text: "Flowdesk lernt aus euren Antworten und Hilfeartikeln." },
|
||||||
|
{ title: "Zurücklehnen", text: "Routinefragen laufen automatisch – dein Team kümmert sich um den Rest." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
eyebrow: "Preise",
|
||||||
|
title: "Faire Preise, die *mitwachsen.*",
|
||||||
|
discount: "20",
|
||||||
|
plans: [
|
||||||
|
{ name: "Starter", price: "29", text: "Für kleine Teams", features: "2 Nutzer\n1.000 Tickets pro Monat\nE-Mail & Chat", highlight: "" },
|
||||||
|
{ name: "Team", price: "79", text: "Für wachsende Teams", features: "10 Nutzer\nUnbegrenzte Tickets\nKI-Antworten\nAutomationen", highlight: "ja" },
|
||||||
|
{ name: "Business", price: "199", text: "Für Unternehmen", features: "Unbegrenzte Nutzer\nSSO & Audit-Log\nPersönlicher Ansprechpartner", highlight: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testimonials: {
|
||||||
|
title: "Teams lieben *Flowdesk.*",
|
||||||
|
items: [
|
||||||
|
{ quote: "Unsere Antwortzeit ist von sechs Stunden auf zwölf Minuten gefallen. Das Team hat endlich Luft für die kniffligen Fälle.", author: "Sarah Lange", role: "Head of Support, Velora", image: unsplash("1544005313-94ddf0286df2", 400) },
|
||||||
|
{ quote: "Eingerichtet an einem Nachmittag. Seitdem beantwortet Flowdesk zwei Drittel der Anfragen selbst.", author: "Jonas Becker", role: "Gründer, Kraftbau", image: unsplash("1552058544-f2b08422138a", 400) },
|
||||||
|
{ quote: "Endlich ein Tool, das sich nicht nach Enterprise-Monster anfühlt.", author: "Mira Hoffmann", role: "COO, Lumio", image: unsplash("1517841905240-472988babdf9", 400) },
|
||||||
|
{ quote: "Die Auswertungen nutzen wir jede Woche im Team-Meeting. Wir sehen sofort, wo es hakt.", author: "David Kühn", role: "Teamlead Service, Nordwerk", image: unsplash("1607990281513-2c110a25bd8c", 400) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: "Fragen & *Antworten*",
|
||||||
|
items: [
|
||||||
|
{ question: "Wie lange dauert die Einrichtung?", answer: "Die meisten Teams sind in unter einer Stunde startklar. Beim Umzug von einem anderen Tool helfen wir kostenlos." },
|
||||||
|
{ question: "Antwortet die KI ohne Kontrolle?", answer: "Nur wenn ihr das wollt. Standardmäßig schlägt Flowdesk Antworten vor, und euer Team gibt sie frei." },
|
||||||
|
{ question: "Wo liegen unsere Daten?", answer: "Ausschließlich auf Servern in Frankfurt am Main. Einen AV-Vertrag gibt es für jeden Tarif." },
|
||||||
|
{ question: "Kann ich jederzeit kündigen?", answer: "Ja. Monatliche Tarife sind monatlich kündbar, Jahrestarife zum Ende der Laufzeit." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
title: "Bereit für *ruhigere* Support-Tage?",
|
||||||
|
text: "Starte kostenlos – in fünf Minuten beantwortet Flowdesk die ersten Anfragen.",
|
||||||
|
button: "Jetzt kostenlos starten",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saas = defineTemplate({
|
||||||
|
key: "saas",
|
||||||
|
name: "Launchpad",
|
||||||
|
industry: "Startup, SaaS & Tech",
|
||||||
|
description: "Modern und präzise: animiertes Produkt-Mockup mit 3D-Neigung, Bento-Features, Terminal-Ablauf, Preise mit Monat/Jahr-Umschalter und FAQ.",
|
||||||
|
cover: unsplash("1460925895917-afdab827c52f", 1200),
|
||||||
|
samples: {
|
||||||
|
inquiry: "Hallo, wir sind ein Onlineshop mit sechs Support-Mitarbeitenden und rund 3.000 Tickets im Monat (E-Mail und Chat). Was würde Flowdesk für uns kosten, und wie läuft der Umzug von unserem bisherigen Tool? Beste Grüße, Nina Albers",
|
||||||
|
topic: "Launch: KI-Antwortvorschläge jetzt für alle Teams",
|
||||||
|
},
|
||||||
|
palettes: [
|
||||||
|
{ key: "violett", label: "Violett", dark: true, bg: "#07070b", fg: "#eceef5", muted: "#8b8fa3", surface: "#12121a", accent: "#7c5cff" },
|
||||||
|
{ key: "aurora", label: "Aurora", dark: true, bg: "#050a09", fg: "#e9f3ef", muted: "#86a097", surface: "#0f1816", accent: "#2ee6a6" },
|
||||||
|
{ key: "hell", label: "Hell", bg: "#fbfbfd", fg: "#0b0d14", muted: "#5f6577", surface: "#f0f1f6", accent: "#4f46e5" },
|
||||||
|
],
|
||||||
|
fonts: [
|
||||||
|
{ key: "geist", label: "Geist" },
|
||||||
|
{ key: "sora", label: "Sora & Inter" },
|
||||||
|
],
|
||||||
|
sections: describeSections(defaults, {
|
||||||
|
brand: "Produkt",
|
||||||
|
hero: "Startbereich",
|
||||||
|
logos: { label: "Kundenlogos", optional: true, items: { items: "Logo" } },
|
||||||
|
features: { label: "Funktionen", optional: true, items: { items: "Funktion" } },
|
||||||
|
steps: { label: "So geht's", optional: true, items: { items: "Schritt" } },
|
||||||
|
pricing: { label: "Preise", optional: true, items: { plans: "Tarif" } },
|
||||||
|
testimonials: { label: "Kundenstimmen", optional: true, items: { items: "Stimme" } },
|
||||||
|
faq: { label: "FAQ", optional: true, items: { items: "Frage" } },
|
||||||
|
cta: "Abschluss",
|
||||||
|
}),
|
||||||
|
defaults,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SaasData = typeof saas.defaults
|
||||||
12
modules/templates/catalog/saas/fonts.ts
Normal file
12
modules/templates/catalog/saas/fonts.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Geist, Geist_Mono, Sora } from "next/font/google"
|
||||||
|
import type { FontPair } from "../../lib/types"
|
||||||
|
|
||||||
|
// preload: false – siehe restaurant/fonts.ts. Geist Mono steht in beiden Paaren für Code/Terminal bereit.
|
||||||
|
const geist = Geist({ subsets: ["latin"], variable: "--font-saas-geist", preload: false })
|
||||||
|
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-saas-mono", preload: false })
|
||||||
|
const sora = Sora({ subsets: ["latin"], variable: "--font-saas-sora", preload: false })
|
||||||
|
|
||||||
|
export const fonts: Record<string, FontPair> = {
|
||||||
|
geist: { className: `${geist.variable} ${geistMono.variable}`, display: "var(--font-saas-geist)", body: "var(--font-saas-geist)" },
|
||||||
|
sora: { className: `${sora.variable} ${geistMono.variable}`, display: "var(--font-saas-sora)", body: "var(--font-inter)" },
|
||||||
|
}
|
||||||
559
modules/templates/catalog/saas/template.tsx
Normal file
559
modules/templates/catalog/saas/template.tsx
Normal file
|
|
@ -0,0 +1,559 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion, useInView, useMotionValue, useSpring, useTransform } from "motion/react"
|
||||||
|
import { useEffect, useRef, useState, type PointerEvent } from "react"
|
||||||
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
BotIcon,
|
||||||
|
ChartNoAxesColumnIcon,
|
||||||
|
CheckIcon,
|
||||||
|
InboxIcon,
|
||||||
|
LockIcon,
|
||||||
|
MailIcon,
|
||||||
|
MessageCircleIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
PlugIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
WorkflowIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateProps } from "../../lib/types"
|
||||||
|
import { splitList } from "../shared/hooks"
|
||||||
|
import { TImage } from "../shared/media"
|
||||||
|
import { Accordion, Marquee, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
|
||||||
|
import { TemplateShell } from "../shared/shell"
|
||||||
|
import { saas, type SaasData } from "./definition"
|
||||||
|
import { fonts } from "./fonts"
|
||||||
|
|
||||||
|
const em = "bg-gradient-to-r from-t-accent to-t-fg bg-clip-text text-transparent"
|
||||||
|
const container = "mx-auto w-full max-w-[1200px] px-5 md:px-8"
|
||||||
|
const heading = "font-t-display font-semibold tracking-[-0.035em]"
|
||||||
|
const mono = { fontFamily: "var(--font-saas-mono), ui-monospace, monospace" }
|
||||||
|
const card = "rounded-3xl border border-t-fg/10 bg-t-surface/60"
|
||||||
|
const featureIcons = [InboxIcon, BotIcon, WorkflowIcon, ChartNoAxesColumnIcon, PlugIcon, LockIcon]
|
||||||
|
|
||||||
|
function useTypewriter(text: string, active: boolean, speed = 28) {
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
const timer = setInterval(() => setCount((c) => (c >= text.length ? c : c + 2)), speed)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [active, text.length, speed])
|
||||||
|
return text.slice(0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Eyebrow({ children }: { children: string }) {
|
||||||
|
return <p className="mb-4 text-sm font-medium text-t-accent">{children}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaasTemplate({ content }: TemplateProps<SaasData>) {
|
||||||
|
const { brand, hero, logos, features, steps, pricing, testimonials, faq, cta } = content.data
|
||||||
|
const show = (key: string) => !content.hidden.includes(key)
|
||||||
|
const links = [
|
||||||
|
{ href: "#funktionen", label: "Funktionen", visible: show("features") },
|
||||||
|
{ href: "#preise", label: "Preise", visible: show("pricing") },
|
||||||
|
{ href: "#faq", label: "FAQ", visible: show("faq") },
|
||||||
|
].filter((link) => link.visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateShell def={saas} content={content} fonts={fonts[content.fonts] ?? fonts.geist}>
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-[900px] bg-[radial-gradient(50rem_30rem_at_50%_-5%,color-mix(in_oklab,var(--t-accent)_35%,transparent),transparent_70%)]" />
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-[900px] [mask-image:linear-gradient(to_bottom,black,transparent)] bg-[size:56px_56px] bg-[linear-gradient(to_right,color-mix(in_oklab,var(--t-fg)_6%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--t-fg)_6%,transparent)_1px,transparent_1px)]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Nav name={brand.name} cta={hero.cta} links={links} />
|
||||||
|
|
||||||
|
<section id="top" className={cn(container, "relative pt-36 pb-10 text-center md:pt-44")}>
|
||||||
|
<motion.a
|
||||||
|
href="#funktionen"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease }}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-t-fg/10 bg-t-surface/70 px-4 py-1.5 text-sm backdrop-blur"
|
||||||
|
>
|
||||||
|
<SparklesIcon className="size-4 text-t-accent" /> {hero.badge} <ArrowRightIcon className="size-3.5 text-t-muted" />
|
||||||
|
</motion.a>
|
||||||
|
<RevealWords
|
||||||
|
as="h1"
|
||||||
|
onMount
|
||||||
|
delay={0.15}
|
||||||
|
text={hero.title}
|
||||||
|
emClassName={em}
|
||||||
|
className={cn(heading, "mx-auto mt-8 max-w-4xl text-[clamp(2.75rem,7vw,5.75rem)] leading-[1.02]")}
|
||||||
|
/>
|
||||||
|
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.6 }}>
|
||||||
|
<p className="mx-auto mt-7 max-w-2xl text-lg leading-relaxed text-t-muted md:text-xl">{hero.text}</p>
|
||||||
|
<div className="mt-9 flex flex-wrap justify-center gap-3">
|
||||||
|
<a href="#preise" className="rounded-full bg-t-accent px-7 py-3.5 font-medium text-t-accent-fg shadow-[0_10px_40px_-10px_var(--t-accent)] transition-transform hover:-translate-y-0.5">
|
||||||
|
{hero.cta}
|
||||||
|
</a>
|
||||||
|
<a href="#funktionen" className="rounded-full border border-t-fg/15 px-7 py-3.5 font-medium transition-colors hover:bg-t-surface">
|
||||||
|
{hero.ctaSecondary}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="mt-5 text-sm text-t-muted">{hero.note}</p>
|
||||||
|
</motion.div>
|
||||||
|
<ProductMockup name={brand.name} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show("logos") && (
|
||||||
|
<section className="py-16">
|
||||||
|
<p className="mb-8 text-center text-sm text-t-muted">{logos.title}</p>
|
||||||
|
<div className="[mask-image:linear-gradient(to_right,transparent,black_15%,black_85%,transparent)]">
|
||||||
|
<Marquee duration={30}>
|
||||||
|
{logos.items.map((logo, i) => (
|
||||||
|
<span key={i} className={cn("px-10 text-2xl text-t-fg/50", ["font-semibold tracking-tight", "font-light italic", "font-bold uppercase tracking-widest text-lg", "font-medium"][i % 4])}>
|
||||||
|
{logo.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("features") && (
|
||||||
|
<section id="funktionen" className={cn(container, "py-24 md:py-32")}>
|
||||||
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
|
<Reveal><Eyebrow>{features.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={features.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-14 grid gap-4 md:grid-cols-3">
|
||||||
|
{features.items.map((item, i) => {
|
||||||
|
const Icon = featureIcons[i % featureIcons.length]
|
||||||
|
const wide = i % 4 === 0 || i % 4 === 3
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={(i % 3) * 0.08} className={cn(wide && "md:col-span-2")}>
|
||||||
|
<article className={cn(card, "group flex h-full min-h-72 flex-col overflow-hidden p-7 transition-colors hover:border-t-accent/40")}>
|
||||||
|
<div className="relative mb-6 flex-1">
|
||||||
|
<FeatureVisual index={i} />
|
||||||
|
</div>
|
||||||
|
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Icon className="size-5 text-t-accent" /> {item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-t-muted">{item.text}</p>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("steps") && <Steps steps={steps} name={brand.name} />}
|
||||||
|
{show("pricing") && <Pricing pricing={pricing} />}
|
||||||
|
|
||||||
|
{show("testimonials") && (
|
||||||
|
<section className={cn(container, "py-24 md:py-32")}>
|
||||||
|
<RevealWords text={testimonials.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<div className="mt-14 gap-4 [column-fill:_balance] sm:columns-2">
|
||||||
|
{testimonials.items.map((item, i) => (
|
||||||
|
<Reveal key={i} delay={(i % 2) * 0.1} className="mb-4 break-inside-avoid">
|
||||||
|
<figure className={cn(card, "p-7")}>
|
||||||
|
<blockquote className="text-lg leading-relaxed">„{item.quote}“</blockquote>
|
||||||
|
<figcaption className="mt-6 flex items-center gap-3">
|
||||||
|
<span className="relative size-11 overflow-hidden rounded-full bg-t-surface">
|
||||||
|
<TImage src={item.image} alt={item.author} sizes="44px" />
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">
|
||||||
|
<span className="block font-semibold">{item.author}</span>
|
||||||
|
<span className="text-t-muted">{item.role}</span>
|
||||||
|
</span>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show("faq") && (
|
||||||
|
<section id="faq" className={cn(container, "max-w-3xl py-24 md:py-32")}>
|
||||||
|
<RevealWords text={faq.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<Accordion
|
||||||
|
items={faq.items}
|
||||||
|
className="mt-12 divide-y divide-t-fg/10 border-y border-t-fg/10"
|
||||||
|
questionClassName="py-6 text-lg font-medium"
|
||||||
|
answerClassName="pb-6 leading-relaxed text-t-muted"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className={cn(container, "pb-24")}>
|
||||||
|
<Reveal>
|
||||||
|
<div className="relative overflow-hidden rounded-[2.5rem] border border-t-fg/10 bg-t-surface px-6 py-20 text-center md:py-28">
|
||||||
|
<div aria-hidden className="absolute inset-0 bg-[radial-gradient(40rem_20rem_at_50%_120%,color-mix(in_oklab,var(--t-accent)_45%,transparent),transparent_70%)]" />
|
||||||
|
<RevealWords text={cta.title} emClassName={em} className={cn(heading, "relative mx-auto max-w-3xl text-[clamp(2.25rem,5vw,4.25rem)] leading-[1.05]")} />
|
||||||
|
<p className="relative mx-auto mt-5 max-w-xl text-lg text-t-muted">{cta.text}</p>
|
||||||
|
<a href="#preise" className="relative mt-9 inline-flex items-center gap-2 rounded-full bg-t-accent px-8 py-4 font-medium text-t-accent-fg">
|
||||||
|
{cta.button} <ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className={cn(container, "flex flex-col gap-6 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||||
|
<p><span className="font-semibold text-t-fg">{brand.name}</span> · {brand.tagline}</p>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||||
|
<a href="#" className="hover:text-t-fg">AGB</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</TemplateShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Logo({ name }: { name: string }) {
|
||||||
|
return (
|
||||||
|
<a href="#top" className="flex items-center gap-2.5 font-semibold tracking-tight">
|
||||||
|
<span className="size-7 rounded-lg bg-gradient-to-br from-t-accent to-t-fg/80 shadow-[0_0_20px_-4px_var(--t-accent)]" />
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: string }[] }) {
|
||||||
|
const { scrolled, hidden } = useScrollNav()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header animate={{ y: hidden ? "-130%" : "0%" }} transition={{ duration: 0.45, ease }} className="fixed inset-x-0 top-0 z-50 px-3 pt-3">
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
"mx-auto flex h-14 max-w-[1100px] items-center justify-between gap-6 rounded-full border px-4 transition-all duration-500",
|
||||||
|
scrolled ? "border-t-fg/10 bg-t-bg/75 backdrop-blur-xl" : "border-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Logo name={name} />
|
||||||
|
<div className="hidden items-center gap-8 text-sm text-t-muted md:flex">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a key={link.href} href={link.href} className="transition-colors hover:text-t-fg">{link.label}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a href="#" className="hidden rounded-full px-4 py-2 text-sm text-t-muted hover:text-t-fg sm:block">Login</a>
|
||||||
|
<a href="#preise" className="rounded-full bg-t-fg px-4 py-2 text-sm font-medium text-t-bg">{cta}</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickets = [
|
||||||
|
{ name: "Anna Weber", text: "Wo bleibt meine Bestellung #4821?", time: "2 Min", icon: MailIcon },
|
||||||
|
{ name: "Ben Schulz", text: "Kann ich die Größe noch ändern?", time: "8 Min", icon: MessageCircleIcon },
|
||||||
|
{ name: "Clara Yilmaz", text: "Rückruf wegen Rechnung", time: "15 Min", icon: PhoneIcon },
|
||||||
|
{ name: "David Ott", text: "Danke, alles angekommen!", time: "1 Std", icon: MailIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
function ProductMockup({ name }: { name: string }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const inView = useInView(ref, { once: true, margin: "0px 0px -20% 0px" })
|
||||||
|
const x = useMotionValue(0)
|
||||||
|
const y = useMotionValue(0)
|
||||||
|
const rotateX = useSpring(useTransform(y, [-0.5, 0.5], [7, -7]), { stiffness: 120, damping: 20 })
|
||||||
|
const rotateY = useSpring(useTransform(x, [-0.5, 0.5], [-9, 9]), { stiffness: 120, damping: 20 })
|
||||||
|
const reply = useTypewriter(
|
||||||
|
"Hallo Anna, deine Bestellung #4821 ist heute Morgen rausgegangen und kommt morgen bis 12 Uhr an. Hier ist dein Tracking-Link – melde dich gern, falls noch etwas offen ist!",
|
||||||
|
inView
|
||||||
|
)
|
||||||
|
|
||||||
|
function onMove(event: PointerEvent<HTMLDivElement>) {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
x.set((event.clientX - rect.left) / rect.width - 0.5)
|
||||||
|
y.set((event.clientY - rect.top) / rect.height - 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onPointerMove={onMove}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
x.set(0)
|
||||||
|
y.set(0)
|
||||||
|
}}
|
||||||
|
style={{ perspective: 1600 }}
|
||||||
|
className="relative mx-auto mt-16 max-w-5xl text-left md:mt-20"
|
||||||
|
>
|
||||||
|
<div aria-hidden className="absolute inset-x-10 top-10 bottom-0 rounded-[3rem] bg-t-accent/35 blur-3xl" />
|
||||||
|
<motion.div
|
||||||
|
style={{ rotateX, rotateY }}
|
||||||
|
initial={{ opacity: 0, y: 80 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 1.2, ease, delay: 0.5 }}
|
||||||
|
className="relative overflow-hidden rounded-2xl border border-t-fg/10 bg-t-bg shadow-[0_50px_100px_-30px_rgba(0,0,0,0.5)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-t-fg/10 px-4 py-3">
|
||||||
|
<span className="size-3 rounded-full bg-red-400/80" />
|
||||||
|
<span className="size-3 rounded-full bg-amber-400/80" />
|
||||||
|
<span className="size-3 rounded-full bg-emerald-400/80" />
|
||||||
|
<span className="mx-auto rounded-md bg-t-surface px-3 py-1 text-xs text-t-muted" style={mono}>app.{name.toLowerCase().replace(/[^a-z0-9]/g, "")}.de/inbox</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid min-h-[22rem] md:grid-cols-[170px_1fr_1.25fr]">
|
||||||
|
<aside className="hidden space-y-1 border-r border-t-fg/10 p-3 text-sm md:block">
|
||||||
|
{[["Posteingang", "24"], ["Mir zugewiesen", "6"], ["Automationen", ""], ["Berichte", ""]].map(([label, count], i) => (
|
||||||
|
<p key={label} className={cn("flex justify-between rounded-lg px-3 py-2", i === 0 ? "bg-t-surface font-medium" : "text-t-muted")}>
|
||||||
|
{label}
|
||||||
|
{count && <span className="rounded-full bg-t-accent px-1.5 text-xs text-t-accent-fg">{count}</span>}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
<ul className="border-r border-t-fg/10">
|
||||||
|
{tickets.map((ticket, i) => (
|
||||||
|
<motion.li
|
||||||
|
key={ticket.name}
|
||||||
|
initial={{ opacity: 0, x: -12 }}
|
||||||
|
animate={inView ? { opacity: 1, x: 0 } : {}}
|
||||||
|
transition={{ delay: 0.8 + i * 0.12 }}
|
||||||
|
className={cn("flex gap-3 border-b border-t-fg/5 px-4 py-3.5", i === 0 && "bg-t-accent/10")}
|
||||||
|
>
|
||||||
|
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-t-surface text-sm font-semibold">{ticket.name.charAt(0)}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center justify-between text-sm font-medium">
|
||||||
|
{ticket.name}
|
||||||
|
<span className="text-xs font-normal text-t-muted">{ticket.time}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 truncate text-xs text-t-muted">
|
||||||
|
<ticket.icon className="size-3 shrink-0" /> {ticket.text}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="hidden flex-col gap-3 p-5 md:flex">
|
||||||
|
<p className="w-fit max-w-[85%] rounded-2xl rounded-bl-md bg-t-surface px-4 py-3 text-sm">Hallo, wo bleibt meine Bestellung #4821? Ich brauche sie bis Freitag.</p>
|
||||||
|
<div className="rounded-2xl border border-t-accent/40 bg-t-accent/5 p-4">
|
||||||
|
<p className="flex items-center gap-1.5 text-xs font-medium text-t-accent">
|
||||||
|
<SparklesIcon className="size-3.5" /> KI-Vorschlag
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 min-h-[5.5rem] text-sm leading-relaxed">
|
||||||
|
{reply}
|
||||||
|
<span className="ml-0.5 inline-block h-4 w-1 animate-pulse bg-t-accent align-middle" />
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<span className="rounded-lg bg-t-accent px-3 py-1.5 text-xs font-medium text-t-accent-fg">Senden</span>
|
||||||
|
<span className="rounded-lg border border-t-fg/15 px-3 py-1.5 text-xs">Bearbeiten</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<p className="rounded-xl bg-t-surface p-3"><span className="block text-lg font-semibold">2 Min</span><span className="text-t-muted">Ø Antwortzeit</span></p>
|
||||||
|
<p className="rounded-xl bg-t-surface p-3"><span className="block text-lg font-semibold">63 %</span><span className="text-t-muted">automatisch gelöst</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureVisual({ index }: { index: number }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const inView = useInView(ref, { once: true })
|
||||||
|
const typed = useTypewriter("Gern! Ich habe dir einen neuen Termin für Donnerstag reserviert …", inView && index % 6 === 1, 45)
|
||||||
|
const variant = index % 6
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="flex h-full min-h-32 items-center justify-center">
|
||||||
|
{variant === 0 && (
|
||||||
|
<div className="w-full max-w-sm space-y-2">
|
||||||
|
{[MailIcon, MessageCircleIcon, PhoneIcon].map((Icon, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ delay: i * 0.15 }}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-t-fg/10 bg-t-bg px-3 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<Icon className="size-4 text-t-accent" />
|
||||||
|
<span className="h-2 flex-1 rounded-full bg-t-fg/10" />
|
||||||
|
<span className="h-2 w-10 rounded-full bg-t-accent/40" />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{variant === 1 && <p className="w-full rounded-2xl rounded-bl-md bg-t-bg p-4 text-sm leading-relaxed">{typed}<span className="ml-0.5 inline-block h-3.5 w-1 animate-pulse bg-t-accent align-middle" /></p>}
|
||||||
|
{variant === 2 && (
|
||||||
|
<div className="w-full space-y-2.5">
|
||||||
|
{["Rechnungen → Buchhaltung", "VIP-Kunden priorisieren", "Nach 24 h nachfassen"].map((rule, i) => (
|
||||||
|
<div key={rule} className="flex items-center justify-between rounded-xl bg-t-bg px-3 py-2.5 text-sm">
|
||||||
|
{rule}
|
||||||
|
<motion.span className="relative h-5 w-9 rounded-full bg-t-fg/15" animate={inView ? { backgroundColor: "var(--t-accent)" } : {}} transition={{ delay: 0.4 + i * 0.3 }}>
|
||||||
|
<motion.span className="absolute top-0.5 left-0.5 size-4 rounded-full bg-white" animate={inView ? { x: 16 } : {}} transition={{ delay: 0.4 + i * 0.3 }} />
|
||||||
|
</motion.span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{variant === 3 && (
|
||||||
|
<div className="flex h-32 w-full items-end gap-2">
|
||||||
|
{[38, 52, 45, 70, 62, 84, 78, 96].map((height, i) => (
|
||||||
|
<motion.span
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-t-md bg-gradient-to-t from-t-accent/30 to-t-accent"
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={inView ? { height: `${height}%` } : {}}
|
||||||
|
transition={{ duration: 0.8, ease, delay: i * 0.06 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{variant === 4 && (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{[MailIcon, MessageCircleIcon, PhoneIcon, PlugIcon, InboxIcon, WorkflowIcon, BotIcon, ChartNoAxesColumnIcon].map((Icon, i) => (
|
||||||
|
<motion.span
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, scale: 0.6 }}
|
||||||
|
animate={inView ? { opacity: 1, scale: 1 } : {}}
|
||||||
|
transition={{ delay: i * 0.06, type: "spring", bounce: 0.4 }}
|
||||||
|
className="grid size-12 place-items-center rounded-2xl border border-t-fg/10 bg-t-bg"
|
||||||
|
>
|
||||||
|
<Icon className="size-5 text-t-accent" />
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{variant === 5 && (
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="mx-auto grid size-16 place-items-center rounded-2xl bg-t-accent text-t-accent-fg shadow-[0_0_40px_-6px_var(--t-accent)]">
|
||||||
|
<LockIcon className="size-7" />
|
||||||
|
</span>
|
||||||
|
<p className="mt-4 text-xs font-medium tracking-widest text-t-muted uppercase">Hosted in Germany</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Steps({ steps, name }: { steps: SaasData["steps"]; name: string }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const inView = useInView(ref, { once: true, margin: "0px 0px -20% 0px" })
|
||||||
|
const cli = name.toLowerCase().replace(/[^a-z0-9]/g, "") || "app"
|
||||||
|
const lines = [
|
||||||
|
`$ ${cli} connect --mail support@shop.de`,
|
||||||
|
"✓ Postfach verbunden · 2.341 Nachrichten",
|
||||||
|
`$ ${cli} learn --help-center`,
|
||||||
|
"✓ 128 Artikel gelernt",
|
||||||
|
`$ ${cli} autopilot on`,
|
||||||
|
"✓ 63 % der Anfragen werden automatisch beantwortet",
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cn(container, "grid items-center gap-12 py-24 md:py-32 lg:grid-cols-2")}>
|
||||||
|
<div>
|
||||||
|
<Reveal><Eyebrow>{steps.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<ol className="mt-10 space-y-6">
|
||||||
|
{steps.items.map((step, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
|
<li className="flex gap-5">
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center rounded-full border border-t-accent/40 text-sm font-semibold text-t-accent" style={mono}>{i + 1}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold">{step.title}</p>
|
||||||
|
<p className="mt-1 text-t-muted">{step.text}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div ref={ref} className="overflow-hidden rounded-2xl border border-t-fg/10 bg-[#0b0b10] text-sm text-zinc-200 shadow-2xl" style={mono}>
|
||||||
|
<div className="flex gap-2 border-b border-white/10 px-4 py-3">
|
||||||
|
<span className="size-3 rounded-full bg-white/15" />
|
||||||
|
<span className="size-3 rounded-full bg-white/15" />
|
||||||
|
<span className="size-3 rounded-full bg-white/15" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 p-6">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<motion.p
|
||||||
|
key={line}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={inView ? { opacity: 1 } : {}}
|
||||||
|
transition={{ delay: 0.3 + i * 0.45 }}
|
||||||
|
className={line.startsWith("✓") ? "text-emerald-400" : ""}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</motion.p>
|
||||||
|
))}
|
||||||
|
<span className="inline-block h-4 w-2 animate-pulse bg-zinc-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pricing({ pricing }: { pricing: SaasData["pricing"] }) {
|
||||||
|
const [yearly, setYearly] = useState(true)
|
||||||
|
const discount = Math.min(90, Math.max(0, Number(pricing.discount) || 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="preise" className={cn(container, "py-24 md:py-32")}>
|
||||||
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
|
<Reveal><Eyebrow>{pricing.eyebrow}</Eyebrow></Reveal>
|
||||||
|
<RevealWords text={pricing.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||||
|
<div className="mt-8 inline-flex rounded-full border border-t-fg/10 bg-t-surface p-1 text-sm">
|
||||||
|
{[false, true].map((option) => (
|
||||||
|
<button
|
||||||
|
key={String(option)}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={yearly === option}
|
||||||
|
onClick={() => setYearly(option)}
|
||||||
|
className={cn("relative cursor-pointer rounded-full px-5 py-2", yearly === option ? "text-t-bg" : "text-t-muted")}
|
||||||
|
>
|
||||||
|
{yearly === option && <motion.span layoutId="saas-billing" className="absolute inset-0 rounded-full bg-t-fg" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
|
||||||
|
<span className="relative">{option ? `Jährlich · −${discount} %` : "Monatlich"}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-14 grid gap-4 lg:grid-cols-3">
|
||||||
|
{pricing.plans.map((plan, i) => {
|
||||||
|
const featured = Boolean(plan.highlight.trim())
|
||||||
|
const base = Number(plan.price.replace(",", ".")) || 0
|
||||||
|
const price = Math.round(yearly ? base * (1 - discount / 100) : base)
|
||||||
|
return (
|
||||||
|
<Reveal key={i} delay={i * 0.08}>
|
||||||
|
<article
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-full flex-col rounded-3xl border p-8",
|
||||||
|
featured ? "border-t-accent bg-t-surface shadow-[0_0_60px_-20px_var(--t-accent)]" : "border-t-fg/10 bg-t-surface/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{featured && <span className="absolute -top-3 left-8 rounded-full bg-t-accent px-3 py-1 text-xs font-medium text-t-accent-fg">Am beliebtesten</span>}
|
||||||
|
<h3 className="text-lg font-semibold">{plan.name}</h3>
|
||||||
|
<p className="text-sm text-t-muted">{plan.text}</p>
|
||||||
|
<p className="mt-6 flex items-end gap-1">
|
||||||
|
<span className="relative h-14 overflow-hidden">
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
<motion.span
|
||||||
|
key={price}
|
||||||
|
initial={{ y: "100%", opacity: 0 }}
|
||||||
|
animate={{ y: "0%", opacity: 1 }}
|
||||||
|
exit={{ y: "-100%", opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease }}
|
||||||
|
className={cn(heading, "block text-5xl")}
|
||||||
|
>
|
||||||
|
{price} €
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</span>
|
||||||
|
<span className="pb-2 text-sm text-t-muted">/ Monat</span>
|
||||||
|
</p>
|
||||||
|
<ul className="mt-8 flex-1 space-y-3 text-sm">
|
||||||
|
{splitList(plan.features, "\n").map((feature) => (
|
||||||
|
<li key={feature} className="flex items-center gap-2.5">
|
||||||
|
<CheckIcon className="size-4 text-t-accent" /> {feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<a href="#" className={cn("mt-8 rounded-full py-3 text-center text-sm font-medium transition-opacity hover:opacity-85", featured ? "bg-t-accent text-t-accent-fg" : "bg-t-fg text-t-bg")}>
|
||||||
|
{plan.name} wählen
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
modules/templates/catalog/shared/demo-badge.tsx
Normal file
56
modules/templates/catalog/shared/demo-badge.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ArrowUpRightIcon, SparklesIcon } from "lucide-react"
|
||||||
|
import { useAiAccess } from "../../ai/access-context"
|
||||||
|
import { AI_DEMO_SLUGS, AI_DEMOS, demoHref } from "../../ai/demos"
|
||||||
|
import type { TemplateContent } from "../../lib/types"
|
||||||
|
|
||||||
|
const pill = "flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 font-sans text-[11px] font-medium tracking-wide text-white/90 backdrop-blur-md"
|
||||||
|
const position = "fixed bottom-[calc(1rem+var(--t-safe-bottom,0px))] left-4 z-[60]"
|
||||||
|
|
||||||
|
// Entwurfs-Hinweis unten links; mit freigeschalteten KI-Demos zugleich deren Startmenü
|
||||||
|
export function DemoBadge({ content }: { content: TemplateContent }) {
|
||||||
|
const access = useAiAccess()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const demos = access ? AI_DEMO_SLUGS.filter((slug) => content.ai[AI_DEMOS[slug].feature]) : []
|
||||||
|
const label = (
|
||||||
|
<>
|
||||||
|
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
|
||||||
|
Entwurf · Since3
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!access || demos.length === 0) return <div className={`pointer-events-none ${position} ${pill}`}>{label}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${position} flex flex-col items-start gap-2 font-sans`}>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.nav
|
||||||
|
aria-label="KI-Demos"
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 8 }}
|
||||||
|
className="w-64 rounded-2xl bg-black/80 p-1.5 text-white shadow-2xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<p className="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wider text-white/50">KI-Demos für Ihren Betrieb</p>
|
||||||
|
{demos.map((slug) => (
|
||||||
|
<a key={slug} href={demoHref(access, slug)} className="flex items-center justify-between rounded-xl px-3 py-2.5 text-sm transition-colors hover:bg-white/10">
|
||||||
|
{AI_DEMOS[slug].label}
|
||||||
|
<ArrowUpRightIcon className="size-4 opacity-60" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</motion.nav>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)} className={`${pill} cursor-pointer`}>
|
||||||
|
{label}
|
||||||
|
<span className="ml-1 flex items-center gap-1 rounded-full bg-white/15 px-2 py-0.5">
|
||||||
|
<SparklesIcon className="size-3" /> KI-Demos
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
modules/templates/catalog/shared/demo-form.tsx
Normal file
43
modules/templates/catalog/shared/demo-form.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import { useState, type ReactNode } from "react"
|
||||||
|
import { ease } from "./motion"
|
||||||
|
|
||||||
|
// Formulare der Vorlagen senden nichts – nach dem Absenden erscheint eine Bestätigung mit Demo-Hinweis
|
||||||
|
export function DemoForm({ children, success, className }: { children: ReactNode; success: ReactNode; className?: string }) {
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{sent ? (
|
||||||
|
<motion.div
|
||||||
|
key="sent"
|
||||||
|
className={className}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease }}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
<p className="mt-4 text-sm opacity-60">
|
||||||
|
Demo-Hinweis: Auf Ihrer fertigen Webseite landet diese Anfrage direkt in Ihrem Postfach.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.form
|
||||||
|
key="form"
|
||||||
|
className={className}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.4, ease }}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setSent(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
modules/templates/catalog/shared/fields.tsx
Normal file
58
modules/templates/catalog/shared/fields.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Formularfelder der Vorlagen in vier Grundstilen; Farben kommen aus der Umgebung (currentColor, t-accent)
|
||||||
|
export type FieldVariant = "line" | "box" | "soft" | "glass"
|
||||||
|
|
||||||
|
const labelStyle: Record<FieldVariant, string> = {
|
||||||
|
line: "text-[11px] uppercase tracking-[0.22em] opacity-60",
|
||||||
|
box: "text-xs font-medium opacity-70",
|
||||||
|
soft: "px-1 text-xs font-medium opacity-70",
|
||||||
|
glass: "text-xs font-medium opacity-70",
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlStyle: Record<FieldVariant, string> = {
|
||||||
|
line: "mt-2 w-full border-b border-current/20 bg-transparent py-2.5 outline-none transition-colors focus:border-t-accent",
|
||||||
|
box: "mt-1.5 w-full rounded-xl border border-current/15 bg-transparent px-4 py-3 outline-none transition-colors focus:border-t-accent",
|
||||||
|
soft: "mt-1.5 w-full rounded-2xl bg-t-surface px-4 py-3.5 outline-none ring-2 ring-transparent transition focus:ring-t-accent/50",
|
||||||
|
glass: "mt-1.5 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none transition-colors focus:border-t-accent",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shared = { label: string; variant?: FieldVariant; className?: string }
|
||||||
|
|
||||||
|
function Wrap({ label, variant, className, children }: Shared & { variant: FieldVariant; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className={cn("block", className)}>
|
||||||
|
<span className={labelStyle[variant]}>{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TField({ label, variant = "box", className, ...props }: Shared & InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<Wrap label={label} variant={variant} className={className}>
|
||||||
|
<input {...props} className={controlStyle[variant]} />
|
||||||
|
</Wrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TArea({ label, variant = "box", className, rows = 4, ...props }: Shared & TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||||
|
return (
|
||||||
|
<Wrap label={label} variant={variant} className={className}>
|
||||||
|
<textarea {...props} rows={rows} className={cn(controlStyle[variant], "resize-none")} />
|
||||||
|
</Wrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TSelect({ label, variant = "box", className, options, ...props }: Shared & SelectHTMLAttributes<HTMLSelectElement> & { options: string[] }) {
|
||||||
|
return (
|
||||||
|
<Wrap label={label} variant={variant} className={className}>
|
||||||
|
<select {...props} className={cn(controlStyle[variant], "[&>option]:text-zinc-900")}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option}>{option}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Wrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
modules/templates/catalog/shared/hooks.ts
Normal file
38
modules/templates/catalog/shared/hooks.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react"
|
||||||
|
|
||||||
|
function subscribe(onChange: () => void) {
|
||||||
|
const timer = setInterval(onChange, 1000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktuelle Zeit im Sekundentakt; auf dem Server null, damit nichts beim Hydrieren abweicht
|
||||||
|
export function useNow() {
|
||||||
|
return useSyncExternalStore(subscribe, () => Math.floor(Date.now() / 1000) * 1000, () => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "2026-11-30" gilt bis Tagesende (lokale Zeit), "2026-11-19T09:00" ab genau dieser Uhrzeit
|
||||||
|
export function useCountdown(target: string) {
|
||||||
|
const now = useNow()
|
||||||
|
const end = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(target) ? `${target}T23:59:59` : target)
|
||||||
|
if (now === null || Number.isNaN(end)) return null
|
||||||
|
|
||||||
|
const diff = Math.max(0, end - now)
|
||||||
|
return {
|
||||||
|
days: Math.floor(diff / 86_400_000),
|
||||||
|
hours: Math.floor(diff / 3_600_000) % 24,
|
||||||
|
minutes: Math.floor(diff / 60_000) % 60,
|
||||||
|
seconds: Math.floor(diff / 1000) % 60,
|
||||||
|
done: diff === 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "08:30" → Minuten seit Mitternacht, sonst null
|
||||||
|
export function minutesOf(time: string) {
|
||||||
|
const match = time.trim().match(/^(\d{1,2}):(\d{2})$/)
|
||||||
|
return match ? Number(match[1]) * 60 + Number(match[2]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const splitList = (value: string, separator: string | RegExp = ",") =>
|
||||||
|
value.split(separator).map((part) => part.trim()).filter(Boolean)
|
||||||
43
modules/templates/catalog/shared/media.tsx
Normal file
43
modules/templates/catalog/shared/media.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import Image from "next/image"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function isImageUrl(src: string) {
|
||||||
|
try {
|
||||||
|
const { protocol } = new URL(src)
|
||||||
|
return protocol === "https:" || protocol === "http:"
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bild als Füllung des (relativ positionierten) Elternelements. Unsplash läuft über die
|
||||||
|
// Bildoptimierung, beliebige andere Links unoptimiert. Leere oder halb getippte Links zeigen eine Fläche.
|
||||||
|
export function TImage({ src, alt = "", className, sizes = "100vw", eager }: {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
className?: string
|
||||||
|
sizes?: string
|
||||||
|
eager?: boolean
|
||||||
|
}) {
|
||||||
|
if (!isImageUrl(src)) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cn("absolute inset-0 bg-[linear-gradient(135deg,var(--t-surface),color-mix(in_oklab,var(--t-muted)_35%,var(--t-surface)))]", className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
fill
|
||||||
|
sizes={sizes}
|
||||||
|
unoptimized={!src.startsWith("https://images.unsplash.com/")}
|
||||||
|
loading={eager ? "eager" : undefined}
|
||||||
|
fetchPriority={eager ? "high" : undefined}
|
||||||
|
className={cn("object-cover", className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
334
modules/templates/catalog/shared/motion.tsx
Normal file
334
modules/templates/catalog/shared/motion.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
animate,
|
||||||
|
AnimatePresence,
|
||||||
|
motion,
|
||||||
|
useInView,
|
||||||
|
useMotionValue,
|
||||||
|
useMotionValueEvent,
|
||||||
|
useReducedMotion,
|
||||||
|
useScroll,
|
||||||
|
useTransform,
|
||||||
|
type MotionValue,
|
||||||
|
} from "motion/react"
|
||||||
|
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"
|
||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { TImage } from "./media"
|
||||||
|
|
||||||
|
export const ease = [0.22, 1, 0.36, 1] as const
|
||||||
|
const inView = { once: true, margin: "0px 0px -10% 0px" } as const
|
||||||
|
|
||||||
|
type Token = { word: string; em: boolean }
|
||||||
|
|
||||||
|
// Zeilen per "\n", *Wörter in Sternchen* werden hervorgehoben (kursiv/Akzent, je nach Vorlage)
|
||||||
|
export function parseRich(text: string): Token[][] {
|
||||||
|
let em = false
|
||||||
|
return text.split("\n").map((line) =>
|
||||||
|
line.split(" ").filter(Boolean).map((raw) => {
|
||||||
|
let word = raw
|
||||||
|
const opens = word.startsWith("*")
|
||||||
|
if (opens) word = word.slice(1)
|
||||||
|
const closes = word.endsWith("*")
|
||||||
|
if (closes) word = word.slice(0, -1)
|
||||||
|
if (opens) em = true
|
||||||
|
const token = { word, em }
|
||||||
|
if (closes) em = false
|
||||||
|
return token
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation: bekommt nach dem ersten Scrollen einen Hintergrund, versteckt sich beim Runterscrollen
|
||||||
|
export function useScrollNav() {
|
||||||
|
const { scrollY } = useScroll()
|
||||||
|
const [state, setState] = useState({ scrolled: false, hidden: false })
|
||||||
|
|
||||||
|
useMotionValueEvent(scrollY, "change", (y) => {
|
||||||
|
const next = { scrolled: y > 40, hidden: y > (scrollY.getPrevious() ?? 0) && y > 240 }
|
||||||
|
setState((current) => (current.scrolled === next.scrolled && current.hidden === next.hidden ? current : next))
|
||||||
|
})
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schriftgröße für einen Schriftzug über die volle Breite, abhängig von der Länge des Namens
|
||||||
|
export function wordmarkSize(name: string, max = 26) {
|
||||||
|
return `${Math.min(max, 140 / Math.max(name.length, 1)).toFixed(2)}vw`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Rich({ text, emClassName }: { text: string; emClassName?: string }) {
|
||||||
|
return parseRich(text).map((tokens, l) => (
|
||||||
|
<Fragment key={l}>
|
||||||
|
{l > 0 && <br />}
|
||||||
|
{tokens.map((token, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
{i > 0 && " "}
|
||||||
|
{token.em ? <em className={emClassName}>{token.word}</em> : token.word}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Reveal({ children, className, delay = 0, y = 32 }: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
delay?: number
|
||||||
|
y?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
initial={{ opacity: 0, y }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={inView}
|
||||||
|
transition={{ duration: 0.9, ease, delay }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const textTags = { h1: motion.h1, h2: motion.h2, h3: motion.h3, p: motion.p }
|
||||||
|
|
||||||
|
// Wörter gleiten zeilenweise aus einer Maske nach oben
|
||||||
|
export function RevealWords({ text, as = "h2", className, emClassName, delay = 0, onMount = false }: {
|
||||||
|
text: string
|
||||||
|
as?: keyof typeof textTags
|
||||||
|
className?: string
|
||||||
|
emClassName?: string
|
||||||
|
delay?: number
|
||||||
|
onMount?: boolean
|
||||||
|
}) {
|
||||||
|
const Tag = textTags[as]
|
||||||
|
const lines = parseRich(text)
|
||||||
|
const trigger = onMount ? { animate: "show" } : { whileInView: "show", viewport: inView }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag className={className} initial="hidden" {...trigger}>
|
||||||
|
<span className="sr-only">{text.replace(/\*/g, "")}</span>
|
||||||
|
{lines.map((tokens, l) => {
|
||||||
|
const offset = lines.slice(0, l).reduce((sum, line) => sum + line.length, 0)
|
||||||
|
return (
|
||||||
|
<span key={l} aria-hidden className="block">
|
||||||
|
{tokens.map((token, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
{i > 0 && " "}
|
||||||
|
<span className="mb-[-0.14em] inline-block overflow-hidden pb-[0.14em] align-top">
|
||||||
|
<motion.span
|
||||||
|
className={cn("inline-block", token.em && emClassName)}
|
||||||
|
variants={{
|
||||||
|
hidden: { y: "105%" },
|
||||||
|
show: { y: "0%", transition: { duration: 1.05, ease, delay: delay + (offset + i) * 0.055 } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.word}
|
||||||
|
</motion.span>
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollWord({ progress, range, children }: { progress: MotionValue<number>; range: [number, number]; children: string }) {
|
||||||
|
const opacity = useTransform(progress, range, [0.14, 1])
|
||||||
|
return <motion.span style={{ opacity }}>{children}</motion.span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wörter leuchten beim Scrollen nacheinander auf
|
||||||
|
export function ScrollWords({ text, className }: { text: string; className?: string }) {
|
||||||
|
const ref = useRef<HTMLParagraphElement>(null)
|
||||||
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.85", "end 0.45"] })
|
||||||
|
const words = text.split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p ref={ref} className={className}>
|
||||||
|
{words.map((word, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
{i > 0 && " "}
|
||||||
|
<ScrollWord progress={scrollYProgress} range={[i / words.length, (i + 1) / words.length]}>
|
||||||
|
{word}
|
||||||
|
</ScrollWord>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParallaxImage({ src, alt, className, imageClassName, offset = 60, sizes, eager }: {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
className?: string
|
||||||
|
imageClassName?: string
|
||||||
|
offset?: number
|
||||||
|
sizes?: string
|
||||||
|
eager?: boolean
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
|
||||||
|
const y = useTransform(scrollYProgress, [0, 1], [-offset, offset])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn("relative overflow-hidden", className)}>
|
||||||
|
<motion.div className="absolute inset-x-0" style={{ y, top: -offset, bottom: -offset }}>
|
||||||
|
<TImage src={src} alt={alt} sizes={sizes} eager={eager} className={imageClassName} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endlos-Laufband; jede Kopie enthält den Inhalt doppelt, damit auch breite Bildschirme gefüllt sind
|
||||||
|
export function Marquee({ children, className, duration = 40, reverse = false }: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
duration?: number
|
||||||
|
reverse?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex overflow-hidden", className)}>
|
||||||
|
{[0, 1].map((copy) => (
|
||||||
|
<motion.div
|
||||||
|
key={copy}
|
||||||
|
aria-hidden={copy === 1}
|
||||||
|
className="flex shrink-0"
|
||||||
|
animate={{ x: reverse ? ["-100%", "0%"] : ["0%", "-100%"] }}
|
||||||
|
transition={{ duration, ease: "linear", repeat: Infinity }}
|
||||||
|
>
|
||||||
|
{[0, 1].map((repeat) => (
|
||||||
|
<div key={repeat} className="flex shrink-0 items-center">{children}</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zählt deutsch formatierte Zahlen ("1.200", "4,9") hoch, sobald sie sichtbar werden
|
||||||
|
export function Counter({ value, className }: { value: string; className?: string }) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null)
|
||||||
|
const visible = useInView(ref, inView)
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target = parseFloat(value.replace(/\./g, "").replace(",", "."))
|
||||||
|
if (!visible || reduceMotion || Number.isNaN(target)) return
|
||||||
|
|
||||||
|
const decimals = value.split(",")[1]?.length ?? 0
|
||||||
|
const format = (n: number) => n.toLocaleString("de-DE", { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
|
const controls = animate(0, target, {
|
||||||
|
duration: 1.8,
|
||||||
|
ease,
|
||||||
|
onUpdate: (n) => {
|
||||||
|
if (ref.current) ref.current.textContent = format(n)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return () => controls.stop()
|
||||||
|
}, [visible, reduceMotion, value])
|
||||||
|
|
||||||
|
return <span ref={ref} className={className}>{value}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt-Wechsel in Formularen mit mehreren Schritten (Buchung, Bewerbung, Quiz)
|
||||||
|
export function StepPanel({ step, children, className }: { step: number | string; children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={step}
|
||||||
|
className={className}
|
||||||
|
initial={{ opacity: 0, x: 28 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -28 }}
|
||||||
|
transition={{ duration: 0.35, ease }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Accordion({ items, className, itemClassName, questionClassName, answerClassName }: {
|
||||||
|
items: { question: string; answer: string }[]
|
||||||
|
className?: string
|
||||||
|
itemClassName?: string
|
||||||
|
questionClassName?: string
|
||||||
|
answerClassName?: string
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState<number | null>(0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className={itemClassName}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open === i}
|
||||||
|
onClick={() => setOpen(open === i ? null : i)}
|
||||||
|
className={cn("flex w-full cursor-pointer items-center justify-between gap-6 text-left", questionClassName)}
|
||||||
|
>
|
||||||
|
<span>{item.question}</span>
|
||||||
|
<PlusIcon className={cn("size-5 shrink-0 transition-transform duration-300", open === i && "rotate-45")} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{open === i && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<p className={answerClassName}>{item.answer}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertikales Scrollen schiebt eine Bildreihe horizontal durch (Sektion bleibt dabei stehen)
|
||||||
|
export function HorizontalScroll({ children, header, className, trackClassName }: {
|
||||||
|
children: ReactNode
|
||||||
|
header?: ReactNode
|
||||||
|
className?: string
|
||||||
|
trackClassName?: string
|
||||||
|
}) {
|
||||||
|
const sectionRef = useRef<HTMLElement>(null)
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const distance = useMotionValue(0)
|
||||||
|
const { scrollYProgress } = useScroll({ target: sectionRef, offset: ["start start", "end end"] })
|
||||||
|
const x = useTransform(() => -scrollYProgress.get() * distance.get())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const section = sectionRef.current
|
||||||
|
const track = trackRef.current
|
||||||
|
if (!section || !track) return
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
const overflow = Math.max(0, track.scrollWidth - document.documentElement.clientWidth)
|
||||||
|
distance.set(overflow)
|
||||||
|
section.style.height = `calc(100vh + ${overflow}px)`
|
||||||
|
})
|
||||||
|
observer.observe(track)
|
||||||
|
observer.observe(document.documentElement)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [distance])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={sectionRef} className={cn("relative h-screen", className)}>
|
||||||
|
<div className="sticky top-0 flex h-screen flex-col justify-center overflow-hidden">
|
||||||
|
{header}
|
||||||
|
<motion.div ref={trackRef} style={{ x }} className={cn("flex w-max", trackClassName)}>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
modules/templates/catalog/shared/shell.tsx
Normal file
48
modules/templates/catalog/shared/shell.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { MotionConfig } from "motion/react"
|
||||||
|
import { useEffect, type CSSProperties, type ReactNode } from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { themeStyle } from "../../lib/content"
|
||||||
|
import { ChatWidget } from "../../ai/ui/chat-widget"
|
||||||
|
import type { FontPair, TemplateContent, TemplateDefinition } from "../../lib/types"
|
||||||
|
import { DemoBadge } from "./demo-badge"
|
||||||
|
|
||||||
|
// Rahmen jeder Vorlage: Palette und Schriften als CSS-Variablen (bg-t-*, font-t-*), reduzierte
|
||||||
|
// Bewegung laut Systemeinstellung, sanftes Scrollen zu Ankern, Entwurfs-Hinweis und KI-Chat.
|
||||||
|
// Fixe Leisten am unteren Rand (z. B. mobile CTA) schieben Badge und Chat über --t-safe-bottom nach oben.
|
||||||
|
export function TemplateShell({ def, content, fonts, className, children }: {
|
||||||
|
def: TemplateDefinition
|
||||||
|
content: TemplateContent
|
||||||
|
fonts: FontPair
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
const previous = root.style.scrollBehavior
|
||||||
|
root.style.scrollBehavior = "smooth"
|
||||||
|
return () => {
|
||||||
|
root.style.scrollBehavior = previous
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const style = { ...themeStyle(def, content), "--t-display": fonts.display, "--t-body": fonts.body } as CSSProperties
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionConfig reducedMotion="user">
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
fonts.className,
|
||||||
|
"relative min-h-dvh overflow-x-clip bg-t-bg font-t-body text-t-fg antialiased selection:bg-t-accent selection:text-t-accent-fg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DemoBadge content={content} />
|
||||||
|
<ChatWidget content={content} />
|
||||||
|
</div>
|
||||||
|
</MotionConfig>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
modules/templates/lib/content.ts
Normal file
168
modules/templates/lib/content.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import type { CSSProperties } from "react"
|
||||||
|
import type { AiSettings, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition } from "./types"
|
||||||
|
|
||||||
|
const MAX_TEXT = 4000
|
||||||
|
const MAX_ITEMS = 40
|
||||||
|
const HEX_COLOR = /^#[0-9a-f]{6}$/i
|
||||||
|
|
||||||
|
export const RICH_HINT = "Enter = neue Zeile, *Wort* = hervorgehoben"
|
||||||
|
|
||||||
|
// Leitet den Datentyp der Vorlage aus ihren Standardinhalten ab
|
||||||
|
export function defineTemplate<D extends TemplateData>(def: TemplateDefinition<D>) {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsplash(id: string, width = 2000) {
|
||||||
|
return `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${width}&q=80`
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
name: "Name", tagline: "Untertitel", suffix: "Zusatz", role: "Rolle", eyebrow: "Überzeile", badge: "Hinweis",
|
||||||
|
title: "Überschrift", text: "Text", lead: "Einleitung", note: "Kleingedrucktes", cta: "Button", ctaSecondary: "Zweiter Button",
|
||||||
|
image: "Bild", imageSecondary: "Zweites Bild", imageThird: "Drittes Bild", src: "Bild", caption: "Bildunterschrift",
|
||||||
|
price: "Preis", period: "Zeitraum", duration: "Dauer", author: "Von", quote: "Zitat", label: "Beschriftung", value: "Wert",
|
||||||
|
time: "Uhrzeit", day: "Tag", location: "Ort", place: "Ort", year: "Jahr", phone: "Telefon", email: "E-Mail",
|
||||||
|
address: "Adresse", hours: "Öffnungszeiten", category: "Kategorie", description: "Beschreibung", meta: "Details",
|
||||||
|
features: "Merkmale (eine pro Zeile)", tasks: "Aufgaben (eine pro Zeile)", question: "Frage", answer: "Antwort",
|
||||||
|
options: "Antworten (mit | trennen)", level: "Intensität (1–3)", trainer: "Coach", company: "Firma", speaker: "Speaker",
|
||||||
|
room: "Raum", highlight: "Hervorheben (ja = hervorgehoben)", left: "Restplätze (leer = keine Anzeige)", area: "Bereich",
|
||||||
|
type: "Art", rooms: "Zimmer", rating: "Bewertung", reviews: "Bewertungen (Text)", count: "Anzahl", number: "Nummer",
|
||||||
|
open: "Öffnet (HH:MM, leer = geschlossen)", close: "Schließt (HH:MM)", deadline: "Aktionsende (JJJJ-MM-TT)",
|
||||||
|
deadlineLabel: "Text vor dem Countdown", success: "Text nach dem Absenden", slots: "Uhrzeiten (mit Komma trennen)",
|
||||||
|
roles: "Wechselnde Jobtitel (mit Komma trennen)", experience: "Erfahrungsstufen (mit Komma trennen)", bullets: "Vorteile (eine pro Zeile)",
|
||||||
|
discount: "Rabatt bei jährlicher Zahlung (%)", button: "Button", dateLabel: "Datum (Anzeige)", date: "Startzeit (JJJJ-MM-TTTHH:MM)",
|
||||||
|
before: "Beschriftung vorher", after: "Beschriftung nachher", closing: "Schlusssatz", signature: "Unterschrift", book: "Buchtitel",
|
||||||
|
factor: "Ersparnis-Faktor (0 bis 1)", min: "Minimum (€)", max: "Maximum (€)", sqm: "Wohnfläche (m²)", instagram: "Instagram",
|
||||||
|
items: "Einträge", plans: "Tarife", members: "Personen", questions: "Fragen", milestones: "Meilensteine", facts: "Kennzahlen",
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXTAREA_KEYS = new Set(["title", "text", "lead", "quote", "description", "answer", "features", "tasks", "bullets"])
|
||||||
|
const IMAGE_KEY = /^(image|src|photo|portrait)/
|
||||||
|
|
||||||
|
function humanize(key: string) {
|
||||||
|
const spaced = key.replace(/([A-Z])/g, " $1").toLowerCase()
|
||||||
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeField(key: string, sample: string): FieldDef {
|
||||||
|
const label = FIELD_LABELS[key] ?? humanize(key)
|
||||||
|
if (IMAGE_KEY.test(key)) return { key, label, type: "image" }
|
||||||
|
if (key === "title") return { key, label, type: "textarea", hint: RICH_HINT }
|
||||||
|
if (TEXTAREA_KEYS.has(key) || sample.includes("\n") || sample.length > 90) return { key, label, type: "textarea" }
|
||||||
|
return { key, label }
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionMeta = string | { label: string; optional?: boolean; items?: Record<string, string> }
|
||||||
|
|
||||||
|
// Editor-Beschreibung aus den Standardinhalten ableiten; nur Sektionsnamen und Listen-Bezeichnungen sind nötig
|
||||||
|
export function describeSections<D extends TemplateData>(defaults: D, meta: Record<keyof D & string, SectionMeta>): SectionDef[] {
|
||||||
|
return Object.entries(defaults).map(([key, section]) => {
|
||||||
|
const info = typeof meta[key] === "string" ? { label: meta[key] as string } : (meta[key] as Exclude<SectionMeta, string>)
|
||||||
|
const fields = Object.entries(section).map(([fieldKey, value]): FieldDef | ListFieldDef => {
|
||||||
|
if (!Array.isArray(value)) return describeField(fieldKey, value)
|
||||||
|
const shape = value[0] ?? {}
|
||||||
|
return {
|
||||||
|
key: fieldKey,
|
||||||
|
label: FIELD_LABELS[fieldKey] ?? humanize(fieldKey),
|
||||||
|
type: "list",
|
||||||
|
itemLabel: info.items?.[fieldKey] ?? "Eintrag",
|
||||||
|
fields: Object.entries(shape).map(([itemKey, sample]) => describeField(itemKey, sample)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { key, label: info.label, optional: info.optional, fields }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, fallback: string) {
|
||||||
|
return typeof value === "string" ? value.slice(0, MAX_TEXT) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeItem(shape: ListItem, input: unknown): ListItem {
|
||||||
|
const source = isRecord(input) ? input : {}
|
||||||
|
return Object.fromEntries(Object.keys(shape).map((key) => [key, text(source[key], "")]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSection(defaults: SectionData, input: unknown): SectionData {
|
||||||
|
const source = isRecord(input) ? input : {}
|
||||||
|
const result: SectionData = {}
|
||||||
|
|
||||||
|
for (const [key, fallback] of Object.entries(defaults)) {
|
||||||
|
const value = source[key]
|
||||||
|
if (Array.isArray(fallback)) {
|
||||||
|
const shape = fallback[0] ?? {}
|
||||||
|
result[key] = Array.isArray(value) ? value.slice(0, MAX_ITEMS).map((item) => mergeItem(shape, item)) : fallback
|
||||||
|
} else {
|
||||||
|
result[key] = text(value, fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gespeicherte Inhalte immer gegen die aktuelle Vorlage auflösen: neue Felder bekommen ihren
|
||||||
|
// Standardwert, entfernte oder fremde Felder fallen weg. So überstehen Varianten Änderungen an der Vorlage.
|
||||||
|
export function resolveContent<D extends TemplateData>(def: TemplateDefinition<D>, raw: unknown): TemplateContent<D> {
|
||||||
|
const source = isRecord(raw) ? raw : {}
|
||||||
|
const data = isRecord(source.data) ? source.data : {}
|
||||||
|
const optional = new Set(def.sections.filter((section) => section.optional).map((section) => section.key))
|
||||||
|
const hidden = Array.isArray(source.hidden) ? source.hidden.filter((key): key is string => typeof key === "string" && optional.has(key)) : []
|
||||||
|
const ai = isRecord(source.ai) ? source.ai : {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
palette: def.palettes.find((palette) => palette.key === source.palette)?.key ?? def.palettes[0].key,
|
||||||
|
fonts: def.fonts.find((pair) => pair.key === source.fonts)?.key ?? def.fonts[0].key,
|
||||||
|
accent: typeof source.accent === "string" && HEX_COLOR.test(source.accent) ? source.accent : "",
|
||||||
|
hidden: [...new Set(hidden)],
|
||||||
|
ai: {
|
||||||
|
chat: ai.chat === true,
|
||||||
|
phone: ai.phone === true,
|
||||||
|
assistant: ai.assistant === true,
|
||||||
|
content: ai.content === true,
|
||||||
|
name: text(ai.name, "").slice(0, 60),
|
||||||
|
greeting: text(ai.greeting, "").slice(0, 400),
|
||||||
|
knowledge: typeof ai.knowledge === "string" ? ai.knowledge.slice(0, 8000) : "",
|
||||||
|
} satisfies AiSettings,
|
||||||
|
data: Object.fromEntries(
|
||||||
|
Object.entries(def.defaults).map(([key, section]) => [key, mergeSection(section, data[key])])
|
||||||
|
) as D,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schwarz oder Weiß, je nachdem was auf der Akzentfarbe besser lesbar ist (WCAG-Luminanz)
|
||||||
|
function readableOn(hex: string) {
|
||||||
|
const [r, g, b] = [1, 3, 5].map((i) => {
|
||||||
|
const channel = parseInt(hex.slice(i, i + 2), 16) / 255
|
||||||
|
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||||
|
})
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.179 ? "#111111" : "#ffffff"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accentColors(def: TemplateDefinition, content: TemplateContent) {
|
||||||
|
const palette = def.palettes.find((p) => p.key === content.palette) ?? def.palettes[0]
|
||||||
|
const accent = content.accent || palette.accent
|
||||||
|
return { accent, accentFg: readableOn(accent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function themeStyle(def: TemplateDefinition, content: TemplateContent): CSSProperties {
|
||||||
|
const palette = def.palettes.find((p) => p.key === content.palette) ?? def.palettes[0]
|
||||||
|
const accent = content.accent || palette.accent
|
||||||
|
|
||||||
|
return {
|
||||||
|
"--t-bg": palette.bg,
|
||||||
|
"--t-fg": palette.fg,
|
||||||
|
"--t-muted": palette.muted,
|
||||||
|
"--t-surface": palette.surface,
|
||||||
|
"--t-accent": accent,
|
||||||
|
"--t-accent-fg": readableOn(accent),
|
||||||
|
colorScheme: palette.dark ? "dark" : "light",
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandName(content: TemplateContent) {
|
||||||
|
const name = content.data.brand?.name
|
||||||
|
return typeof name === "string" && name.trim() ? name.trim() : null
|
||||||
|
}
|
||||||
27
modules/templates/lib/preview.ts
Normal file
27
modules/templates/lib/preview.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Nachrichten zwischen Editor und Vorschau-iframe (gleiche Origin, siehe live-preview.tsx)
|
||||||
|
export const PREVIEW_READY = "since3:preview-ready"
|
||||||
|
export const PREVIEW_CONTENT = "since3:preview-content"
|
||||||
|
|
||||||
|
export const DEVICES = {
|
||||||
|
desktop: { label: "Desktop", width: 1440 },
|
||||||
|
tablet: { label: "Tablet", width: 820 },
|
||||||
|
mobile: { label: "Mobil", width: 390 },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Device = keyof typeof DEVICES
|
||||||
|
|
||||||
|
export type ShareState = "shared" | "expired" | "private"
|
||||||
|
|
||||||
|
export function shareState(token: string | null, expiresAt: string | null): ShareState {
|
||||||
|
if (!token) return "private"
|
||||||
|
return expiresAt && new Date(expiresAt).getTime() <= Date.now() ? "expired" : "shared"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dateFormat = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeZone: "Europe/Berlin" })
|
||||||
|
|
||||||
|
export const SHARE_EXPIRY_OPTIONS = [
|
||||||
|
{ days: 0, label: "Unbegrenzt" },
|
||||||
|
{ days: 7, label: "7 Tage" },
|
||||||
|
{ days: 30, label: "30 Tage" },
|
||||||
|
{ days: 90, label: "90 Tage" },
|
||||||
|
] as const
|
||||||
33
modules/templates/lib/registry.ts
Normal file
33
modules/templates/lib/registry.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { beauty } from "../catalog/beauty/definition"
|
||||||
|
import { coach } from "../catalog/coach/definition"
|
||||||
|
import { event } from "../catalog/event/definition"
|
||||||
|
import { fitness } from "../catalog/fitness/definition"
|
||||||
|
import { handwerk } from "../catalog/handwerk/definition"
|
||||||
|
import { immobilien } from "../catalog/immobilien/definition"
|
||||||
|
import { kampagne } from "../catalog/kampagne/definition"
|
||||||
|
import { kanzlei } from "../catalog/kanzlei/definition"
|
||||||
|
import { karriere } from "../catalog/karriere/definition"
|
||||||
|
import { praxis } from "../catalog/praxis/definition"
|
||||||
|
import { restaurant } from "../catalog/restaurant/definition"
|
||||||
|
import { saas } from "../catalog/saas/definition"
|
||||||
|
import type { TemplateDefinition } from "./types"
|
||||||
|
|
||||||
|
// Reihenfolge = Reihenfolge im Admin: erst Branchen, dann Seiten-Typen für bestimmte Situationen
|
||||||
|
export const templates: TemplateDefinition[] = [
|
||||||
|
restaurant,
|
||||||
|
handwerk,
|
||||||
|
beauty,
|
||||||
|
kanzlei,
|
||||||
|
praxis,
|
||||||
|
fitness,
|
||||||
|
saas,
|
||||||
|
immobilien,
|
||||||
|
karriere,
|
||||||
|
kampagne,
|
||||||
|
event,
|
||||||
|
coach,
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getTemplate(key: string) {
|
||||||
|
return templates.find((template) => template.key === key)
|
||||||
|
}
|
||||||
82
modules/templates/lib/types.ts
Normal file
82
modules/templates/lib/types.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
export type Palette = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
dark?: boolean
|
||||||
|
bg: string
|
||||||
|
fg: string
|
||||||
|
muted: string
|
||||||
|
surface: string
|
||||||
|
accent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schriftpaar einer Vorlage; die next/font-Objekte leben im fonts.ts der jeweiligen Vorlage
|
||||||
|
export type FontPair = {
|
||||||
|
className: string
|
||||||
|
display: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldDef = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type?: "text" | "textarea" | "image"
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListFieldDef = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: "list"
|
||||||
|
itemLabel: string
|
||||||
|
fields: FieldDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SectionDef = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
// Optionale Sektionen lassen sich im Editor ausblenden
|
||||||
|
optional?: boolean
|
||||||
|
fields: (FieldDef | ListFieldDef)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListItem = Record<string, string>
|
||||||
|
export type SectionData = Record<string, string | ListItem[]>
|
||||||
|
export type TemplateData = Record<string, SectionData>
|
||||||
|
|
||||||
|
export type TemplateDefinition<D extends TemplateData = TemplateData> = {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
industry: string
|
||||||
|
description: string
|
||||||
|
cover: string
|
||||||
|
palettes: Palette[]
|
||||||
|
fonts: { key: string; label: string }[]
|
||||||
|
sections: SectionDef[]
|
||||||
|
// Inhalte einer frischen Variante; legt zugleich die erlaubte Struktur fest (siehe resolveContent)
|
||||||
|
defaults: D
|
||||||
|
// Vorbelegung der KI-Demos (Angebots-Assistent, Content-Generator)
|
||||||
|
samples: { inquiry: string; topic: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// KI-Funktionen einer Variante; Wissen kommt aus den Inhalten plus `knowledge`
|
||||||
|
export type AiSettings = {
|
||||||
|
chat: boolean
|
||||||
|
phone: boolean
|
||||||
|
assistant: boolean
|
||||||
|
content: boolean
|
||||||
|
name: string
|
||||||
|
greeting: string
|
||||||
|
knowledge: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemplateContent<D extends TemplateData = TemplateData> = {
|
||||||
|
palette: string
|
||||||
|
fonts: string
|
||||||
|
// Eigene Akzentfarbe (#rrggbb), leer = Akzent der Palette
|
||||||
|
accent: string
|
||||||
|
hidden: string[]
|
||||||
|
ai: AiSettings
|
||||||
|
data: D
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemplateProps<D extends TemplateData> = { content: TemplateContent<D> }
|
||||||
20
modules/templates/queries/shared-variant.ts
Normal file
20
modules/templates/queries/shared-variant.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import "server-only"
|
||||||
|
import { cache } from "react"
|
||||||
|
import { createClient } from "@/lib/supabase/server"
|
||||||
|
import { resolveContent } from "../lib/content"
|
||||||
|
import { getTemplate } from "../lib/registry"
|
||||||
|
|
||||||
|
const TOKEN = /^[A-Za-z0-9_-]{20,64}$/
|
||||||
|
|
||||||
|
// Öffentlich (siehe proxy.ts): nur die eine Variante zum Token, abgelaufene Links ergeben null
|
||||||
|
export const getSharedVariant = cache(async (token: string) => {
|
||||||
|
if (!TOKEN.test(token)) return null
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data } = await supabase.rpc("get_shared_template_variant", { p_token: token }).maybeSingle()
|
||||||
|
const shared = data as { template_key: string; name: string; content: unknown } | null
|
||||||
|
const def = shared ? getTemplate(shared.template_key) : undefined
|
||||||
|
if (!shared || !def) return null
|
||||||
|
|
||||||
|
return { name: shared.name, def, content: resolveContent(def, shared.content) }
|
||||||
|
})
|
||||||
41
modules/templates/queries/variants.ts
Normal file
41
modules/templates/queries/variants.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import "server-only"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { createClient } from "@/lib/supabase/server"
|
||||||
|
|
||||||
|
export type VariantRow = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
template_key: string
|
||||||
|
client_id: string | null
|
||||||
|
content: unknown
|
||||||
|
share_token: string | null
|
||||||
|
share_expires_at: string | null
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VariantListItem = Omit<VariantRow, "content">
|
||||||
|
|
||||||
|
export async function getVariants() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("template_variants")
|
||||||
|
.select("id, name, template_key, client_id, share_token, share_expires_at, updated_at")
|
||||||
|
.order("updated_at", { ascending: false })
|
||||||
|
|
||||||
|
if (error) throw new Error(`Failed to load template variants: ${error.message}`)
|
||||||
|
|
||||||
|
return data as VariantListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVariant(id: string) {
|
||||||
|
if (!z.string().uuid().safeParse(id).success) return null
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("template_variants")
|
||||||
|
.select("id, name, template_key, client_id, content, share_token, share_expires_at, updated_at")
|
||||||
|
.eq("id", id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
return data as VariantRow | null
|
||||||
|
}
|
||||||
29
modules/templates/ui/components/create-variant-button.tsx
Normal file
29
modules/templates/ui/components/create-variant-button.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react"
|
||||||
|
import { PencilIcon } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { createVariant } from "../../actions/variants"
|
||||||
|
|
||||||
|
export function CreateVariantButton({ templateKey }: { templateKey: string }) {
|
||||||
|
const [pending, startTransition] = useTransition()
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createVariant(templateKey)
|
||||||
|
if (result?.error) setError(result.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PencilIcon /> {pending ? "Wird angelegt …" : "Anpassen"}
|
||||||
|
</Button>
|
||||||
|
{error && <p className="w-full text-xs text-destructive">{error}</p>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
modules/templates/ui/components/editor/ai-panel.tsx
Normal file
91
modules/templates/ui/components/editor/ai-panel.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ExternalLinkIcon } from "lucide-react"
|
||||||
|
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { AI_DEMO_SLUGS, AI_DEMOS } from "../../../ai/demos"
|
||||||
|
import type { AiSettings } from "../../../lib/types"
|
||||||
|
import { Toggle } from "./toggle"
|
||||||
|
|
||||||
|
const demoText = {
|
||||||
|
telefon: "Sprach-Demo im Browser: Anrufen, Fragen stellen, Termin anfragen.",
|
||||||
|
assistent: "Kundenanfrage rein, Antwort-E-Mail oder Angebotsentwurf raus.",
|
||||||
|
content: "Posts und Anzeigen im Ton des Kunden, als echte Vorschau.",
|
||||||
|
}
|
||||||
|
|
||||||
|
function Heading({ title, text }: { title: string; text?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-sm font-medium">{title}</h3>
|
||||||
|
{text && <p className="mt-0.5 text-xs text-muted-foreground">{text}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiPanel({ variantId, ai, onChange }: { variantId: string; ai: AiSettings; onChange: (patch: Partial<AiSettings>) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<p className="rounded-lg bg-surface px-3 py-2.5 text-xs text-muted-foreground">
|
||||||
|
Die KI kennt alle Inhalte dieser Variante. Über den geteilten Link sind höchstens 150 KI-Anfragen pro Tag möglich.
|
||||||
|
Änderungen hier greifen nach dem Speichern.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Heading title="Chat-Assistent" text="Chat-Fenster unten rechts auf der Webseite." />
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Toggle checked={ai.chat} onChange={(chat) => onChange({ chat })}>Auf der Webseite anzeigen</Toggle>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ai-name">Name des Assistenten</FieldLabel>
|
||||||
|
<Input id="ai-name" value={ai.name} placeholder="z. B. Mia" maxLength={60} onChange={(event) => onChange({ name: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ai-greeting">Begrüßung</FieldLabel>
|
||||||
|
<Textarea id="ai-greeting" rows={2} value={ai.greeting} placeholder="Leer = automatische Begrüßung mit Firmennamen" onChange={(event) => onChange({ greeting: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Heading title="Zusatzwissen" text="Was nicht auf der Webseite steht: Preise, FAQ, Parken, Zahlungsarten, Regeln." />
|
||||||
|
<Field>
|
||||||
|
<Textarea
|
||||||
|
rows={6}
|
||||||
|
value={ai.knowledge}
|
||||||
|
aria-label="Zusatzwissen"
|
||||||
|
placeholder={"Parkplätze im Hof\nKartenzahlung möglich\nHunde willkommen"}
|
||||||
|
onChange={(event) => onChange({ knowledge: event.target.value })}
|
||||||
|
/>
|
||||||
|
<FieldDescription>Stichpunkte reichen. Die KI erfindet nichts dazu.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Heading title="KI-Demos" text="Freigeschaltete Demos erscheinen im geteilten Link im Entwurfs-Badge unten links." />
|
||||||
|
<div className="flex flex-col divide-y rounded-lg border">
|
||||||
|
{AI_DEMO_SLUGS.map((slug) => {
|
||||||
|
const feature = AI_DEMOS[slug].feature
|
||||||
|
return (
|
||||||
|
<div key={slug}>
|
||||||
|
<Toggle checked={ai[feature]} onChange={(checked) => onChange({ [feature]: checked })}>
|
||||||
|
<span className="block">{AI_DEMOS[slug].label}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{demoText[slug]}</span>
|
||||||
|
</Toggle>
|
||||||
|
<a
|
||||||
|
href={`/ki/${variantId}/${slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mx-3 mb-2.5 inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-3" /> Intern testen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
modules/templates/ui/components/editor/content-panel.tsx
Normal file
133
modules/templates/ui/components/editor/content-panel.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useId } from "react"
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, Trash2Icon } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||||
|
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import type { FieldDef, ListFieldDef, ListItem, TemplateData, TemplateDefinition } from "../../../lib/types"
|
||||||
|
|
||||||
|
const MAX_ITEMS = 40
|
||||||
|
|
||||||
|
export function ContentPanel({ def, data, hidden, onChange }: {
|
||||||
|
def: TemplateDefinition
|
||||||
|
data: TemplateData
|
||||||
|
hidden: string[]
|
||||||
|
onChange: (section: string, field: string, value: string | ListItem[]) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{def.sections.map((section, index) => (
|
||||||
|
<Collapsible key={section.key} defaultOpen={index === 0} className="rounded-lg border">
|
||||||
|
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-medium [&[data-panel-open]>svg]:rotate-180">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{section.label}
|
||||||
|
{hidden.includes(section.key) && (
|
||||||
|
<span className="rounded bg-surface px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground">ausgeblendet</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="flex flex-col gap-4 border-t px-3 py-4">
|
||||||
|
{section.fields.map((field) =>
|
||||||
|
field.type === "list" ? (
|
||||||
|
<ListEditor
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
items={(data[section.key]?.[field.key] as ListItem[] | undefined) ?? []}
|
||||||
|
onChange={(items) => onChange(section.key, field.key, items)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FieldInput
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={(data[section.key]?.[field.key] as string | undefined) ?? ""}
|
||||||
|
onChange={(value) => onChange(section.key, field.key, value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldInput({ field, value, onChange }: { field: FieldDef; value: string; onChange: (value: string) => void }) {
|
||||||
|
const id = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={id}>{field.label}</FieldLabel>
|
||||||
|
{field.type === "textarea" ? (
|
||||||
|
<Textarea id={id} value={value} rows={3} onChange={(event) => onChange(event.target.value)} />
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
type={field.type === "image" ? "url" : "text"}
|
||||||
|
placeholder={field.type === "image" ? "https://images.unsplash.com/…" : undefined}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.type === "image" && value && (
|
||||||
|
<div aria-hidden className="h-20 rounded-md border bg-surface bg-cover bg-center" style={{ backgroundImage: `url("${value}")` }} />
|
||||||
|
)}
|
||||||
|
{field.hint && <FieldDescription>{field.hint}</FieldDescription>}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListEditor({ field, items, onChange }: { field: ListFieldDef; items: ListItem[]; onChange: (items: ListItem[]) => void }) {
|
||||||
|
function update(index: number, key: string, value: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, [key]: value } : item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(index: number, offset: number) {
|
||||||
|
const next = [...items]
|
||||||
|
const [item] = next.splice(index, 1)
|
||||||
|
next.splice(index + offset, 0, item)
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">{field.label}</p>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={index} className="rounded-md border bg-surface/50 p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{field.itemLabel} {index + 1}</span>
|
||||||
|
<div className="flex">
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Nach oben" disabled={index === 0} onClick={() => move(index, -1)}>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Nach unten" disabled={index === items.length - 1} onClick={() => move(index, 1)}>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={`${field.itemLabel} entfernen`} onClick={() => onChange(items.filter((_, i) => i !== index))}>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{field.fields.map((sub) => (
|
||||||
|
<FieldInput key={sub.key} field={sub} value={item[sub.key] ?? ""} onChange={(value) => update(index, sub.key, value)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={items.length >= MAX_ITEMS}
|
||||||
|
onClick={() => onChange([...items, Object.fromEntries(field.fields.map((sub) => [sub.key, ""]))])}
|
||||||
|
>
|
||||||
|
<PlusIcon /> {field.itemLabel} hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
modules/templates/ui/components/editor/design-panel.tsx
Normal file
112
modules/templates/ui/components/editor/design-panel.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TemplateContent, TemplateDefinition } from "../../../lib/types"
|
||||||
|
import { Toggle } from "./toggle"
|
||||||
|
|
||||||
|
type ThemePatch = Partial<Pick<TemplateContent, "palette" | "fonts" | "accent" | "hidden">>
|
||||||
|
|
||||||
|
function Heading({ title, text }: { title: string; text?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-sm font-medium">{title}</h3>
|
||||||
|
{text && <p className="mt-0.5 text-xs text-muted-foreground">{text}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesignPanel({ def, content, onChange }: {
|
||||||
|
def: TemplateDefinition
|
||||||
|
content: TemplateContent
|
||||||
|
onChange: (patch: ThemePatch) => void
|
||||||
|
}) {
|
||||||
|
const palette = def.palettes.find((p) => p.key === content.palette) ?? def.palettes[0]
|
||||||
|
const accent = content.accent || palette.accent
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<section>
|
||||||
|
<Heading title="Farbpalette" />
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{def.palettes.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={p.key === content.palette}
|
||||||
|
onClick={() => onChange({ palette: p.key })}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer overflow-hidden rounded-lg border text-left transition-shadow",
|
||||||
|
p.key === content.palette ? "ring-2 ring-ring" : "hover:border-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-14 items-end gap-1 p-2" style={{ background: p.bg }}>
|
||||||
|
<span className="size-4 rounded-full" style={{ background: p.fg }} />
|
||||||
|
<span className="size-4 rounded-full" style={{ background: p.accent }} />
|
||||||
|
</div>
|
||||||
|
<p className="px-2 py-1.5 text-xs">{p.label}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Heading title="Akzentfarbe" text="Zum Beispiel die Markenfarbe des Kunden. Textfarbe darauf wird automatisch gewählt." />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={accent}
|
||||||
|
aria-label="Akzentfarbe"
|
||||||
|
onChange={(event) => onChange({ accent: event.target.value })}
|
||||||
|
className="h-10 w-14 cursor-pointer rounded-md border bg-transparent p-1"
|
||||||
|
/>
|
||||||
|
<code className="font-mono text-xs uppercase">{accent}</code>
|
||||||
|
{content.accent && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onChange({ accent: "" })}>
|
||||||
|
Palette verwenden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Heading title="Schriften" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{def.fonts.map((pair) => (
|
||||||
|
<button
|
||||||
|
key={pair.key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={pair.key === content.fonts}
|
||||||
|
onClick={() => onChange({ fonts: pair.key })}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer rounded-lg border px-3 py-2.5 text-left text-sm transition-shadow",
|
||||||
|
pair.key === content.fonts ? "ring-2 ring-ring" : "hover:border-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pair.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Heading title="Sektionen" text="Ausgeblendete Sektionen behalten ihre Inhalte." />
|
||||||
|
<div className="flex flex-col divide-y rounded-lg border">
|
||||||
|
{def.sections.filter((section) => section.optional).map((section) => (
|
||||||
|
<Toggle
|
||||||
|
key={section.key}
|
||||||
|
checked={!content.hidden.includes(section.key)}
|
||||||
|
onChange={(visible) =>
|
||||||
|
onChange({
|
||||||
|
hidden: visible ? content.hidden.filter((key) => key !== section.key) : [...content.hidden, section.key],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</Toggle>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
modules/templates/ui/components/editor/preview-frame.tsx
Normal file
95
modules/templates/ui/components/editor/preview-frame.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useEffectEvent, useRef, useState } from "react"
|
||||||
|
import { MonitorIcon, SmartphoneIcon, TabletIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DEVICES, PREVIEW_CONTENT, PREVIEW_READY, type Device } from "../../../lib/preview"
|
||||||
|
import type { TemplateContent } from "../../../lib/types"
|
||||||
|
|
||||||
|
const icons = { desktop: MonitorIcon, tablet: TabletIcon, mobile: SmartphoneIcon }
|
||||||
|
|
||||||
|
// Die Vorlage läuft in echter Gerätebreite im iframe (damit Media Queries greifen) und wird
|
||||||
|
// passend herunterskaliert. Änderungen gehen per postMessage hinein, ohne Neuladen.
|
||||||
|
export function PreviewFrame({ templateKey, variantId, content }: { templateKey: string; variantId: string; content: TemplateContent }) {
|
||||||
|
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [size, setSize] = useState({ width: 0, height: 0 })
|
||||||
|
const [device, setDevice] = useState<Device>("desktop")
|
||||||
|
|
||||||
|
const post = useEffectEvent(() => {
|
||||||
|
frameRef.current?.contentWindow?.postMessage({ type: PREVIEW_CONTENT, content }, window.location.origin)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
post()
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onMessage(event: MessageEvent) {
|
||||||
|
if (event.origin === window.location.origin && event.source === frameRef.current?.contentWindow && event.data?.type === PREVIEW_READY) {
|
||||||
|
post()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("message", onMessage)
|
||||||
|
return () => window.removeEventListener("message", onMessage)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
const observer = new ResizeObserver(([entry]) => setSize({ width: entry.contentRect.width, height: entry.contentRect.height }))
|
||||||
|
observer.observe(container)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deviceWidth = DEVICES[device].width
|
||||||
|
const inset = device === "desktop" ? 0 : 16
|
||||||
|
const scale = size.width ? Math.min(1, (size.width - inset * 2) / deviceWidth) : 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex min-h-[75vh] flex-col overflow-hidden rounded-xl border bg-surface lg:min-h-0">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b bg-card px-2 py-1.5">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(Object.keys(DEVICES) as Device[]).map((key) => {
|
||||||
|
const Icon = icons[key]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={device === key}
|
||||||
|
onClick={() => setDevice(key)}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-colors",
|
||||||
|
device === key ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
{DEVICES[key].label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{deviceWidth}px · {Math.round(scale * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref={containerRef} className="relative min-h-0 flex-1 overflow-hidden">
|
||||||
|
{size.width > 0 && (
|
||||||
|
<iframe
|
||||||
|
ref={frameRef}
|
||||||
|
src={`/preview/${templateKey}?live=1&variant=${variantId}`}
|
||||||
|
title="Vorschau der Variante"
|
||||||
|
className={cn("absolute origin-top-left border-0 bg-white", inset > 0 && "rounded-2xl shadow-xl ring-1 ring-black/10")}
|
||||||
|
style={{
|
||||||
|
width: deviceWidth,
|
||||||
|
height: (size.height - inset * 2) / scale,
|
||||||
|
top: inset,
|
||||||
|
left: Math.max(0, (size.width - deviceWidth * scale) / 2),
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
modules/templates/ui/components/editor/share-panel.tsx
Normal file
159
modules/templates/ui/components/editor/share-panel.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useState, useSyncExternalStore, useTransition } from "react"
|
||||||
|
import { CopyPlusIcon, ExternalLinkIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { CopyField } from "@/modules/admin/ui/components/copy-field"
|
||||||
|
import { deleteVariant, duplicateVariant, updateShare } from "../../../actions/variants"
|
||||||
|
import { dateFormat, SHARE_EXPIRY_OPTIONS } from "../../../lib/preview"
|
||||||
|
import type { EditorVariant } from "../../views/variant-editor"
|
||||||
|
|
||||||
|
const selectClass = "h-10 w-full rounded-md border bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
const subscribe = () => () => {}
|
||||||
|
|
||||||
|
function Heading({ title, text }: { title: string; text?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-sm font-medium">{title}</h3>
|
||||||
|
{text && <p className="mt-0.5 text-xs text-muted-foreground">{text}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharePanel({ variant, clients, clientId, onClientChange, dirty }: {
|
||||||
|
variant: EditorVariant
|
||||||
|
clients: { id: string; name: string }[]
|
||||||
|
clientId: string | null
|
||||||
|
onClientChange: (clientId: string | null) => void
|
||||||
|
dirty: boolean
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const origin = useSyncExternalStore(subscribe, () => window.location.origin, () => "")
|
||||||
|
const [share, setShare] = useState({ token: variant.shareToken, expiresAt: variant.shareExpiresAt, expired: variant.shareState === "expired" })
|
||||||
|
// -1 = bestehendes Ablaufdatum beibehalten
|
||||||
|
const [expiry, setExpiry] = useState<number>(variant.shareExpiresAt ? -1 : 0)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
function run(action: () => Promise<{ error: string } | unknown>) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await action()
|
||||||
|
if (result && typeof result === "object" && "error" in result) setError(String(result.error))
|
||||||
|
else setError(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyShare(enabled: boolean, options: { regenerate?: boolean; days?: number } = {}) {
|
||||||
|
run(async () => {
|
||||||
|
const days = options.days ?? (expiry < 0 ? 0 : expiry)
|
||||||
|
const result = await updateShare(variant.id, { enabled, expiresInDays: days, regenerate: options.regenerate })
|
||||||
|
if (!("error" in result)) {
|
||||||
|
setShare({ ...result, expired: false })
|
||||||
|
setExpiry(result.expiresAt ? days : 0)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = share.token ? `${origin}/demo/${share.token}` : ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<section>
|
||||||
|
<Heading title="Client" text="Optional – ordnet die Variante einem Kunden zu. Wird mit „Speichern“ übernommen." />
|
||||||
|
<select
|
||||||
|
value={clientId ?? ""}
|
||||||
|
onChange={(event) => onClientChange(event.target.value || null)}
|
||||||
|
className={selectClass}
|
||||||
|
aria-label="Client"
|
||||||
|
>
|
||||||
|
<option value="">Kein Client (Pitch)</option>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<option key={client.id} value={client.id}>{client.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Heading title="Link teilen" text="Kunden öffnen den Link ohne Login. Er zeigt immer den zuletzt gespeicherten Stand." />
|
||||||
|
{share.token ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{share.expired && (
|
||||||
|
<p className="rounded-md bg-signal-warning/12 px-3 py-2 text-xs text-signal-warning">
|
||||||
|
Dieser Link ist abgelaufen. Wähle eine neue Gültigkeit, um ihn wieder zu aktivieren.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<CopyField label="Öffentlicher Link" value={url} />
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
Gültigkeit
|
||||||
|
<select
|
||||||
|
value={expiry}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => applyShare(true, { days: Number(event.target.value) })}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{expiry < 0 && share.expiresAt && (
|
||||||
|
<option value={-1}>bis {dateFormat.format(new Date(share.expiresAt))}</option>
|
||||||
|
)}
|
||||||
|
{SHARE_EXPIRY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.days} value={option.days}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a href={url} target="_blank" rel="noreferrer" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||||
|
<ExternalLinkIcon /> Öffnen
|
||||||
|
</a>
|
||||||
|
<Button variant="outline" size="sm" disabled={pending} onClick={() => applyShare(true, { regenerate: true })}>
|
||||||
|
<RefreshCwIcon /> Neuer Link
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" disabled={pending} onClick={() => applyShare(false)}>
|
||||||
|
Freigabe beenden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">„Neuer Link“ macht den bisherigen Link ungültig.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button disabled={pending} onClick={() => applyShare(true)}>Link erstellen</Button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Heading title="Variante" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button variant="outline" disabled={pending} onClick={() => run(() => duplicateVariant(variant.id))}>
|
||||||
|
<CopyPlusIcon /> Duplizieren
|
||||||
|
</Button>
|
||||||
|
{dirty && <p className="text-xs text-muted-foreground">Dupliziert wird der gespeicherte Stand.</p>}
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => {
|
||||||
|
const result = await deleteVariant(variant.id)
|
||||||
|
if ("success" in result) router.push("/admin/templates")
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Endgültig löschen
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" className="text-destructive" onClick={() => setConfirmDelete(true)}>
|
||||||
|
<Trash2Icon /> Löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
modules/templates/ui/components/editor/toggle.tsx
Normal file
21
modules/templates/ui/components/editor/toggle.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function Toggle({ checked, onChange, children }: { checked: boolean; onChange: (checked: boolean) => void; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-center justify-between gap-3 px-3 py-2.5 text-sm">
|
||||||
|
<span className="min-w-0">{children}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn("relative h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors", checked ? "bg-primary" : "bg-ink-300 dark:bg-ink-700")}
|
||||||
|
>
|
||||||
|
<span className={cn("absolute top-0.5 left-0.5 size-4 rounded-full bg-background shadow transition-transform", checked && "translate-x-4")} />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
modules/templates/ui/components/live-preview.tsx
Normal file
34
modules/templates/ui/components/live-preview.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import type { AiAccess } from "../../ai/demos"
|
||||||
|
import { resolveContent } from "../../lib/content"
|
||||||
|
import { PREVIEW_CONTENT, PREVIEW_READY } from "../../lib/preview"
|
||||||
|
import { getTemplate } from "../../lib/registry"
|
||||||
|
import type { TemplateContent } from "../../lib/types"
|
||||||
|
import { TemplateRenderer } from "./template-renderer"
|
||||||
|
|
||||||
|
// Läuft im iframe des Editors und übernimmt ungespeicherte Änderungen per postMessage
|
||||||
|
export function LivePreview({ templateKey, initialContent, access }: {
|
||||||
|
templateKey: string
|
||||||
|
initialContent: TemplateContent
|
||||||
|
access: AiAccess | null
|
||||||
|
}) {
|
||||||
|
const [content, setContent] = useState(initialContent)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const def = getTemplate(templateKey)
|
||||||
|
if (!def) return
|
||||||
|
|
||||||
|
function onMessage(event: MessageEvent) {
|
||||||
|
if (event.origin !== window.location.origin || event.data?.type !== PREVIEW_CONTENT || !def) return
|
||||||
|
setContent(resolveContent(def, event.data.content))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", onMessage)
|
||||||
|
window.parent.postMessage({ type: PREVIEW_READY }, window.location.origin)
|
||||||
|
return () => window.removeEventListener("message", onMessage)
|
||||||
|
}, [templateKey])
|
||||||
|
|
||||||
|
return <TemplateRenderer templateKey={templateKey} content={content} access={access} />
|
||||||
|
}
|
||||||
50
modules/templates/ui/components/template-renderer.tsx
Normal file
50
modules/templates/ui/components/template-renderer.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ComponentType } from "react"
|
||||||
|
import { AiAccessProvider } from "../../ai/access-context"
|
||||||
|
import type { AiAccess } from "../../ai/demos"
|
||||||
|
import { BeautyTemplate } from "../../catalog/beauty/template"
|
||||||
|
import { CoachTemplate } from "../../catalog/coach/template"
|
||||||
|
import { EventTemplate } from "../../catalog/event/template"
|
||||||
|
import { FitnessTemplate } from "../../catalog/fitness/template"
|
||||||
|
import { HandwerkTemplate } from "../../catalog/handwerk/template"
|
||||||
|
import { ImmobilienTemplate } from "../../catalog/immobilien/template"
|
||||||
|
import { KampagneTemplate } from "../../catalog/kampagne/template"
|
||||||
|
import { KanzleiTemplate } from "../../catalog/kanzlei/template"
|
||||||
|
import { KarriereTemplate } from "../../catalog/karriere/template"
|
||||||
|
import { PraxisTemplate } from "../../catalog/praxis/template"
|
||||||
|
import { RestaurantTemplate } from "../../catalog/restaurant/template"
|
||||||
|
import { SaasTemplate } from "../../catalog/saas/template"
|
||||||
|
import type { TemplateContent } from "../../lib/types"
|
||||||
|
|
||||||
|
type TemplateComponent = ComponentType<{ content: TemplateContent }>
|
||||||
|
|
||||||
|
const components: Record<string, TemplateComponent> = {
|
||||||
|
restaurant: RestaurantTemplate as TemplateComponent,
|
||||||
|
handwerk: HandwerkTemplate as TemplateComponent,
|
||||||
|
beauty: BeautyTemplate as TemplateComponent,
|
||||||
|
kanzlei: KanzleiTemplate as TemplateComponent,
|
||||||
|
praxis: PraxisTemplate as TemplateComponent,
|
||||||
|
fitness: FitnessTemplate as TemplateComponent,
|
||||||
|
saas: SaasTemplate as TemplateComponent,
|
||||||
|
immobilien: ImmobilienTemplate as TemplateComponent,
|
||||||
|
karriere: KarriereTemplate as TemplateComponent,
|
||||||
|
kampagne: KampagneTemplate as TemplateComponent,
|
||||||
|
event: EventTemplate as TemplateComponent,
|
||||||
|
coach: CoachTemplate as TemplateComponent,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateRenderer({ templateKey, content, access = null }: {
|
||||||
|
templateKey: string
|
||||||
|
content: TemplateContent
|
||||||
|
access?: AiAccess | null
|
||||||
|
}) {
|
||||||
|
const Template = components[templateKey]
|
||||||
|
if (!Template) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AiAccessProvider access={access}>
|
||||||
|
<Template content={content} />
|
||||||
|
</AiAccessProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
modules/templates/ui/views/templates-view.tsx
Normal file
125
modules/templates/ui/views/templates-view.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { EyeIcon } from "lucide-react"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { getClients } from "@/modules/admin/queries/clients"
|
||||||
|
import { dateFormat, shareState, type ShareState } from "../../lib/preview"
|
||||||
|
import { getTemplate, templates } from "../../lib/registry"
|
||||||
|
import { getVariants } from "../../queries/variants"
|
||||||
|
import { CreateVariantButton } from "../components/create-variant-button"
|
||||||
|
|
||||||
|
const shareBadge: Record<ShareState, { label: string; variant: "success" | "warning" | "neutral" }> = {
|
||||||
|
shared: { label: "Geteilt", variant: "success" },
|
||||||
|
expired: { label: "Abgelaufen", variant: "warning" },
|
||||||
|
private: { label: "Privat", variant: "neutral" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TemplatesView = async () => {
|
||||||
|
const [variants, clients] = await Promise.all([getVariants(), getClients()])
|
||||||
|
const clientNames = new Map(clients.map((client) => [client.id, client.name]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-10">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold md:text-3xl">Vorlagen</h1>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Demo-Webseiten für Pitches: Vorlage wählen, für den Kunden anpassen und als Link teilen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<article key={template.key} className="group flex flex-col overflow-hidden rounded-xl border bg-card">
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={template.cover}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1280px) 25vw, (min-width: 640px) 50vw, 100vw"
|
||||||
|
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent" />
|
||||||
|
<span className="absolute top-3 left-3 rounded-full bg-black/50 px-2.5 py-1 text-xs text-white backdrop-blur">
|
||||||
|
{template.industry}
|
||||||
|
</span>
|
||||||
|
<div className="absolute bottom-3 left-3 flex gap-1.5">
|
||||||
|
{template.palettes.map((palette) => (
|
||||||
|
<span
|
||||||
|
key={palette.key}
|
||||||
|
title={palette.label}
|
||||||
|
className="size-4 rounded-full ring-2 ring-white/70"
|
||||||
|
style={{ background: `linear-gradient(135deg, ${palette.bg} 50%, ${palette.accent} 50%)` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2 p-4">
|
||||||
|
<h2 className="font-semibold">{template.name}</h2>
|
||||||
|
<p className="flex-1 text-sm text-muted-foreground">{template.description}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
href={`/preview/${template.key}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
|
>
|
||||||
|
<EyeIcon /> Ansehen
|
||||||
|
</a>
|
||||||
|
<CreateVariantButton templateKey={template.key} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-semibold">Varianten</h2>
|
||||||
|
{variants.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||||
|
Noch keine Varianten. Wähle oben eine Vorlage und klicke auf „Anpassen“.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Vorlage</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">Client</TableHead>
|
||||||
|
<TableHead>Link</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">Geändert</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{variants.map((variant) => {
|
||||||
|
const badge = shareBadge[shareState(variant.share_token, variant.share_expires_at)]
|
||||||
|
return (
|
||||||
|
<TableRow key={variant.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link href={`/admin/templates/${variant.id}`} className="font-medium hover:underline">
|
||||||
|
{variant.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getTemplate(variant.template_key)?.name ?? variant.template_key}</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
{(variant.client_id && clientNames.get(variant.client_id)) ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><Badge variant={badge.variant}>{badge.label}</Badge></TableCell>
|
||||||
|
<TableCell className="hidden text-muted-foreground md:table-cell">
|
||||||
|
{dateFormat.format(new Date(variant.updated_at))}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemplatesView
|
||||||
171
modules/templates/ui/views/variant-editor.tsx
Normal file
171
modules/templates/ui/views/variant-editor.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useEffect, useEffectEvent, useState, useTransition } from "react"
|
||||||
|
import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { saveVariant } from "../../actions/variants"
|
||||||
|
import type { ShareState } from "../../lib/preview"
|
||||||
|
import { getTemplate } from "../../lib/registry"
|
||||||
|
import type { ListItem, TemplateContent } from "../../lib/types"
|
||||||
|
import { AiPanel } from "../components/editor/ai-panel"
|
||||||
|
import { ContentPanel } from "../components/editor/content-panel"
|
||||||
|
import { DesignPanel } from "../components/editor/design-panel"
|
||||||
|
import { PreviewFrame } from "../components/editor/preview-frame"
|
||||||
|
import { SharePanel } from "../components/editor/share-panel"
|
||||||
|
|
||||||
|
export type EditorVariant = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
templateKey: string
|
||||||
|
clientId: string | null
|
||||||
|
content: TemplateContent
|
||||||
|
shareToken: string | null
|
||||||
|
shareExpiresAt: string | null
|
||||||
|
shareState: ShareState
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: "content", label: "Inhalte" },
|
||||||
|
{ key: "design", label: "Design" },
|
||||||
|
{ key: "ai", label: "KI" },
|
||||||
|
{ key: "share", label: "Teilen" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type Tab = (typeof tabs)[number]["key"]
|
||||||
|
|
||||||
|
function snapshot(name: string, clientId: string | null, content: TemplateContent) {
|
||||||
|
return JSON.stringify({ name, clientId, content })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VariantEditor({ variant, clients }: { variant: EditorVariant; clients: { id: string; name: string }[] }) {
|
||||||
|
const def = getTemplate(variant.templateKey)
|
||||||
|
const [name, setName] = useState(variant.name)
|
||||||
|
const [clientId, setClientId] = useState(variant.clientId)
|
||||||
|
const [content, setContent] = useState(variant.content)
|
||||||
|
const [saved, setSaved] = useState(() => snapshot(variant.name, variant.clientId, variant.content))
|
||||||
|
const [tab, setTab] = useState<Tab>("content")
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const current = snapshot(name, clientId, content)
|
||||||
|
const dirty = current !== saved
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await saveVariant(variant.id, { name, clientId, content })
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setSaved(current)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onShortcut = useEffectEvent((event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
||||||
|
event.preventDefault()
|
||||||
|
if (dirty && !pending) save()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", onShortcut)
|
||||||
|
return () => window.removeEventListener("keydown", onShortcut)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return
|
||||||
|
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
|
||||||
|
window.addEventListener("beforeunload", warn)
|
||||||
|
return () => window.removeEventListener("beforeunload", warn)
|
||||||
|
}, [dirty])
|
||||||
|
|
||||||
|
if (!def) return null
|
||||||
|
|
||||||
|
function updateField(section: string, field: string, value: string | ListItem[]) {
|
||||||
|
setContent((c) => ({ ...c, data: { ...c.data, [section]: { ...c.data[section], [field]: value } } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 lg:h-[calc(100dvh-4rem)]">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/admin/templates"
|
||||||
|
aria-label="Zurück zu den Vorlagen"
|
||||||
|
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (dirty && !window.confirm("Ungespeicherte Änderungen verwerfen?")) event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
aria-label="Name der Variante"
|
||||||
|
className="w-full truncate rounded-md bg-transparent text-xl font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/50 md:text-2xl"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Vorlage „{def.name}“ · {def.industry}</p>
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-xs", dirty ? "text-signal-warning" : "text-muted-foreground")}>
|
||||||
|
{pending ? "Speichert …" : dirty ? "Ungespeicherte Änderungen" : "Gespeichert"}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={`/preview/${def.key}?variant=${variant.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Zeigt den gespeicherten Stand"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon /> Vollbild
|
||||||
|
</a>
|
||||||
|
<Button size="sm" onClick={save} disabled={!dirty || pending}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[minmax(320px,400px)_minmax(0,1fr)]">
|
||||||
|
<aside className="flex min-h-0 flex-col overflow-hidden rounded-xl border bg-card">
|
||||||
|
<div role="tablist" className="flex gap-1 border-b p-1.5">
|
||||||
|
{tabs.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === item.key}
|
||||||
|
onClick={() => setTab(item.key)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 cursor-pointer rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||||
|
tab === item.key ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
{tab === "content" && (
|
||||||
|
<ContentPanel def={def} data={content.data} hidden={content.hidden} onChange={updateField} />
|
||||||
|
)}
|
||||||
|
{tab === "design" && (
|
||||||
|
<DesignPanel def={def} content={content} onChange={(patch) => setContent((c) => ({ ...c, ...patch }))} />
|
||||||
|
)}
|
||||||
|
{tab === "ai" && (
|
||||||
|
<AiPanel variantId={variant.id} ai={content.ai} onChange={(patch) => setContent((c) => ({ ...c, ai: { ...c.ai, ...patch } }))} />
|
||||||
|
)}
|
||||||
|
{tab === "share" && (
|
||||||
|
<SharePanel variant={variant} clients={clients} clientId={clientId} onClientChange={setClientId} dirty={dirty} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<PreviewFrame templateKey={def.key} variantId={variant.id} content={content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,11 @@ const nextConfig: NextConfig = {
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
"/api/invoices/*": ["./modules/shared/pdf/assets/**/*"],
|
"/api/invoices/*": ["./modules/shared/pdf/assets/**/*"],
|
||||||
},
|
},
|
||||||
|
images: {
|
||||||
|
// Platzhalterbilder der Vorlagen. Ohne `search`, weil Unsplash-Links Parameter (w, q, fit) tragen.
|
||||||
|
// Bild-Links anderer Seiten lädt die Vorlage unoptimiert (modules/templates/catalog/shared/media.tsx).
|
||||||
|
remotePatterns: [{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" }],
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
// Bereits besuchte Seiten 5 Min. im Browser halten. Eigene Änderungen leeren den Cache sofort
|
// Bereits besuchte Seiten 5 Min. im Browser halten. Eigene Änderungen leeren den Cache sofort
|
||||||
// (revalidatePath in den Actions), ebenso Login/Logout (Cookie-Änderung).
|
// (revalidatePath in den Actions), ebenso Login/Logout (Cookie-Änderung).
|
||||||
|
|
|
||||||
124
package-lock.json
generated
124
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "since3-portal",
|
"name": "since3-portal",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.131.0",
|
||||||
"@base-ui/react": "^1.8.0",
|
"@base-ui/react": "^1.8.0",
|
||||||
"@react-pdf/renderer": "^4.9.0",
|
"@react-pdf/renderer": "^4.9.0",
|
||||||
"@supabase/ssr": "^0.12.7",
|
"@supabase/ssr": "^0.12.7",
|
||||||
|
|
@ -17,6 +18,7 @@
|
||||||
"decimal.js": "^10.6.0",
|
"decimal.js": "^10.6.0",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
"lucide-react": "^1.48.0",
|
"lucide-react": "^1.48.0",
|
||||||
|
"motion": "^14.0.0",
|
||||||
"next": "16.3.6",
|
"next": "16.3.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
|
|
@ -50,6 +52,27 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.131.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz",
|
||||||
|
"integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"standardwebhooks": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
|
|
@ -2095,6 +2118,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.117.2",
|
"version": "2.117.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.117.2.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.117.2.tgz",
|
||||||
|
|
@ -5438,6 +5467,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
|
|
@ -5627,6 +5662,29 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "14.0.0",
|
||||||
|
"motion-utils": "14.0.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
|
@ -6841,6 +6899,19 @@
|
||||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
|
|
@ -7491,6 +7562,43 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion/-/motion-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "14.0.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -9264,6 +9372,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|
@ -9695,6 +9813,12 @@
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.131.0",
|
||||||
"@base-ui/react": "^1.8.0",
|
"@base-ui/react": "^1.8.0",
|
||||||
"@react-pdf/renderer": "^4.9.0",
|
"@react-pdf/renderer": "^4.9.0",
|
||||||
"@supabase/ssr": "^0.12.7",
|
"@supabase/ssr": "^0.12.7",
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
"decimal.js": "^10.6.0",
|
"decimal.js": "^10.6.0",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
"lucide-react": "^1.48.0",
|
"lucide-react": "^1.48.0",
|
||||||
|
"motion": "^14.0.0",
|
||||||
"next": "16.3.6",
|
"next": "16.3.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
|
|
|
||||||
5
proxy.ts
5
proxy.ts
|
|
@ -3,13 +3,16 @@ import { createClient } from "./lib/supabase/proxy";
|
||||||
|
|
||||||
|
|
||||||
const publicRoutes = ["/", "/impressum", "/datenschutz", "/agb"]
|
const publicRoutes = ["/", "/impressum", "/datenschutz", "/agb"]
|
||||||
|
// Geteilte Vorlagen (/demo/<token>) öffnen Kunden ohne Login; die KI-Routen prüfen Token bzw. Admin selbst
|
||||||
|
const publicPrefixes = ["/demo/", "/api/ai/"]
|
||||||
|
|
||||||
export default async function proxy(request: NextRequest) {
|
export default async function proxy(request: NextRequest) {
|
||||||
const { supabase, response } = createClient(request)
|
const { supabase, response } = createClient(request)
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
const isPublicRoute = publicRoutes.includes(request.nextUrl.pathname)
|
const { pathname } = request.nextUrl
|
||||||
|
const isPublicRoute = publicRoutes.includes(pathname) || publicPrefixes.some((prefix) => pathname.startsWith(prefix))
|
||||||
|
|
||||||
if (!user && !isPublicRoute) {
|
if (!user && !isPublicRoute) {
|
||||||
const redirectResponse = NextResponse.redirect(new URL("/", request.url))
|
const redirectResponse = NextResponse.redirect(new URL("/", request.url))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue