13 Commits

Author SHA1 Message Date
85d2d2c069 feat(ai): Implement AI agent contact form and fix local Qdrant network configs
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 18m2s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-06 11:56:12 +01:00
6a6fbb6f19 feat: register payloadChatPlugin from @mintel/payload-ai in Payload CMS config
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🏗️ Build (push) Failing after 17m52s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-06 01:50:54 +01:00
6b6b2b8ece fix(blog): auto-play LoadTimeSimulator, fix Carousel data, filter TableOfContents text, extend CarouselBlock schema
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Successful in 21m19s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m59s
Build & Deploy / 🔔 Notify (push) Successful in 9s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m40s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m18s
Nightly QA / 🔍 Static Analysis (push) Successful in 4m34s
Nightly QA / 📝 E2E (push) Successful in 4m45s
Nightly QA / 🔔 Notify (push) Has been skipped
2026-03-06 00:54:45 +01:00
9f412d81a8 chore: release v1.9.9 to trigger prod deploy
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 10m35s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 QA (push) Successful in 50s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m44s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 23:54:12 +01:00
9c401f13de chore: trigger CI build after clearing infra registry space
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🏗️ Build (push) Successful in 16m49s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 QA (push) Successful in 56s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m0s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-05 23:22:45 +01:00
5857404ac1 fix(blog): merge defaultJSXConverters to prevent 'unknown node' on standard Lexical nodes
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 20m40s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-05 22:53:19 +01:00
34a96f8aef fix(blog): resolve IconList string collision rendering 'Check' text
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 16m0s
Build & Deploy / 🚀 Deploy (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m9s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m34s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 21:50:05 +01:00
4e6f3f29cf fix(blog): add missing mintelP/TLDR renderers, fix iconList, diagram blocks, reduce AI components to 13
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 11m52s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 1m15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 6m31s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Add mintelP renderer with inline markdown link/marker support (228 broken blocks)
- Add mintelTldr renderer for summary boxes
- Fix iconList to display item.title instead of empty item.description
- Rewire all 6 diagram block types to render via Mermaid
- Remove ai property from 30 non-essential blocks (46 -> 13)
- Tighten MemeCard to 5 verified templates, max 1 per article
- Fix PerformanceChartBlock syntax after ai removal
2026-03-05 17:39:57 +01:00
1bd516fbe4 fix: production container names in cms-sync and pin zod version for consistency
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🏗️ Build (push) Successful in 10m16s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 51s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m52s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-05 15:57:50 +01:00
4d0e3433a6 ci(deploy): remove unnecessary next.js build cache from docker image to save disk space
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 51s
Build & Deploy / 🏗️ Build (push) Successful in 13m0s
Build & Deploy / 🚀 Deploy (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 51s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m21s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-05 14:22:49 +01:00
ee9cde1ed0 ci(deploy): fix yaml syntax and ensure docker prune runs before build
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🏗️ Build (push) Failing after 16m44s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 14:06:30 +01:00
33cf701034 ci(deploy): isolate docker buildcache per target env to prevent registry blob upload collisions
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 12m6s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 13:41:51 +01:00
1fae5edee3 ci(deploy): remove obsolete wait-for-upstream block to unblock prod releases
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Failing after 23m17s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-05 13:25:44 +01:00
59 changed files with 10045 additions and 14104 deletions

View File

@@ -113,21 +113,7 @@ jobs:
echo "short_sha=$SHORT_SHA"
} >> "$GITHUB_OUTPUT"
# ⏳ Wait for Upstream Packages/Images if Tagged
if [[ "${{ github.ref_type }}" == "tag" ]]; then
echo "🔎 Checking for @mintel dependencies in package.json..."
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | head -1 | cut -d'"' -f4 | sed 's/\^//; s/\~//')
TAG_TO_WAIT="v$UPSTREAM_VERSION"
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
echo "⏳ This release depends on @mintel v$UPSTREAM_VERSION. Waiting for upstream build..."
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
chmod +x wait-for-upstream.sh
GITEA_TOKEN=${{ secrets.GITHUB_TOKEN }} ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
fi
fi
else
echo "target=skip" >> "$GITHUB_OUTPUT"
fi
@@ -299,8 +285,15 @@ jobs:
perl -pi -e 's/link:\.\/_at-mintel\/packages\/pdf"/link:.\/_at-mintel\/packages\/pdf-library"/g' package.json
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:..\/\.\.\/_at-mintel\/packages\/$1"/g' apps/web/package.json
perl -pi -e 's/link:\.\.\/\.\.\/_at-mintel\/packages\/pdf"/link:..\/\.\.\/_at-mintel\/packages\/pdf-library"/g' apps/web/package.json
- name: 🧹 Free Disk Space
run: |
docker builder prune -af || true
docker image prune -af || true
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
@@ -318,8 +311,8 @@ jobs:
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: registry.infra.mintel.me/mintel/mintel.me:${{ needs.prepare.outputs.image_tag }}
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache,mode=max
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }}
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }},mode=max
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}

View File

@@ -48,7 +48,7 @@ WORKDIR /app
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/.next/cache ./apps/web/.next/cache
# Start from the app directory to ensure references solve correctly
WORKDIR /app/apps/web

View File

@@ -1,5 +1,5 @@
import { Section } from "@/src/components/Section";
import { ContactForm } from "@/src/components/ContactForm";
import { AgentChat } from "@/src/components/agent/AgentChat";
import { AbstractCircuit } from "@/src/components/Effects";
export default function ContactPage() {
@@ -12,9 +12,10 @@ export default function ContactPage() {
effects={<></>}
className="pt-24 pb-12 md:pt-32 md:pb-20"
>
{/* Full-width Form */}
<ContactForm />
{/* AI Agent Chat */}
<AgentChat />
</Section>
</div>
);
}

View File

@@ -0,0 +1,381 @@
import { NextResponse, NextRequest } from 'next/server';
import redis from '../../../src/lib/redis';
import * as Sentry from '@sentry/nextjs';
import {
PRICING,
initialState,
PAGE_SAMPLES,
FEATURE_OPTIONS,
FUNCTION_OPTIONS,
API_OPTIONS,
ASSET_OPTIONS,
DESIGN_OPTIONS,
EMPLOYEE_OPTIONS,
DEADLINE_LABELS,
} from '../../../src/logic/pricing/constants';
// Rate limiting
const RATE_LIMIT_POINTS = 10;
const RATE_LIMIT_DURATION = 60;
// Tool definitions for Mistral
const TOOLS = [
{
type: 'function' as const,
function: {
name: 'update_company_info',
description: 'Aktualisiert Firmen-/Kontaktinformationen des Kunden. Nutze dieses Tool wenn der Nutzer seinen Namen, seine Firma oder Mitarbeiterzahl nennt.',
parameters: {
type: 'object',
properties: {
companyName: { type: 'string', description: 'Firmenname' },
name: { type: 'string', description: 'Name des Ansprechpartners' },
employeeCount: {
type: 'string',
enum: EMPLOYEE_OPTIONS.map((e) => e.id),
description: 'Mitarbeiterzahl',
},
existingWebsite: { type: 'string', description: 'URL der bestehenden Website' },
},
},
},
},
{
type: 'function' as const,
function: {
name: 'update_project_type',
description: 'Setzt den Projekttyp. Nutze dieses Tool wenn klar wird ob es eine Website oder Web-App wird.',
parameters: {
type: 'object',
properties: {
projectType: {
type: 'string',
enum: ['website', 'web-app'],
description: 'Art des Projekts',
},
},
required: ['projectType'],
},
},
},
{
type: 'function' as const,
function: {
name: 'show_page_selector',
description: 'Zeigt dem Nutzer eine interaktive Auswahl der verfügbaren Seiten-Typen. Nutze dieses Tool wenn über die Struktur/Seiten der Website gesprochen wird.',
parameters: {
type: 'object',
properties: {
preselected: {
type: 'array',
items: { type: 'string' },
description: 'Bereits ausgewählte Seiten-IDs basierend auf dem Gespräch',
},
},
},
},
},
{
type: 'function' as const,
function: {
name: 'show_feature_selector',
description: 'Zeigt dem Nutzer eine interaktive Auswahl der verfügbaren Features (Blog, Produkte, Jobs, Cases, Events). Nutze dieses Tool wenn über Inhalts-Bereiche gesprochen wird.',
parameters: {
type: 'object',
properties: {
preselected: {
type: 'array',
items: { type: 'string' },
description: 'Vorausgewählte Feature-IDs',
},
},
},
},
},
{
type: 'function' as const,
function: {
name: 'show_function_selector',
description: 'Zeigt dem Nutzer eine interaktive Auswahl der technischen Funktionen (Suche, Filter, PDF, Formulare). Nutze dieses Tool wenn über technische Anforderungen gesprochen wird.',
parameters: {
type: 'object',
properties: {
preselected: {
type: 'array',
items: { type: 'string' },
description: 'Vorausgewählte Funktions-IDs',
},
},
},
},
},
{
type: 'function' as const,
function: {
name: 'show_api_selector',
description: 'Zeigt dem Nutzer eine interaktive Auswahl der System-Integrationen (CRM, ERP, Payment, etc.). Nutze dieses Tool wenn über Drittanbieter-Anbindungen gesprochen wird.',
parameters: {
type: 'object',
properties: {
preselected: {
type: 'array',
items: { type: 'string' },
description: 'Vorausgewählte API-IDs',
},
},
},
},
},
{
type: 'function' as const,
function: {
name: 'show_asset_selector',
description: 'Zeigt dem Nutzer eine Auswahl welche Assets bereits vorhanden sind (Logo, Styleguide, Bilder etc.). Nutze dieses Tool wenn über vorhandenes Material gesprochen wird.',
parameters: {
type: 'object',
properties: {
preselected: {
type: 'array',
items: { type: 'string' },
description: 'Vorausgewählte Asset-IDs',
},
},
},
},
},
{
type: 'function' as const,
function: {
name: 'show_design_picker',
description: 'Zeigt dem Nutzer eine visuelle Design-Stil-Auswahl. Nutze dieses Tool wenn über das Design oder den visuellen Stil gesprochen wird.',
parameters: {
type: 'object',
properties: {
preselected: { type: 'string', description: 'Vorausgewählter Design-Stil' },
},
},
},
},
{
type: 'function' as const,
function: {
name: 'show_timeline_picker',
description: 'Zeigt dem Nutzer eine Timeline/Deadline-Auswahl. Nutze dieses Tool wenn über Zeitrahmen oder Deadlines gesprochen wird.',
parameters: {
type: 'object',
properties: {
preselected: { type: 'string', description: 'Vorausgewählte Deadline' },
},
},
},
},
{
type: 'function' as const,
function: {
name: 'show_contact_fields',
description: 'Zeigt dem Nutzer Eingabefelder für E-Mail-Adresse und optionale Nachricht. Nutze dieses Tool wenn es Zeit ist die Kontaktdaten zu sammeln, typischerweise gegen Ende des Gesprächs.',
parameters: {
type: 'object',
properties: {},
},
},
},
{
type: 'function' as const,
function: {
name: 'request_file_upload',
description: 'Zeigt dem Nutzer einen Datei-Upload-Bereich. Nutze dieses Tool wenn der Nutzer Dateien teilen möchte (Briefing, Sitemap, Design-Referenzen etc.).',
parameters: {
type: 'object',
properties: {
label: { type: 'string', description: 'Beschriftung des Upload-Bereichs' },
},
},
},
},
{
type: 'function' as const,
function: {
name: 'show_estimate_preview',
description: 'Zeigt dem Nutzer eine Live-Kostenübersicht basierend auf dem aktuellen Konfigurationsstand. Nutze dieses Tool wenn genügend Informationen gesammelt wurden oder wenn der Nutzer nach Kosten fragt.',
parameters: {
type: 'object',
properties: {},
},
},
},
{
type: 'function' as const,
function: {
name: 'generate_estimate_pdf',
description: 'Generiert ein PDF-Angebot basierend auf dem aktuellen Konfigurationsstand. Nutze dieses Tool wenn der Nutzer ein Angebot/PDF möchte oder das Gespräch abgeschlossen wird.',
parameters: {
type: 'object',
properties: {},
},
},
},
{
type: 'function' as const,
function: {
name: 'submit_inquiry',
description: 'Sendet die Anfrage ab und benachrichtigt Marc Mintel. Nutze dieses Tool wenn der Nutzer explizit absenden möchte und mindestens Name + Email vorhanden sind.',
parameters: {
type: 'object',
properties: {},
},
},
},
];
// Available options for the system prompt
const availableOptions = `
VERFÜGBARE SEITEN: ${PAGE_SAMPLES.map((p) => `${p.id} (${p.label})`).join(', ')}
VERFÜGBARE FEATURES: ${FEATURE_OPTIONS.map((f) => `${f.id} (${f.label})`).join(', ')}
VERFÜGBARE FUNKTIONEN: ${FUNCTION_OPTIONS.map((f) => `${f.id} (${f.label})`).join(', ')}
VERFÜGBARE API-INTEGRATIONEN: ${API_OPTIONS.map((a) => `${a.id} (${a.label})`).join(', ')}
VERFÜGBARE ASSETS: ${ASSET_OPTIONS.map((a) => `${a.id} (${a.label})`).join(', ')}
VERFÜGBARE DESIGN-STILE: ${DESIGN_OPTIONS.map((d) => `${d.id} (${d.label})`).join(', ')}
DEADLINES: ${Object.entries(DEADLINE_LABELS).map(([k, v]) => `${k} (${v})`).join(', ')}
MITARBEITER: ${EMPLOYEE_OPTIONS.map((e) => `${e.id} (${e.label})`).join(', ')}
PREISE (netto):
- Basis Website: ${PRICING.BASE_WEBSITE}
- Pro Seite: ${PRICING.PAGE}
- Pro Feature: ${PRICING.FEATURE}
- Pro Funktion: ${PRICING.FUNCTION}
- API-Integration: ${PRICING.API_INTEGRATION}
- CMS Setup: ${PRICING.CMS_SETUP}
- Hosting monatlich: ${PRICING.HOSTING_MONTHLY}
`;
const SYSTEM_PROMPT = `Du bist ein professioneller Projektberater der Digitalagentur "Mintel" spezialisiert auf Next.js, Payload CMS und moderne Web-Infrastruktur.
DEINE AUFGABE:
Du führst ein natürliches Beratungsgespräch, um alle Informationen für eine Website-/Web-App-Projektschätzung zu sammeln. Du bist freundlich, kompetent und effizient.
GESPRÄCHSFÜHRUNG:
1. Begrüße den Nutzer und frage nach seinem Namen und Unternehmen.
2. Finde heraus, was für ein Projekt es wird (Website oder Web-App).
3. Sammle schrittweise die Anforderungen NICHT alle auf einmal fragen!
4. Pro Nachricht maximal 1-2 Themen ansprechen.
5. Nutze die verfügbaren Tools um interaktive Auswahl-Widgets zu zeigen.
6. Wenn du genug Informationen hast, zeige eine Kostenübersicht.
7. Biete an, ein PDF-Angebot zu generieren.
8. Sammle am Ende Kontaktdaten und biete an die Anfrage abzusenden.
WICHTIGE REGELN:
- ANTWORTE IN DER SPRACHE DES NUTZERS (Deutsch/Englisch).
- Halte Antworten kurz und natürlich (2-4 Sätze pro Nachricht).
- Zeige Widgets über Tool-Calls nicht als Text-Listen.
- Wenn der Nutzer eine konkrete Auswahl trifft müssen wir das über die passenden UI-Tools machen, bestätige kurz und gehe zum nächsten Thema.
- Du darfst mehrere Tools gleichzeitig aufrufen wenn es sinnvoll ist.
- Sei proaktiv: Wenn der Nutzer sagt "ich brauche eine Website für mein Restaurant", sag nicht nur "ok", sondern schlage direkt passende Seiten vor (Home, About, Speisekarte, Kontakt, Impressum) und zeige den Seiten-Selektor.
${availableOptions}
AKTUELLER FORMSTATE (wird vom Frontend mitgeliefert):
Wird in jeder Nachricht als JSON übergeben.`;
export async function POST(req: NextRequest) {
try {
const { messages, formState, visitorId, honeypot } = await req.json();
// Validation
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
}
const latestMessage = messages[messages.length - 1].content;
// Honeypot
if (honeypot && honeypot.length > 0) {
await new Promise((resolve) => setTimeout(resolve, 3000));
return NextResponse.json({
message: 'Vielen Dank für Ihre Anfrage.',
toolCalls: [],
});
}
// Rate Limiting
try {
if (visitorId) {
const requestCount = await redis.incr(`agent_chat_rate_limit:${visitorId}`);
if (requestCount === 1) {
await redis.expire(`agent_chat_rate_limit:${visitorId}`, RATE_LIMIT_DURATION);
}
if (requestCount > RATE_LIMIT_POINTS) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 },
);
}
}
} catch (redisError) {
console.error('Redis Rate Limiting Error:', redisError);
Sentry.captureException(redisError, { tags: { context: 'agent-chat-rate-limit' } });
}
// Build messages for OpenRouter
const systemMessage = {
role: 'system',
content: `${SYSTEM_PROMPT}\n\nAKTUELLER FORMSTATE:\n${JSON.stringify(formState || initialState, null, 2)}`,
};
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
throw new Error('OPENROUTER_API_KEY is not set');
}
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${openRouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me',
'X-Title': 'Mintel.me Project Agent',
},
body: JSON.stringify({
model: 'mistralai/mistral-large-2407',
temperature: 0.4,
tools: TOOLS,
tool_choice: 'auto',
messages: [
systemMessage,
...messages.map((m: any) => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
})),
],
}),
});
if (!fetchRes.ok) {
const errBody = await fetchRes.text();
throw new Error(`OpenRouter API Error: ${errBody}`);
}
const data = await fetchRes.json();
const choice = data.choices[0];
const responseMessage = choice.message;
// Extract tool calls
const toolCalls = responseMessage.tool_calls?.map((tc: any) => ({
id: tc.id,
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments || '{}'),
})) || [];
return NextResponse.json({
message: responseMessage.content || '',
toolCalls,
rawToolCalls: responseMessage.tool_calls || [],
});
} catch (error) {
console.error('Agent Chat API Error:', error);
Sentry.captureException(error, { tags: { context: 'agent-chat-api' } });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,140 @@
import { NextResponse, NextRequest } from 'next/server';
import { searchPosts } from '../../../src/lib/qdrant';
import redis from '../../../src/lib/redis';
import * as Sentry from '@sentry/nextjs';
// Rate limiting constants
const RATE_LIMIT_POINTS = 5; // 5 requests
const RATE_LIMIT_DURATION = 60; // per 1 minute
export async function POST(req: NextRequest) {
try {
const { messages, visitorId, honeypot } = await req.json();
// 1. Basic Validation
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
}
const latestMessage = messages[messages.length - 1].content;
const isBot = honeypot && honeypot.length > 0;
if (latestMessage.length > 500) {
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
}
// 2. Honeypot check
if (isBot) {
console.warn('Honeypot triggered in AI search');
await new Promise((resolve) => setTimeout(resolve, 3000));
return NextResponse.json({
answerText: 'Vielen Dank für Ihre Anfrage.',
posts: [],
});
}
// 3. Rate Limiting via Redis
try {
if (visitorId) {
const requestCount = await redis.incr(`ai_search_rate_limit:${visitorId}`);
if (requestCount === 1) {
await redis.expire(`ai_search_rate_limit:${visitorId}`, RATE_LIMIT_DURATION);
}
if (requestCount > RATE_LIMIT_POINTS) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 },
);
}
}
} catch (redisError) {
console.error('Redis Rate Limiting Error:', redisError);
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
// Fail open if Redis is down
}
// 4. Fetch Context from Qdrant
let contextStr = '';
let foundPosts: any[] = [];
try {
const searchResults = await searchPosts(latestMessage, 5);
if (searchResults && searchResults.length > 0) {
const postDescriptions = searchResults
.map((p: any) => p.payload?.content)
.join('\n\n');
contextStr = `BLOG-POSTS & WISSEN:\n${postDescriptions}`;
foundPosts = searchResults
.filter((p: any) => p.payload?.data)
.map((p: any) => p.payload?.data);
}
} catch (e) {
console.error('Qdrant Search Error:', e);
Sentry.captureException(e, { tags: { context: 'ai-search-qdrant' } });
}
// 5. Generate AI Response via OpenRouter (Mistral)
const systemPrompt = `Du bist ein professioneller technischer Berater der Agentur "Mintel" einer Full-Stack Digitalagentur spezialisiert auf Next.js, Payload CMS und moderne Web-Infrastruktur.
Deine Aufgabe ist es, Besuchern bei technischen Fragen zu helfen, basierend auf den Blog-Artikeln und dem Fachwissen der Agentur.
WICHTIGE REGELN:
1. ANTWORTE IMMER IN DER SPRACHE DES BENUTZERS. Wenn der Benutzer Deutsch spricht, antworte auf Deutsch. Bei Englisch, antworte auf Englisch.
2. Nutze das bereitgestellte BLOG-WISSEN unten, um deine Antworten zu fundieren. Verweise auf relevante Blog-Posts.
3. Sei hilfreich, präzise und technisch versiert. Du kannst Code-Beispiele geben wenn sinnvoll.
4. Wenn du keine passende Information findest, gib das offen zu und schlage vor, über das Kontaktformular direkt Kontakt aufzunehmen.
5. Antworte in Markdown-Format (Überschriften, Listen, Code-Blöcke sind erlaubt).
6. Halte Antworten kompakt aber informativ maximal 3-4 Absätze.
7. Oute dich als AI-Assistent von Mintel.
VERFÜGBARER KONTEXT:
${contextStr ? contextStr : 'Keine spezifischen Blog-Daten für diese Anfrage gefunden.'}
`;
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
throw new Error('OPENROUTER_API_KEY is not set');
}
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${openRouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me',
'X-Title': 'Mintel.me AI Search',
},
body: JSON.stringify({
model: 'mistralai/mistral-large-2407',
temperature: 0.3,
messages: [
{ role: 'system', content: systemPrompt },
...messages.map((m: any) => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
})),
],
}),
});
if (!fetchRes.ok) {
const errBody = await fetchRes.text();
throw new Error(`OpenRouter API Error: ${errBody}`);
}
const data = await fetchRes.json();
const text = data.choices[0].message.content;
return NextResponse.json({
answerText: text,
posts: foundPosts,
});
} catch (error) {
console.error('AI Search API Error:', error);
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -21,6 +21,7 @@
"video:render:button": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
"video:render:all": "npm run video:render:contact && npm run video:render:button",
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
"index:posts": "node --import tsx --experimental-loader ./ignore-css.mjs ./scripts/index-posts.ts",
"typecheck": "tsc --noEmit",
"check:og": "tsx scripts/check-og-images.ts",
"check:forms": "tsx scripts/check-forms.ts",
@@ -56,6 +57,7 @@
"@payloadcms/richtext-lexical": "^3.77.0",
"@payloadcms/storage-s3": "^3.77.0",
"@payloadcms/ui": "^3.77.0",
"@qdrant/js-client-rest": "^1.17.0",
"@react-pdf/renderer": "^4.3.2",
"@remotion/bundler": "^4.0.414",
"@remotion/cli": "^4.0.414",
@@ -92,9 +94,11 @@
"qrcode": "^1.5.4",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-social-media-embed": "^2.5.18",
"react-tweet": "^3.3.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"remotion": "^4.0.414",
"replicate": "^1.4.0",
"require-in-the-middle": "^8.0.1",
@@ -142,4 +146,4 @@
"type": "git",
"url": "git@git.infra.mintel.me:mmintel/mintel.me.git"
}
}
}

View File

@@ -22,6 +22,7 @@ import { CrmContacts } from "./src/payload/collections/CrmContacts";
import { CrmInteractions } from "./src/payload/collections/CrmInteractions";
import { CrmTopics } from "./src/payload/collections/CrmTopics";
import { Projects } from "./src/payload/collections/Projects";
import { payloadChatPlugin } from "@mintel/payload-ai";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
@@ -84,25 +85,29 @@ export default buildConfig({
plugins: [
...(process.env.S3_ENDPOINT
? [
s3Storage({
collections: {
media: {
prefix: `${process.env.S3_PREFIX || "mintel-me"}/media`,
},
s3Storage({
collections: {
media: {
prefix: `${process.env.S3_PREFIX || "mintel-me"}/media`,
},
bucket: process.env.S3_BUCKET || "",
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || "",
secretAccessKey: process.env.S3_SECRET_KEY || "",
},
region: process.env.S3_REGION || "fsn1",
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: true,
},
bucket: process.env.S3_BUCKET || "",
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || "",
secretAccessKey: process.env.S3_SECRET_KEY || "",
},
}),
]
region: process.env.S3_REGION || "fsn1",
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: true,
},
}),
]
: []),
payloadChatPlugin({
enabled: true,
mcpServers: [],
}),
],
endpoints: [
{

View File

@@ -89,14 +89,14 @@ resolve_target() {
prod|production)
REMOTE_PROJECT="mintel-me-production"
REMOTE_DB_CONTAINER="mintel-me-production-postgres-db-1"
REMOTE_APP_CONTAINER="mintel-me-production-app-1"
REMOTE_APP_CONTAINER="mintel-me-production-mintel-me-app-1"
REMOTE_SITE_DIR="/home/deploy/sites/mintel.me"
;;
branch-*)
local SLUG=${TARGET#branch-}
REMOTE_PROJECT="mintel-me-branch-$SLUG"
REMOTE_DB_CONTAINER="${REMOTE_PROJECT}-postgres-db-1"
REMOTE_APP_CONTAINER="${REMOTE_PROJECT}-app-1"
REMOTE_APP_CONTAINER="${REMOTE_PROJECT}-mintel-me-app-1"
REMOTE_SITE_DIR="/home/deploy/sites/branch.mintel.me/$SLUG"
;;
*)

View File

@@ -0,0 +1,127 @@
/**
* Index all published blog posts into Qdrant for AI search.
*
* Usage: pnpm --filter @mintel/web run index:posts
*/
import { getPayload } from 'payload';
import configPromise from '../payload.config';
import { upsertPostVector } from '../src/lib/qdrant';
function extractPlainText(node: any): string {
if (!node) return '';
// Handle text nodes
if (typeof node === 'string') return node;
if (node.text) return node.text;
// Handle arrays
if (Array.isArray(node)) {
return node.map(extractPlainText).join('');
}
// Handle node with children
if (node.children) {
const childText = node.children.map(extractPlainText).join('');
// Add line breaks for block-level elements
if (['paragraph', 'heading', 'listitem', 'quote'].includes(node.type)) {
return childText + '\n';
}
return childText;
}
// Lexical root
if (node.root) {
return extractPlainText(node.root);
}
return '';
}
async function run() {
console.log('🔍 Starting blog post indexing for AI search...');
let payload;
let retries = 5;
while (retries > 0) {
try {
console.log(`Connecting to database (URI: ${process.env.DATABASE_URI || 'default'})...`);
payload = await getPayload({ config: configPromise });
break;
} catch (e: any) {
if (
e.code === 'ECONNREFUSED' ||
e.code === 'ENOTFOUND' ||
e.message?.includes('ECONNREFUSED') ||
e.message?.includes('cannot connect to Postgres')
) {
console.log(`Database not ready, retrying in 3s... (${retries} retries left)`);
retries--;
await new Promise((res) => setTimeout(res, 3000));
} else {
throw e;
}
}
}
if (!payload) {
throw new Error('Failed to connect to database after multiple retries.');
}
// Fetch all published posts
const result = await payload.find({
collection: 'posts',
limit: 1000,
where: {
_status: { equals: 'published' },
},
});
console.log(`Found ${result.docs.length} published posts to index.`);
let indexed = 0;
for (const post of result.docs) {
const plainContent = extractPlainText(post.content);
// Build searchable text: title + description + tags + content
const tags = (post.tags as any[])?.map((t: any) => t.tag).filter(Boolean).join(', ') || '';
const searchableText = [
`Titel: ${post.title}`,
`Beschreibung: ${post.description}`,
tags ? `Tags: ${tags}` : '',
`Inhalt: ${plainContent.substring(0, 2000)}`, // Limit content to avoid token overflow
]
.filter(Boolean)
.join('\n\n');
// Upsert into Qdrant
await upsertPostVector(
post.id,
searchableText,
{
content: searchableText,
data: {
id: post.id,
title: post.title,
slug: post.slug,
description: post.description,
tags,
},
},
);
indexed++;
console.log(` ✅ [${indexed}/${result.docs.length}] ${post.title}`);
// Small delay to avoid rate limiting on the embedding API
await new Promise((res) => setTimeout(res, 200));
}
console.log(`\n🎉 Successfully indexed ${indexed} posts into Qdrant.`);
process.exit(0);
}
run().catch((e) => {
console.error('Indexing failed:', e);
process.exit(1);
});

View File

@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useSafePathname } from "./analytics/useSafePathname";
import * as React from "react";
import { AISearchResults } from "./search/AISearchResults";
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
@@ -11,6 +12,19 @@ export const Header: React.FC = () => {
const pathname = useSafePathname();
const [isScrolled, setIsScrolled] = React.useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
const [isAISearchOpen, setIsAISearchOpen] = React.useState(false);
// Cmd+K to open AI search
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setIsAISearchOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
React.useEffect(() => {
const handleScroll = () => {
@@ -50,8 +64,8 @@ export const Header: React.FC = () => {
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
<div
className={`absolute inset-0 transition-all duration-500 -z-10 ${isScrolled
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
}`}
/>
@@ -95,8 +109,8 @@ export const Header: React.FC = () => {
key={link.href}
href={link.href}
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${active
? "text-slate-900"
: "text-slate-400 hover:text-slate-900"
? "text-slate-900"
: "text-slate-400 hover:text-slate-900"
}`}
>
{active && (
@@ -108,6 +122,17 @@ export const Header: React.FC = () => {
</Link>
);
})}
<button
onClick={() => setIsAISearchOpen(true)}
className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-400 hover:text-slate-900 transition-all duration-300 flex items-center gap-1.5 cursor-pointer"
title="AI Suche (⌘K)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
AI
</button>
<Link
href="/contact"
className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
@@ -246,8 +271,8 @@ export const Header: React.FC = () => {
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${active
? "bg-slate-50 border-slate-200 ring-1 ring-slate-200"
: "bg-white border-slate-100 active:bg-slate-50"
? "bg-slate-50 border-slate-200 ring-1 ring-slate-200"
: "bg-white border-slate-100 active:bg-slate-50"
}`}
>
<div>
@@ -307,6 +332,12 @@ export const Header: React.FC = () => {
</React.Fragment>
)}
</AnimatePresence>
{/* AI Search Modal */}
<AISearchResults
isOpen={isAISearchOpen}
onClose={() => setIsAISearchOpen(false)}
/>
</header>
);
};

View File

@@ -1,12 +1,62 @@
import { RichText } from "@payloadcms/richtext-lexical/react";
import {
RichText,
defaultJSXConverters,
} from "@payloadcms/richtext-lexical/react";
import type { JSXConverters } from "@payloadcms/richtext-lexical/react";
import { MemeCard } from "@/src/components/MemeCard";
import { Mermaid } from "@/src/components/Mermaid";
import { LeadMagnet } from "@/src/components/LeadMagnet";
import { ComparisonRow } from "@/src/components/Landing/ComparisonRow";
import { mdxComponents } from "../content-engine/components";
import React from "react";
/**
* Renders markdown-style inline links [text](/url) as <a> tags.
* Used by mintelP blocks which store body text with links.
*/
function renderInlineMarkdown(text: string): React.ReactNode {
if (!text) return null;
const parts = text.split(/(\[[^\]]+\]\([^)]+\)|<Marker>[^<]*<\/Marker>)/);
return parts.map((part, i) => {
const linkMatch = part.match(/\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
return (
<a
key={i}
href={linkMatch[2]}
className="text-slate-900 underline underline-offset-4 hover:text-slate-600 transition-colors"
>
{linkMatch[1]}
</a>
);
}
const markerMatch = part.match(/<Marker>([^<]*)<\/Marker>/);
if (markerMatch) {
return (
<mark key={i} className="bg-yellow-100/60 px-1 rounded">
{markerMatch[1]}
</mark>
);
}
return <React.Fragment key={i}>{part}</React.Fragment>;
});
}
const jsxConverters: JSXConverters = {
...defaultJSXConverters,
// Override paragraph to filter out leftover <TableOfContents /> raw text
paragraph: ({ node, nodesToJSX }: any) => {
const children = node?.children;
if (
children?.length === 1 &&
children[0]?.type === "text" &&
children[0]?.text?.trim()?.startsWith("<") &&
children[0]?.text?.trim()?.endsWith("/>")
) {
return null; // suppress raw JSX component text like <TableOfContents />
}
return <p>{nodesToJSX({ nodes: children })}</p>;
},
blocks: {
memeCard: ({ node }: any) => (
<div className="my-8">
@@ -49,6 +99,15 @@ const jsxConverters: JSXConverters = {
showShare={true}
/>
),
// --- Core text blocks ---
mintelP: ({ node }: any) => (
<p className="text-base md:text-lg text-slate-600 leading-relaxed mb-6">
{renderInlineMarkdown(node.fields.text)}
</p>
),
mintelTldr: ({ node }: any) => (
<mdxComponents.TLDR>{node.fields.content}</mdxComponents.TLDR>
),
// --- MDX Registry Injections ---
leadParagraph: ({ node }: any) => (
<mdxComponents.LeadParagraph>
@@ -81,37 +140,46 @@ const jsxConverters: JSXConverters = {
/>
),
diagramState: ({ node }: any) => (
<mdxComponents.DiagramState
states={[]}
transitions={[]}
caption={node.fields.definition}
/>
<div className="my-8">
<Mermaid id={`diagram-state-${node.fields.id || Date.now()}`}>
{node.fields.definition}
</Mermaid>
</div>
),
diagramTimeline: ({ node }: any) => (
<mdxComponents.DiagramTimeline
events={[]}
title={node.fields.definition}
/>
<div className="my-8">
<Mermaid id={`diagram-timeline-${node.fields.id || Date.now()}`}>
{node.fields.definition}
</Mermaid>
</div>
),
diagramGantt: ({ node }: any) => (
<mdxComponents.DiagramGantt tasks={[]} title={node.fields.definition} />
<div className="my-8">
<Mermaid id={`diagram-gantt-${node.fields.id || Date.now()}`}>
{node.fields.definition}
</Mermaid>
</div>
),
diagramPie: ({ node }: any) => (
<mdxComponents.DiagramPie data={[]} title={node.fields.definition} />
<div className="my-8">
<Mermaid id={`diagram-pie-${node.fields.id || Date.now()}`}>
{node.fields.definition}
</Mermaid>
</div>
),
diagramSequence: ({ node }: any) => (
<mdxComponents.DiagramSequence
participants={[]}
steps={[]}
title={node.fields.definition}
/>
<div className="my-8">
<Mermaid id={`diagram-seq-${node.fields.id || Date.now()}`}>
{node.fields.definition}
</Mermaid>
</div>
),
diagramFlow: ({ node }: any) => (
<mdxComponents.DiagramFlow
nodes={[]}
edges={[]}
title={node.fields.definition}
/>
<div className="my-8">
<Mermaid id={`diagram-flow-${node.fields.id || Date.now()}`}>
{node.fields.definition}
</Mermaid>
</div>
),
waterfallChart: ({ node }: any) => (
@@ -128,12 +196,22 @@ const jsxConverters: JSXConverters = {
),
iconList: ({ node }: any) => (
<mdxComponents.IconList>
{node.fields.items?.map((item: any, i: number) => (
// @ts-ignore
<mdxComponents.IconListItem key={i} icon={item.icon || "check"}>
{item.description}
</mdxComponents.IconListItem>
))}
{node.fields.items?.map((item: any, i: number) => {
const isCheck = item.icon === "check" || !item.icon;
const isCross = item.icon === "x" || item.icon === "cross";
const isBullet = item.icon === "circle" || item.icon === "bullet";
return (
// @ts-ignore
<mdxComponents.IconListItem
key={i}
check={isCheck}
cross={isCross}
bullet={isBullet}
>
{item.title || item.description}
</mdxComponents.IconListItem>
);
})}
</mdxComponents.IconList>
),
statsGrid: ({ node }: any) => {
@@ -159,8 +237,8 @@ const jsxConverters: JSXConverters = {
<mdxComponents.Carousel
items={
node.fields.slides?.map((s: any) => ({
title: s.caption || "Image",
content: "",
title: s.title || s.caption || "Slide",
content: s.content || s.caption || "",
icon: undefined,
})) || []
}

View File

@@ -0,0 +1,554 @@
'use client';
import { useState, useRef, useEffect, useCallback, KeyboardEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { initialState, FormState } from '../../logic/pricing';
import {
PAGE_SAMPLES,
FEATURE_OPTIONS,
FUNCTION_OPTIONS,
API_OPTIONS,
ASSET_OPTIONS,
} from '../../logic/pricing/constants';
import { sendContactInquiry } from '../../actions/contact';
// Widgets
import { SelectionGrid } from './widgets/SelectionGrid';
import { DesignPicker } from './widgets/DesignPicker';
import { FileDropzone } from './widgets/FileDropzone';
import { ContactFields } from './widgets/ContactFields';
import { TimelinePicker } from './widgets/TimelinePicker';
import { EstimatePreview } from './widgets/EstimatePreview';
// AI Orb
import AIOrb from '../search/AIOrb';
interface ToolCall {
id: string;
name: string;
arguments: Record<string, any>;
}
interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
toolCalls?: ToolCall[];
rawToolCalls?: any[];
tool_call_id?: string;
}
export function AgentChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [formState, setFormState] = useState<FormState>({ ...initialState } as FormState);
const [honeypot, setHoneypot] = useState('');
const [isSubmitted, setIsSubmitted] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isLoading]);
// Auto-focus input
useEffect(() => {
inputRef.current?.focus();
}, [isLoading]);
// Track which widgets are locked (already interacted with)
const [lockedWidgets, setLockedWidgets] = useState<Set<string>>(new Set());
const lockWidget = (messageId: string) => {
setLockedWidgets((prev) => new Set([...prev, messageId]));
};
const updateFormState = useCallback((updates: Partial<FormState>) => {
setFormState((prev) => ({ ...prev, ...updates }));
}, []);
const genId = () => Math.random().toString(36).substring(2, 10);
// Send message to agent API
const sendMessage = async (userMessage?: string) => {
const msgText = userMessage || input.trim();
if (!msgText && messages.length > 0) return;
setError(null);
setIsLoading(true);
// Add user message
const userMsg: ChatMessage = {
id: genId(),
role: 'user',
content: msgText || 'Hallo!',
};
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput('');
try {
// Build API messages (exclude widget rendering details)
const apiMessages = newMessages.map((m) => ({
role: m.role,
content: m.content,
...(m.rawToolCalls ? { tool_calls: m.rawToolCalls } : {}),
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
}));
const res = await fetch('/api/agent-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: apiMessages,
formState,
honeypot,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'API request failed');
}
// Process tool calls to update FormState
const toolCalls: ToolCall[] = data.toolCalls || [];
for (const tc of toolCalls) {
processToolCall(tc);
}
// Add assistant message
const assistantMsg: ChatMessage = {
id: genId(),
role: 'assistant',
content: data.message || '',
toolCalls,
rawToolCalls: data.rawToolCalls,
};
setMessages((prev) => [...prev, assistantMsg]);
// If there are tool calls, we need to send tool results back
if (toolCalls.length > 0 && data.rawToolCalls?.length > 0) {
// Auto-acknowledge tool calls
const toolResultMessages = toolCalls.map((tc) => ({
id: genId(),
role: 'tool' as const,
content: JSON.stringify({ status: 'ok', tool: tc.name }),
tool_call_id: tc.id,
}));
setMessages((prev) => [...prev, ...toolResultMessages]);
}
} catch (err: any) {
console.error('Agent chat error:', err);
setError(err.message || 'Ein Fehler ist aufgetreten.');
} finally {
setIsLoading(false);
}
};
// Process tool calls to update form state
const processToolCall = (tc: ToolCall) => {
switch (tc.name) {
case 'update_company_info':
updateFormState({
...(tc.arguments.companyName && { companyName: tc.arguments.companyName }),
...(tc.arguments.name && { name: tc.arguments.name }),
...(tc.arguments.employeeCount && { employeeCount: tc.arguments.employeeCount }),
...(tc.arguments.existingWebsite && { existingWebsite: tc.arguments.existingWebsite }),
});
break;
case 'update_project_type':
updateFormState({ projectType: tc.arguments.projectType });
break;
case 'show_page_selector':
if (tc.arguments.preselected?.length) {
updateFormState({ selectedPages: tc.arguments.preselected });
}
break;
case 'show_feature_selector':
if (tc.arguments.preselected?.length) {
updateFormState({ features: tc.arguments.preselected });
}
break;
case 'show_function_selector':
if (tc.arguments.preselected?.length) {
updateFormState({ functions: tc.arguments.preselected });
}
break;
case 'show_api_selector':
if (tc.arguments.preselected?.length) {
updateFormState({ apiSystems: tc.arguments.preselected });
}
break;
case 'show_asset_selector':
if (tc.arguments.preselected?.length) {
updateFormState({ assets: tc.arguments.preselected });
}
break;
case 'show_design_picker':
if (tc.arguments.preselected) {
updateFormState({ designVibe: tc.arguments.preselected });
}
break;
case 'show_timeline_picker':
if (tc.arguments.preselected) {
updateFormState({ deadline: tc.arguments.preselected });
}
break;
case 'submit_inquiry':
handleSubmitInquiry();
break;
}
};
// Submit inquiry
const handleSubmitInquiry = async () => {
try {
const result = await sendContactInquiry({
name: formState.name,
email: formState.email,
companyName: formState.companyName,
projectType: formState.projectType,
message: formState.message || 'Agent-gestützte Anfrage',
isFreeText: false,
config: formState,
});
if (result.success) {
setIsSubmitted(true);
}
} catch (e) {
console.error('Submit error:', e);
}
};
// Render tool call as widget
const renderToolCallWidget = (tc: ToolCall, messageId: string) => {
const isLocked = lockedWidgets.has(`${messageId}-${tc.name}`);
const widgetKey = `${messageId}-${tc.name}`;
switch (tc.name) {
case 'show_page_selector':
return (
<SelectionGrid
key={widgetKey}
title="Seiten"
options={PAGE_SAMPLES.map((p) => ({ id: p.id, label: p.label, desc: p.desc }))}
selected={formState.selectedPages}
onSelectionChange={(selected) => {
updateFormState({ selectedPages: selected });
}}
locked={isLocked}
/>
);
case 'show_feature_selector':
return (
<SelectionGrid
key={widgetKey}
title="Features"
options={FEATURE_OPTIONS.map((f) => ({ id: f.id, label: f.label, desc: f.desc }))}
selected={formState.features}
onSelectionChange={(selected) => {
updateFormState({ features: selected });
}}
locked={isLocked}
/>
);
case 'show_function_selector':
return (
<SelectionGrid
key={widgetKey}
title="Funktionen"
options={FUNCTION_OPTIONS.map((f) => ({ id: f.id, label: f.label, desc: f.desc }))}
selected={formState.functions}
onSelectionChange={(selected) => {
updateFormState({ functions: selected });
}}
locked={isLocked}
/>
);
case 'show_api_selector':
return (
<SelectionGrid
key={widgetKey}
title="Integrationen"
options={API_OPTIONS.map((a) => ({ id: a.id, label: a.label, desc: a.desc }))}
selected={formState.apiSystems}
onSelectionChange={(selected) => {
updateFormState({ apiSystems: selected });
}}
locked={isLocked}
/>
);
case 'show_asset_selector':
return (
<SelectionGrid
key={widgetKey}
title="Vorhandene Assets"
options={ASSET_OPTIONS.map((a) => ({ id: a.id, label: a.label, desc: a.desc }))}
selected={formState.assets}
onSelectionChange={(selected) => {
updateFormState({ assets: selected });
}}
locked={isLocked}
/>
);
case 'show_design_picker':
return (
<DesignPicker
key={widgetKey}
selected={formState.designVibe}
onSelect={(id) => updateFormState({ designVibe: id })}
locked={isLocked}
/>
);
case 'show_timeline_picker':
return (
<TimelinePicker
key={widgetKey}
selected={formState.deadline}
onSelect={(id) => updateFormState({ deadline: id })}
locked={isLocked}
/>
);
case 'show_contact_fields':
return (
<ContactFields
key={widgetKey}
email={formState.email}
setEmail={(v) => updateFormState({ email: v })}
message={formState.message}
setMessage={(v) => updateFormState({ message: v })}
locked={isLocked}
/>
);
case 'request_file_upload':
return (
<FileDropzone
key={widgetKey}
label={tc.arguments.label || 'Dateien hochladen'}
files={formState.contactFiles || []}
onFilesAdded={(files) => {
updateFormState({
contactFiles: [...(formState.contactFiles || []), ...files],
});
}}
locked={isLocked}
/>
);
case 'show_estimate_preview':
return <EstimatePreview key={widgetKey} formState={formState} />;
case 'generate_estimate_pdf':
return (
<div key={widgetKey} className="w-full">
<div className="flex items-center gap-3 p-4 rounded-xl bg-slate-50 border border-slate-200">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-slate-500 shrink-0">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
<polyline points="16 13 12 17 8 13" />
<line x1="12" y1="12" x2="12" y2="17" />
</svg>
<div className="flex-1">
<p className="text-sm font-bold text-slate-900">PDF-Angebot</p>
<p className="text-[10px] text-slate-500">
Das Angebot wird nach Absenden der Anfrage erstellt und zugesendet.
</p>
</div>
</div>
</div>
);
default:
return null;
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
// Start conversation automatically
useEffect(() => {
if (messages.length === 0) {
sendMessage('Hallo!');
}
}, []);
// Success view
if (isSubmitted) {
return (
<div className="w-full min-h-[60vh] flex flex-col items-center justify-center text-center space-y-8 p-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="w-20 h-20 bg-green-500 rounded-full flex items-center justify-center"
>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
</motion.div>
<div className="space-y-2">
<h2 className="text-3xl font-black text-slate-900 tracking-tight">Anfrage gesendet!</h2>
<p className="text-sm text-slate-500 max-w-md">
Marc wird sich in Kürze bei dir unter <strong>{formState.email}</strong> melden.
</p>
</div>
<button
onClick={() => {
setIsSubmitted(false);
setMessages([]);
setFormState({ ...initialState } as FormState);
setLockedWidgets(new Set());
}}
className="text-sm font-bold text-slate-400 underline hover:text-slate-900 transition-colors"
>
Neue Anfrage starten
</button>
</div>
);
}
return (
<div className="w-full max-w-4xl mx-auto flex flex-col" style={{ minHeight: '70vh' }}>
{/* Header */}
<div className="flex items-center gap-3 pb-6 mb-6 border-b border-slate-100">
<AIOrb isThinking={isLoading} size="sm" />
<div>
<h2 className="text-sm font-black tracking-tight text-slate-900 uppercase">
Projekt-Assistent
</h2>
<p className="text-[10px] font-mono text-slate-400 tracking-wider">
AI-GESTÜTZTE BERATUNG
</p>
</div>
</div>
{/* Chat Messages */}
<div className="flex-1 space-y-6 overflow-y-auto pb-6">
<AnimatePresence mode="popLayout">
{messages
.filter((m) => m.role !== 'tool')
.map((msg) => (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] space-y-3 ${msg.role === 'user'
? 'bg-slate-900 text-white rounded-2xl rounded-tr-sm p-4'
: ''
}`}
>
{/* Text content */}
{msg.content && (
<div
className={
msg.role === 'assistant'
? 'text-sm text-slate-700 leading-relaxed whitespace-pre-wrap'
: 'text-sm leading-relaxed'
}
>
{msg.role === 'user' && msg.content === 'Hallo!'
? null
: msg.content}
</div>
)}
{/* Tool call widgets */}
{msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="space-y-3 mt-2">
{msg.toolCalls
.filter((tc) => !['update_company_info', 'update_project_type', 'submit_inquiry'].includes(tc.name))
.map((tc) => renderToolCallWidget(tc, msg.id))}
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
{/* Loading indicator */}
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex justify-start"
>
<div className="flex items-center gap-3 px-2">
<AIOrb isThinking={true} size="sm" />
<span className="text-xs text-slate-400 font-mono animate-pulse">
denkt nach...
</span>
</div>
</motion.div>
)}
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-600 rounded-xl border border-red-100 text-xs font-bold">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
{error}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="pt-4 border-t border-slate-100 mt-auto">
<div className="relative flex items-center rounded-xl border border-slate-200 bg-white transition-all focus-within:border-slate-900 focus-within:ring-1 focus-within:ring-slate-900">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Beschreibe dein Projekt..."
disabled={isLoading}
className="flex-1 bg-transparent border-none text-sm p-4 focus:outline-none text-slate-900 placeholder:text-slate-300"
/>
<input
type="text"
className="hidden"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
<button
onClick={() => sendMessage()}
disabled={!input.trim() || isLoading}
className="p-4 transition-all shrink-0 cursor-pointer disabled:opacity-30 text-slate-400 hover:text-slate-900"
aria-label="Nachricht senden"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
</button>
</div>
<p className="text-center text-[10px] text-slate-300 mt-2 font-mono tracking-wider">
Enter zum Senden
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { cn } from '../../../utils/cn';
interface ContactFieldsProps {
email: string;
setEmail: (val: string) => void;
message: string;
setMessage: (val: string) => void;
locked?: boolean;
}
export function ContactFields({
email,
setEmail,
message,
setMessage,
locked = false,
}: ContactFieldsProps) {
return (
<div className="space-y-3 w-full">
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
Kontaktdaten
</h4>
<div className="space-y-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ihre@email.de"
disabled={locked}
className={cn(
'w-full px-4 py-3 rounded-xl border text-sm font-medium transition-all focus:outline-none',
'bg-white border-slate-200 text-slate-900 placeholder:text-slate-300',
'focus:border-slate-900 focus:ring-1 focus:ring-slate-900',
locked && 'opacity-60',
)}
/>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Optionale Nachricht oder weitere Details..."
rows={3}
disabled={locked}
className={cn(
'w-full px-4 py-3 rounded-xl border text-sm font-medium transition-all focus:outline-none resize-none',
'bg-white border-slate-200 text-slate-900 placeholder:text-slate-300',
'focus:border-slate-900 focus:ring-1 focus:ring-slate-900',
locked && 'opacity-60',
)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { cn } from '../../../utils/cn';
interface DesignOption {
id: string;
label: string;
desc: string;
}
const DESIGN_STYLES: DesignOption[] = [
{ id: 'minimal', label: 'Minimalistisch', desc: 'Viel Weißraum, klare Typografie.' },
{ id: 'bold', label: 'Mutig & Laut', desc: 'Starke Kontraste, große Schriften.' },
{ id: 'nature', label: 'Natürlich', desc: 'Sanfte Erdtöne, organische Formen.' },
{ id: 'tech', label: 'Technisch', desc: 'Präzise Linien, dunkle Akzente.' },
];
interface DesignPickerProps {
selected: string;
onSelect: (id: string) => void;
locked?: boolean;
}
export function DesignPicker({ selected, onSelect, locked = false }: DesignPickerProps) {
return (
<div className="space-y-3 w-full">
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
Design-Stil
</h4>
<div className="grid grid-cols-2 gap-3">
{DESIGN_STYLES.map((style) => {
const isSelected = selected === style.id;
return (
<button
key={style.id}
onClick={() => !locked && onSelect(style.id)}
disabled={locked}
className={cn(
'relative flex flex-col p-4 rounded-xl border text-left transition-all duration-200 cursor-pointer',
isSelected
? 'bg-slate-900 border-slate-800 text-white shadow-lg ring-2 ring-slate-700'
: 'bg-white border-slate-200 text-slate-700 hover:border-slate-400',
locked && 'opacity-60 cursor-default',
)}
>
{/* Mini illustration */}
<div
className={cn(
'w-full h-12 rounded-lg mb-3 overflow-hidden',
isSelected ? 'bg-slate-800' : 'bg-slate-50',
)}
>
{style.id === 'minimal' && (
<div className="p-2 space-y-1">
<div className={cn('h-1 w-3/4 rounded', isSelected ? 'bg-slate-600' : 'bg-slate-200')} />
<div className={cn('h-1 w-1/2 rounded', isSelected ? 'bg-slate-700' : 'bg-slate-100')} />
</div>
)}
{style.id === 'bold' && (
<div className="p-2 space-y-1">
<div className={cn('h-3 w-full rounded', isSelected ? 'bg-slate-600' : 'bg-slate-300')} />
<div className={cn('h-3 w-full rounded', isSelected ? 'bg-slate-600' : 'bg-slate-300')} />
</div>
)}
{style.id === 'nature' && (
<div className="flex items-center justify-center h-full">
<div className={cn('w-8 h-8 rounded-full', isSelected ? 'bg-emerald-800' : 'bg-emerald-100')} />
<div className={cn('w-6 h-6 rounded-full -ml-2', isSelected ? 'bg-emerald-700' : 'bg-emerald-50')} />
</div>
)}
{style.id === 'tech' && (
<div className="p-2 grid grid-cols-2 gap-1 h-full">
<div className={cn('rounded border', isSelected ? 'border-slate-600' : 'border-slate-200')} />
<div className={cn('rounded border', isSelected ? 'border-slate-600' : 'border-slate-200')} />
</div>
)}
</div>
<span className="text-sm font-bold">{style.label}</span>
<span className={cn('text-[10px] mt-0.5', isSelected ? 'text-slate-400' : 'text-slate-400')}>
{style.desc}
</span>
{isSelected && (
<div className="absolute top-2 right-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="text-green-400">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import { PRICING } from '../../../logic/pricing/constants';
import { calculateTotals } from '@mintel/pdf';
interface EstimatePreviewProps {
formState: any;
}
export function EstimatePreview({ formState }: EstimatePreviewProps) {
const totals = calculateTotals(formState, PRICING);
const items = [
{ label: 'Seiten', value: totals.totalPagesCount },
{ label: 'Features', value: totals.totalFeatures },
{ label: 'Funktionen', value: totals.totalFunctions },
{ label: 'Integrationen', value: totals.totalApis },
{ label: 'Sprachen', value: totals.languagesCount },
].filter((i) => i.value > 0);
return (
<div className="w-full rounded-xl border border-slate-200 overflow-hidden bg-white">
<div className="p-4 bg-slate-900 text-white">
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400 mb-1">
Kostenübersicht
</h4>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-black tracking-tight">
{totals.totalPrice.toLocaleString('de-DE')}
</span>
<span className="text-sm font-bold text-slate-400"> netto</span>
</div>
{totals.monthlyPrice > 0 && (
<p className="text-xs text-slate-400 mt-1">
+ {totals.monthlyPrice.toLocaleString('de-DE')} / Monat (Hosting & Betrieb)
</p>
)}
</div>
{items.length > 0 && (
<div className="p-4 grid grid-cols-3 gap-3">
{items.map((item) => (
<div key={item.label} className="text-center">
<p className="text-xl font-black text-slate-900">{item.value}</p>
<p className="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-wider">
{item.label}
</p>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { useCallback, useState } from 'react';
import { cn } from '../../../utils/cn';
interface FileDropzoneProps {
label?: string;
onFilesAdded: (files: File[]) => void;
files: File[];
locked?: boolean;
}
export function FileDropzone({
label = 'Dateien hier ablegen',
onFilesAdded,
files,
locked = false,
}: FileDropzoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (locked) return;
const droppedFiles = Array.from(e.dataTransfer.files);
onFilesAdded(droppedFiles);
},
[onFilesAdded, locked],
);
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (locked) return;
const selectedFiles = Array.from(e.target.files || []);
onFilesAdded(selectedFiles);
},
[onFilesAdded, locked],
);
return (
<div className="space-y-2 w-full">
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
className={cn(
'relative border-2 border-dashed rounded-xl p-6 text-center transition-all duration-200 cursor-pointer',
isDragOver
? 'border-slate-900 bg-slate-50'
: 'border-slate-200 bg-white hover:border-slate-400',
locked && 'opacity-60 cursor-default',
)}
>
<input
type="file"
multiple
onChange={handleFileInput}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
disabled={locked}
/>
<div className="space-y-2">
<svg
className="mx-auto text-slate-300"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p className="text-xs font-bold text-slate-500">{label}</p>
<p className="text-[10px] text-slate-400">
Oder klicken zum Auswählen
</p>
</div>
</div>
{files.length > 0 && (
<div className="space-y-1">
{files.map((file, i) => (
<div
key={i}
className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400 shrink-0"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span className="text-xs text-slate-600 font-medium truncate">{file.name}</span>
<span className="text-[10px] text-slate-400 shrink-0">
{(file.size / 1024).toFixed(0)} KB
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { cn } from '../../../utils/cn';
interface SelectionOption {
id: string;
label: string;
desc?: string;
}
interface SelectionGridProps {
title: string;
options: SelectionOption[];
selected: string[];
onSelectionChange: (selected: string[]) => void;
locked?: boolean;
}
export function SelectionGrid({
title,
options,
selected,
onSelectionChange,
locked = false,
}: SelectionGridProps) {
const toggle = (id: string) => {
if (locked) return;
const next = selected.includes(id)
? selected.filter((s) => s !== id)
: [...selected, id];
onSelectionChange(next);
};
return (
<div className="space-y-3 w-full">
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
{title}
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{options.map((opt) => {
const isSelected = selected.includes(opt.id);
return (
<button
key={opt.id}
onClick={() => toggle(opt.id)}
disabled={locked}
className={cn(
'group relative flex flex-col items-start p-3 rounded-xl border text-left transition-all duration-200 cursor-pointer',
isSelected
? 'bg-slate-900 border-slate-800 text-white shadow-md'
: 'bg-white border-slate-200 text-slate-700 hover:border-slate-400 hover:shadow-sm',
locked && 'opacity-60 cursor-default',
)}
>
<span className="text-xs font-bold tracking-tight">{opt.label}</span>
{opt.desc && (
<span
className={cn(
'text-[10px] mt-0.5 line-clamp-2',
isSelected ? 'text-slate-400' : 'text-slate-400',
)}
>
{opt.desc}
</span>
)}
{isSelected && (
<div className="absolute top-2 right-2">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</button>
);
})}
</div>
{selected.length > 0 && (
<p className="text-[10px] font-mono text-slate-400 mt-1">
{selected.length} ausgewählt
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { cn } from '../../../utils/cn';
const TIMELINE_OPTIONS = [
{ id: 'asap', label: 'So schnell wie möglich', icon: '⚡' },
{ id: '2-3-months', label: 'In 23 Monaten', icon: '📅' },
{ id: '3-6-months', label: 'In 36 Monaten', icon: '🗓️' },
{ id: 'flexible', label: 'Flexibel', icon: '🔄' },
];
interface TimelinePickerProps {
selected: string;
onSelect: (id: string) => void;
locked?: boolean;
}
export function TimelinePicker({ selected, onSelect, locked = false }: TimelinePickerProps) {
return (
<div className="space-y-3 w-full">
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
Zeitrahmen
</h4>
<div className="grid grid-cols-2 gap-2">
{TIMELINE_OPTIONS.map((opt) => {
const isSelected = selected === opt.id;
return (
<button
key={opt.id}
onClick={() => !locked && onSelect(opt.id)}
disabled={locked}
className={cn(
'flex items-center gap-2 p-3 rounded-xl border text-left transition-all duration-200 cursor-pointer',
isSelected
? 'bg-slate-900 border-slate-800 text-white shadow-md'
: 'bg-white border-slate-200 text-slate-700 hover:border-slate-400',
locked && 'opacity-60 cursor-default',
)}
>
<span className="text-lg">{opt.icon}</span>
<span className="text-xs font-bold">{opt.label}</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import React from 'react';
interface AIOrbProps {
isThinking: boolean;
size?: 'sm' | 'md' | 'lg';
}
export default function AIOrb({ isThinking = false, size = 'md' }: AIOrbProps) {
const sizeMap = {
sm: { container: 'w-8 h-8', orb: 'w-5 h-5' },
md: { container: 'w-16 h-16', orb: 'w-10 h-10' },
lg: { container: 'w-24 h-24', orb: 'w-16 h-16' },
};
const s = sizeMap[size];
return (
<div className={`${s.container} relative flex items-center justify-center`}>
{/* Ambient glow */}
<div
className={`absolute inset-0 rounded-full blur-xl transition-all duration-1000 ${isThinking
? 'bg-gradient-to-br from-emerald-400/60 to-cyan-400/40 animate-pulse'
: 'bg-gradient-to-br from-slate-400/30 to-slate-300/20'
}`}
/>
{/* Orb */}
<div
className={`${s.orb} rounded-full relative z-10 transition-all duration-700 ${isThinking
? 'bg-gradient-to-br from-emerald-400 via-cyan-400 to-blue-500 shadow-lg shadow-emerald-400/40'
: 'bg-gradient-to-br from-slate-500 via-slate-400 to-slate-300 shadow-md shadow-slate-400/20'
}`}
style={{
animation: isThinking
? 'ai-orb-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite, ai-orb-rotate 3s linear infinite'
: 'ai-orb-float 4s ease-in-out infinite',
}}
>
{/* Inner highlight */}
<div
className={`absolute inset-[15%] rounded-full transition-all duration-700 ${isThinking
? 'bg-gradient-to-br from-white/40 to-transparent'
: 'bg-gradient-to-br from-white/25 to-transparent'
}`}
/>
</div>
<style jsx>{`
@keyframes ai-orb-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
@keyframes ai-orb-rotate {
from { filter: hue-rotate(0deg); }
to { filter: hue-rotate(360deg); }
}
@keyframes ai-orb-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,419 @@
'use client';
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import Link from 'next/link';
import AIOrb from './AIOrb';
interface PostMatch {
id: string;
title: string;
slug: string;
description: string;
tags?: string;
}
interface Message {
role: 'user' | 'assistant';
content: string;
posts?: PostMatch[];
}
interface ComponentProps {
isOpen: boolean;
onClose: () => void;
initialQuery?: string;
triggerSearch?: boolean;
}
export function AISearchResults({
isOpen,
onClose,
initialQuery = '',
triggerSearch = false,
}: ComponentProps) {
const [query, setQuery] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [honeypot, setHoneypot] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
setTimeout(() => inputRef.current?.focus(), 100);
if (triggerSearch && initialQuery && messages.length === 0) {
setQuery(initialQuery);
handleSearch(initialQuery);
} else if (!triggerSearch) {
setQuery('');
}
} else {
document.body.style.overflow = 'unset';
setQuery('');
setMessages([]);
setError(null);
setIsLoading(false);
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen, triggerSearch]);
useEffect(() => {
if (isOpen && initialQuery && messages.length === 0) {
setQuery(initialQuery);
}
}, [initialQuery, isOpen]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isLoading]);
// Keyboard shortcut: Cmd+K to open
useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (!isOpen) {
// Parent handles opening
} else {
inputRef.current?.focus();
}
}
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleGlobalKeyDown);
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
}, [isOpen, onClose]);
const handleSearch = async (searchQuery: string = query) => {
if (!searchQuery.trim() || isLoading) return;
const newUserMessage: Message = { role: 'user', content: searchQuery };
const newMessagesContext = [...messages, newUserMessage];
setMessages(newMessagesContext);
setQuery('');
setIsLoading(true);
setError(null);
try {
const res = await fetch('/api/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: newMessagesContext,
honeypot,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to fetch search results');
}
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: data.answerText,
posts: data.posts,
},
]);
setTimeout(() => inputRef.current?.focus(), 100);
} catch (err: any) {
console.error(err);
setError(err.message || 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.');
} finally {
setIsLoading(false);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen) return null;
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 transition-all duration-300"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
style={{
backgroundColor: 'rgba(15, 15, 15, 0.95)',
backdropFilter: 'blur(20px)',
animation: 'ai-modal-fade-in 0.3s ease-out',
}}
>
<div
ref={modalRef}
className="relative w-full max-w-4xl overflow-hidden flex flex-col"
style={{
height: '75vh',
background: 'linear-gradient(180deg, rgba(30, 30, 30, 0.95) 0%, rgba(20, 20, 20, 0.98) 100%)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: '1.5rem',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.8)',
animation: 'ai-modal-slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
{/* Header */}
<div
className="p-4 md:p-6 flex items-center justify-between relative z-10"
style={{
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
background: 'rgba(15, 15, 15, 0.8)',
}}
>
<div className="flex items-center gap-3">
<AIOrb isThinking={isLoading} size="sm" />
<h2
className="font-bold tracking-widest uppercase text-sm"
style={{ color: 'rgba(255, 255, 255, 0.9)' }}
>
AI Assistent
</h2>
</div>
<button
onClick={onClose}
className="transition-colors p-2 rounded-lg hover:bg-white/5"
style={{ color: 'rgba(255, 255, 255, 0.4)' }}
aria-label="Schließen"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* Chat History */}
<div className="flex-1 overflow-y-auto p-4 md:p-8 relative space-y-6 scroll-smooth">
{messages.length === 0 && !isLoading && !error && (
<div
className="flex flex-col items-center justify-center h-full text-center space-y-4"
style={{ opacity: 0.5, animation: 'ai-modal-fade-in 0.5s ease-out 0.2s both' }}
>
<AIOrb isThinking={false} size="lg" />
<p className="text-xl md:text-2xl font-bold mt-6" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>
Wie kann ich helfen?
</p>
<p className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.5)' }}>
Frag mich zu Blog-Themen, Technologien oder unseren Services.
</p>
</div>
)}
{messages.map((msg, index) => (
<div
key={index}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className="max-w-[85%] rounded-2xl p-5"
style={{
...(msg.role === 'user'
? {
background: 'linear-gradient(135deg, #333 0%, #222 100%)',
color: '#fff',
borderTopRightRadius: '0.25rem',
}
: {
background: 'rgba(255, 255, 255, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.06)',
color: 'rgba(255, 255, 255, 0.9)',
borderTopLeftRadius: '0.25rem',
}),
}}
>
{msg.role === 'assistant' && (
<h3
className="text-xs font-bold tracking-widest uppercase mb-2 flex items-center gap-1"
style={{ color: 'rgba(255, 255, 255, 0.4)' }}
>
<AIOrb isThinking={false} size="sm" />
AI Assistent
</h3>
)}
<div className="text-base leading-relaxed whitespace-pre-wrap">
{msg.content}
</div>
{/* Post matches */}
{msg.role === 'assistant' && msg.posts && msg.posts.length > 0 && (
<div
className="mt-6 space-y-3 pt-4"
style={{ borderTop: '1px solid rgba(255, 255, 255, 0.08)' }}
>
<h4
className="text-xs font-bold tracking-widest uppercase"
style={{ color: 'rgba(255, 255, 255, 0.35)' }}
>
Relevante Artikel
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{msg.posts.map((post, idx) => (
<Link
key={idx}
href={`/blog/${post.slug}`}
onClick={onClose}
className="group flex flex-col justify-between rounded-lg p-4 transition-all duration-300 hover:-translate-y-0.5"
style={{
background: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.08)',
}}
>
<div>
{post.tags && (
<p
className="text-[10px] font-bold tracking-wider mb-1"
style={{ color: 'rgba(255, 255, 255, 0.35)' }}
>
{post.tags}
</p>
)}
<h5 className="text-sm font-extrabold mb-1 line-clamp-2 transition-colors" style={{ color: '#fff' }}>
{post.title}
</h5>
<p className="text-xs line-clamp-2" style={{ color: 'rgba(255, 255, 255, 0.5)' }}>
{post.description}
</p>
</div>
<div className="flex items-center justify-end mt-2">
<span
className="text-[10px] font-bold tracking-widest uppercase transition-colors"
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
>
Lesen
</span>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="rounded-2xl p-2 w-20 flex justify-center">
<AIOrb isThinking={true} size="md" />
</div>
</div>
)}
{error && (
<div
className="flex items-start space-x-4 p-4 rounded-xl mt-4"
style={{
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
}}
>
<div>
<h3 className="text-sm font-bold" style={{ color: 'rgba(239, 68, 68, 0.8)' }}>Fehler</h3>
<p className="text-xs mt-1" style={{ color: 'rgba(239, 68, 68, 0.6)' }}>{error}</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div
className="p-4 md:p-6"
style={{
borderTop: '1px solid rgba(255, 255, 255, 0.06)',
background: 'rgba(15, 15, 15, 0.8)',
}}
>
<div
className="relative flex items-center rounded-xl transition-all"
style={{
background: 'rgba(255, 255, 255, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.08)',
}}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Stelle eine Frage..."
className="flex-1 bg-transparent border-none text-base md:text-lg p-4 focus:outline-none"
style={{
color: 'rgba(255, 255, 255, 0.9)',
}}
disabled={isLoading}
/>
<input
type="text"
className="hidden"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
<button
onClick={() => handleSearch()}
disabled={!query.trim() || isLoading}
className="p-4 transition-colors shrink-0 cursor-pointer disabled:opacity-30"
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
aria-label="Nachricht senden"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
</button>
</div>
<div className="text-center mt-3">
<span
className="text-[10px] uppercase tracking-widest font-bold"
style={{ color: 'rgba(255, 255, 255, 0.2)' }}
>
Enter zum Senden Esc zum Schließen
</span>
</div>
</div>
</div>
<style jsx>{`
@keyframes ai-modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ai-modal-slide-up {
from { opacity: 0; transform: translateY(20px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
</div>
);
}

View File

@@ -1,14 +1,16 @@
"use client";
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { ComponentShareButton } from '../ComponentShareButton';
import { Reveal } from '../Reveal';
import { Play, RotateCcw } from 'lucide-react';
import { RotateCcw } from 'lucide-react';
export function LoadTimeSimulator({ className = '' }: { className?: string }) {
const [isRunning, setIsRunning] = useState(false);
const [timeElapsed, setTimeElapsed] = useState(0);
const [legacyState, setLegacyState] = useState(0);
const [hasAutoStarted, setHasAutoStarted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [mintelState, setMintelState] = useState(0);
useEffect(() => {
@@ -36,6 +38,25 @@ export function LoadTimeSimulator({ className = '' }: { className?: string }) {
return () => clearInterval(interval);
}, [isRunning, timeElapsed]);
// Auto-start the race when scrolled into viewport
useEffect(() => {
if (hasAutoStarted) return;
const el = containerRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setHasAutoStarted(true);
setIsRunning(true);
observer.disconnect();
}
},
{ threshold: 0.4 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasAutoStarted]);
const startRace = () => {
setTimeElapsed(0);
setLegacyState(0);
@@ -45,7 +66,7 @@ export function LoadTimeSimulator({ className = '' }: { className?: string }) {
return (
<Reveal direction="up" delay={0.1}>
<div className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
<div ref={containerRef} className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
<div className="absolute -inset-1 bg-gradient-to-r from-red-100 to-emerald-100 rounded-3xl blur opacity-30" />
<div id="sim-load-time" className="relative bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
@@ -63,13 +84,15 @@ export function LoadTimeSimulator({ className = '' }: { className?: string }) {
Simulieren Sie den Unterschied zwischen dynamischem Server-Rendering (PHP/MySQL) und statischer Edge-Auslieferung (<span className="font-mono bg-slate-200 px-1 rounded text-[10px]">TTV &lt; 500ms</span>).
</p>
</div>
<button
onClick={startRace}
className="shrink-0 flex items-center gap-2 px-6 py-2.5 bg-slate-900 !text-white rounded-full font-bold text-sm hover:hover:bg-black hover:scale-105 active:scale-95 transition-all shadow-md"
>
{timeElapsed > 0 ? <RotateCcw size={16} /> : <Play size={16} />}
{timeElapsed > 0 ? "Neustart" : "Rennen Starten"}
</button>
{timeElapsed > 0 && !isRunning && (
<button
onClick={startRace}
className="shrink-0 flex items-center gap-2 px-6 py-2.5 bg-slate-900 !text-white rounded-full font-bold text-sm hover:hover:bg-black hover:scale-105 active:scale-95 transition-all shadow-md"
>
<RotateCcw size={16} />
Neustart
</button>
)}
</div>
<div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-100 bg-slate-50/50">

138
apps/web/src/lib/qdrant.ts Normal file
View File

@@ -0,0 +1,138 @@
import { QdrantClient } from '@qdrant/js-client-rest';
import * as os from 'os';
const isDockerContainer =
process.env.IS_DOCKER === 'true' || os.hostname().includes('mintel-me') || process.env.HOSTNAME?.includes('mintel-me');
let qdrantUrl = process.env.QDRANT_URL || 'http://localhost:6333';
if (isDockerContainer && qdrantUrl.includes('localhost')) {
qdrantUrl = qdrantUrl.replace('localhost', 'mintel-qdrant');
}
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
export const qdrant = new QdrantClient({
url: qdrantUrl,
apiKey: qdrantApiKey || undefined,
});
export const COLLECTION_NAME = 'mintel_posts';
export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
/**
* Ensure the collection exists in Qdrant.
*/
export async function ensureCollection() {
try {
const collections = await qdrant.getCollections();
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
if (!exists) {
await qdrant.createCollection(COLLECTION_NAME, {
vectors: {
size: VECTOR_SIZE,
distance: 'Cosine',
},
});
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
}
} catch (error) {
console.error('Error ensuring Qdrant collection:', error);
}
}
/**
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
*/
export async function generateEmbedding(text: string): Promise<number[]> {
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
throw new Error('OPENROUTER_API_KEY is not set');
}
const response = await fetch('https://openrouter.ai/api/v1/embeddings', {
method: 'POST',
headers: {
Authorization: `Bearer ${openRouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me',
'X-Title': 'Mintel.me AI Search',
},
body: JSON.stringify({
model: 'openai/text-embedding-3-small',
input: text,
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`,
);
}
const data = await response.json();
return data.data[0].embedding;
}
/**
* Upsert a post into Qdrant
*/
export async function upsertPostVector(
id: string | number,
text: string,
payload: Record<string, any>,
) {
try {
await ensureCollection();
const vector = await generateEmbedding(text);
await qdrant.upsert(COLLECTION_NAME, {
wait: true,
points: [
{
id,
vector,
payload,
},
],
});
} catch (error) {
console.error('Error writing to Qdrant:', error);
}
}
/**
* Delete a post from Qdrant
*/
export async function deletePostVector(id: string | number) {
try {
await ensureCollection();
await qdrant.delete(COLLECTION_NAME, {
wait: true,
points: [id] as [string | number],
});
} catch (error) {
console.error('Error deleting from Qdrant:', error);
}
}
/**
* Search posts in Qdrant
*/
export async function searchPosts(query: string, limit = 5) {
try {
await ensureCollection();
const vector = await generateEmbedding(query);
const results = await qdrant.search(COLLECTION_NAME, {
vector,
limit,
with_payload: true,
});
return results;
} catch (error) {
console.error('Error searching in Qdrant:', error);
return [];
}
}

25
apps/web/src/lib/redis.ts Normal file
View File

@@ -0,0 +1,25 @@
import Redis from 'ioredis';
import * as os from 'os';
const isDockerContainer =
process.env.IS_DOCKER === 'true' || os.hostname().includes('mintel-me') || process.env.HOSTNAME?.includes('mintel-me');
let redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
if (isDockerContainer && redisUrl.includes('localhost')) {
redisUrl = redisUrl.replace('localhost', 'mintel-redis');
}
// Only create a single instance in Node.js
const globalForRedis = global as unknown as { redis: Redis };
export const redis =
globalForRedis.redis ||
new Redis(redisUrl, {
maxRetriesPerRequest: 3,
});
if (process.env.NODE_ENV !== 'production') {
globalForRedis.redis = redis;
}
export default redis;

View File

@@ -11,12 +11,6 @@ export const ArchitectureBuilderBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "ArchitectureBuilder",
description:
"Interactive comparison between a standard SaaS rental approach and a custom Built-First (Mintel) architecture. Useful for articles discussing digital ownership, software rent vs. build, or technological assets. Requires no props.",
usageExample: "'<ArchitectureBuilder />'",
},
fields: [
{
name: "preset",

View File

@@ -11,12 +11,6 @@ export const ArticleBlockquoteBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "ArticleBlockquote",
description: "Styled blockquote for expert quotes or key statements.",
usageExample:
"'<ArticleBlockquote>\n Performance ist keine IT-Kennzahl, sondern ein ökonomischer Hebel.\n</ArticleBlockquote>'",
},
fields: [
{
name: "quote",

View File

@@ -10,13 +10,6 @@ export const ArticleMemeBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "ArticleMeme",
description:
"Real image-based meme from the media library. Use for static screenshots or custom memes that are not available via memegen.link.",
usageExample:
'<ArticleMeme image="/media/my-meme.png" alt="Sarcastic dev meme" caption="When the code finally builds." />',
},
fields: [
{
name: "image",

View File

@@ -11,13 +11,6 @@ export const ButtonBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "Button",
description:
"DEPRECATED: Use <LeadMagnet /> instead for main CTAs. Only use for small secondary links.",
usageExample:
'<Button href="/contact" variant="outline">Webprojekt anfragen</Button>',
},
fields: [
{
name: "label",

View File

@@ -11,18 +11,21 @@ export const CarouselBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "Carousel",
description:
"Interactive swipeable slider for multi-step explanations. IMPORTANT: items array must contain at least 2 items with substantive title and content text (no empty content).",
usageExample:
'\'<Carousel items={[{ title: "Schritt 1", content: "Analyse der aktuellen Performance..."',
},
fields: [
{
name: "slides",
type: "array",
fields: [
{
name: "title",
type: "text",
admin: { description: "Titel der Slide-Karte." },
},
{
name: "content",
type: "textarea",
admin: { description: "Beschreibungstext der Slide-Karte." },
},
{
name: "image",
type: "upload",

View File

@@ -11,12 +11,6 @@ export const DiagramFlowBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "DiagramFlow",
description:
"Mermaid flowchart diagram defining the graph structure. MUST output raw mermaid code, no quotes or HTML.",
usageExample: "graph TD\\n A[Start] --> B[End]",
},
fields: [
{
name: "definition",

View File

@@ -11,12 +11,6 @@ export const DiagramGanttBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "DiagramGantt",
description: "Mermaid Gantt timeline chart. MUST output raw mermaid code.",
usageExample:
"gantt\\n title Project Roadmap\\n dateFormat YYYY-MM-DD\\n Section Design\\n Draft UI :a1, 2024-01-01, 7d",
},
fields: [
{
name: "definition",

View File

@@ -11,11 +11,6 @@ export const DiagramPieBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "DiagramPie",
description: "Mermaid pie chart diagram. MUST output raw mermaid code.",
usageExample: 'pie title Market Share\\n "Chrome" : 60\\n "Safari" : 20',
},
fields: [
{
name: "definition",

View File

@@ -11,13 +11,6 @@ export const DiagramSequenceBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "DiagramSequence",
description:
"Mermaid sequence diagram showing actor interactions. MUST output raw mermaid code.",
usageExample:
"sequenceDiagram\\n Client->>Server: GET /api\\n Server-->>Client: 200 OK",
},
fields: [
{
name: "definition",

View File

@@ -11,12 +11,6 @@ export const DiagramStateBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "DiagramState",
description:
"Mermaid state diagram showing states and transitions. MUST output raw mermaid code.",
usageExample: "stateDiagram-v2\\n [*] --> Idle\\n Idle --> Loading",
},
fields: [
{
name: "definition",

View File

@@ -11,13 +11,6 @@ export const DiagramTimelineBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "DiagramTimeline",
description:
"Mermaid timeline or journey diagram. MUST output raw mermaid code.",
usageExample:
"timeline\\n title Project Timeline\\n 2024\\n : Q1 : Planning\\n : Q2 : Execution",
},
fields: [
{
name: "definition",

View File

@@ -11,12 +11,6 @@ export const DigitalAssetVisualizerBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "DigitalAssetVisualizer",
description:
"Interactive visualization illustrating the financial difference between software as a liability (SaaS/rent) and software as a digital asset (Custom IP). Great for articles concerning CTO strategies, business value of code, and digital independence. Requires no props.",
usageExample: "'<DigitalAssetVisualizer />'",
},
fields: [
{
name: "assetId",

View File

@@ -16,13 +16,6 @@ export const FAQSectionBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "FAQSection",
description:
"Semantic wrapper for FAQ questions at the end of the article. Put standard Markdown H3/Paragraphs inside.",
usageExample:
"'<FAQSection>\n <H3>Frage 1</H3>\n <Paragraph>Antwort 1</Paragraph>\n</FAQSection>'",
},
fields: [
{
name: "content",

View File

@@ -11,12 +11,6 @@ export const ImageTextBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "ImageText",
description: "Layout component for image next to explanatory text.",
usageExample:
'\'<ImageText image="/img.jpg" title="Architektur">Erklärung...</ImageText>\'',
},
fields: [
{
name: "image",

View File

@@ -11,13 +11,6 @@ export const LinkedInEmbedBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "LinkedInEmbed",
description:
"Embeds a professional post from LinkedIn. Use the activity URN (e.g. urn:li:activity:1234567890).",
usageExample:
"'<LinkedInEmbed urn=\"urn:li:activity:7153664326573674496\" />'",
},
fields: [
{
name: "url",

View File

@@ -11,12 +11,6 @@ export const LoadTimeSimulatorBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "LoadTimeSimulator",
description:
"Interactive visual race simulating the loading experience of a slow legacy CMS vs a fast headless stack. Great for articles discussing load times, technical debt, or user frustration. Requires no props.",
usageExample: "'<LoadTimeSimulator />'",
},
fields: [
{
name: "initialLoadTime",

View File

@@ -11,12 +11,6 @@ export const MarkerBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "Marker",
description:
"Inline highlight (yellow marker effect) for emphasizing key phrases within paragraphs.",
usageExample: "'<Marker>entscheidender Wettbewerbsvorteil</Marker>'",
},
fields: [
{
name: "text",

View File

@@ -13,7 +13,7 @@ export const MemeCardBlock: MintelBlock = {
ai: {
name: "MemeCard",
description:
'Real meme image from memegen.link. template must be a valid memegen.link ID. IMPORTANT: Captions must be EXTREMELY SARCASTIC and PUNCHY (mocking bad B2B agencies, max 6 words per line). Best templates: drake (2-line prefer/dislike), gru (4-step plan backfire), disastergirl (burning house), fine (this is fine dog). Use German captions. Wrap in div with className="my-8".',
"Real meme from memegen.link. ONLY use these templates: drake, distracted-bf, change-my-mind, uno-draw-25, always-has-been. Captions MUST be in German, extremely sarcastic, max 6 words per line. Use pipe | to separate caption lines. Use sparingly: MAX 1 meme per article.",
usageExample: `<div className="my-8">
<MemeCard template="drake" captions="47 WordPress Plugins installieren|Eine saubere Serverless Architektur" />
</div>`,

View File

@@ -11,12 +11,6 @@ export const MetricBarBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "MetricBar",
description:
"Animated horizontal progress bar. Use multiple in sequence to compare metrics. IMPORTANT: value MUST be a real number > 0, never use 0 or placeholder values. Props: label, value (number), max (default 100), unit (default %), color (red|green|blue|slate).",
usageExample: '<MetricBar label="WordPress Sites" value={33',
},
fields: [
{
name: "label",

View File

@@ -11,13 +11,6 @@ export const PerformanceChartBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "PerformanceChart",
description:
"A visual chart illustrating performance metrics (e.g. PageSpeed, TTFB) over time or in comparison. Use to emphasize technical improvements.",
usageExample:
'<PerformanceChart items={[{ label: "Vorher", value: 12 }, { label: "Nachher", value: 98 }]} />',
},
fields: [
{
name: "title",

View File

@@ -11,12 +11,6 @@ export const PerformanceROICalculatorBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "PerformanceROICalculator",
description:
"Interactive simulation calculator showing the monetary ROI of improving load times (based on Deloitte B2B metrics). Use exactly once in performance-related articles to provide a highly engaging simulation. Requires no props.",
usageExample: "'<PerformanceROICalculator />'",
},
fields: [
{
name: "baseConversionRate",

View File

@@ -11,13 +11,6 @@ export const PremiumComparisonChartBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "PremiumComparisonChart",
description:
"Advanced chart for comparing performance metrics with industrial aesthetics.",
usageExample:
'\'<PremiumComparisonChart title="TTFB Vergleich" items={[{ label: "Alt", value: 800, max: 1000, color: "red"',
},
fields: [
{
name: "title",

View File

@@ -12,13 +12,6 @@ export const RevealBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "Reveal",
description:
"Scroll-triggered reveal animation wrapper. Wrap any content to animate on scroll.",
usageExample:
'\'<Reveal>\n <StatsDisplay value="100" label="PageSpeed Score" />\n</Reveal>\'',
},
fields: [
{
name: "direction",

View File

@@ -11,12 +11,6 @@ export const RevenueLossCalculatorBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "RevenueLossCalculator",
description:
"Interactive calculator that estimates financial loss due to slow page load times. Use to build a business case for performance optimization.",
usageExample: "<RevenueLossCalculator />",
},
fields: [
{
name: "title",

View File

@@ -12,12 +12,6 @@ export const SectionBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "Section",
description: "Wraps a thematic section block with optional heading.",
usageExample:
"'<Section>\n <h3>Section Title</h3>\n <p>Content here.</p>\n</Section>'",
},
fields: [
{
name: "title",

View File

@@ -11,13 +11,6 @@ export const StatsDisplayBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "StatsDisplay",
description:
"A single large stat card with prominent value, label, and optional subtext.",
usageExample:
'\'<StatsDisplay value="-20%" label="Conversion" subtext="Jede Sekunde Verzögerung kostet." />\'',
},
fields: [
{
name: "label",

View File

@@ -11,13 +11,6 @@ export const StatsGridBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "StatsGrid",
description:
"Grid of 24 stat cards in a row. Use tilde (~) to separate stats, pipe (|) to separate value|label|subtext within each stat.",
usageExample:
"'<StatsGrid stats=\"53%|Mehr Umsatz|Rakuten 24~33%|Conversion Boost|nach CWV Fix~24%|Top 3 Ranking|bei bestandenen CWV\" />'",
},
fields: [
{
name: "stats",

View File

@@ -11,13 +11,6 @@ export const TrackedLinkBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "TrackedLink",
description:
"A wrapper around next/link that tracks clicks. Use for all INTERNAL navigational links that should be tracked.",
usageExample:
'\'<TrackedLink href="/contact" className="text-blue-600 font-bold">Jetzt anfragen</TrackedLink>\'',
},
fields: [
{
name: "href",

View File

@@ -11,13 +11,6 @@ export const TwitterEmbedBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "TwitterEmbed",
description:
"Embeds a post from X.com (Twitter). Used to provide social proof, industry quotes, or examples. Provide the numerical tweetId.",
usageExample:
'\'<TwitterEmbed tweetId="1753464161943834945" theme="light" />\'',
},
fields: [
{
name: "url",

View File

@@ -11,15 +11,6 @@ export const WaterfallChartBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "WaterfallChart",
description:
"A timeline visualization of network requests (waterfall). Use to show loading sequences or bottlenecks. Labels auto-color coded by type (JS, HTML, IMG).",
usageExample: `<WaterfallChart
title="Initial Load"
events={[
{ name: "Document", start: 0, duration: 150`,
},
fields: [
{
name: "title",

View File

@@ -11,12 +11,6 @@ export const WebVitalsScoreBlock: MintelBlock = {
admin: {
group: "MDX Components",
},
ai: {
name: "WebVitalsScore",
description:
"Displays Core Web Vitals (LCP, INP, CLS) in a premium card layout with automatic traffic light coloring (Good/Needs Improvement/Poor). Use for performance audits or comparisons.",
usageExample: "'<WebVitalsScore values={{ lcp: 2.5, inp: 200, cls: 0.1",
},
fields: [
{
name: "lcp",

View File

@@ -4,6 +4,7 @@
*/
import type { CacheAdapter, CacheConfig } from './interfaces';
import * as os from 'os';
export class RedisCacheAdapter implements CacheAdapter {
private client: any = null;
@@ -14,7 +15,13 @@ export class RedisCacheAdapter implements CacheAdapter {
constructor(config: CacheConfig & { redisUrl?: string } = {}) {
this.prefix = config.prefix || 'mintel:';
this.defaultTTL = config.defaultTTL || 3600;
this.redisUrl = config.redisUrl || process.env.REDIS_URL || 'redis://localhost:6379';
let url = config.redisUrl || process.env.REDIS_URL || 'redis://localhost:6379';
const isDocker = process.env.IS_DOCKER === 'true' || os.hostname().includes('mintel-me') || process.env.HOSTNAME?.includes('mintel-me');
if (isDocker && url.includes('localhost')) {
url = url.replace('localhost', 'mintel-redis');
}
this.redisUrl = url;
}
private async init(): Promise<boolean> {
@@ -23,7 +30,7 @@ export class RedisCacheAdapter implements CacheAdapter {
try {
const Redis = await import('ioredis');
this.client = new Redis.default(this.redisUrl);
this.client.on('error', (err: Error) => {
console.warn('Redis connection error:', err.message);
this.client = null;

View File

@@ -26,12 +26,15 @@ services:
env_file:
- .env
environment:
- IS_DOCKER=true
- NODE_ENV=development
- NEXT_TELEMETRY_DISABLED=1
# - CI=true
- NPM_TOKEN=${NPM_TOKEN:-}
- DATABASE_URI=postgres://${postgres_DB_USER:-payload}:${postgres_DB_PASSWORD:-payload}@postgres-db:5432/${postgres_DB_NAME:-payload}
- PAYLOAD_SECRET=dev-secret
- QDRANT_URL=http://mintel-qdrant:6333
- REDIS_URL=redis://mintel-redis:6379
command: >
sh -c "pnpm install --no-frozen-lockfile && pnpm --filter @mintel/web dev"
@@ -61,6 +64,24 @@ services:
ports:
- "54321:5432"
mintel-redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- default
ports:
- "6379:6379"
mintel-qdrant:
image: qdrant/qdrant:v1.13.2
restart: unless-stopped
volumes:
- mintel_qdrant_data:/qdrant/storage
networks:
- default
ports:
- "26333:6333"
networks:
default:
name: ${PROJECT_NAME:-mintel-me}-internal
@@ -71,3 +92,4 @@ volumes:
node_modules:
apps_node_modules:
pnpm_store:
mintel_qdrant_data:

View File

@@ -7,6 +7,10 @@ services:
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
- IS_DOCKER=true
- QDRANT_URL=http://mintel-qdrant:6333
- REDIS_URL=redis://mintel-redis:6379
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
@@ -55,7 +59,7 @@ services:
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
gatekeeper:
profiles: ["gatekeeper"]
profiles: [ "gatekeeper" ]
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
container_name: ${PROJECT_NAME:-mintel-me}-gatekeeper
restart: always
@@ -93,6 +97,22 @@ services:
volumes:
- payload-db-data:/var/lib/postgresql/data
mintel-redis:
image: redis:7-alpine
restart: always
networks:
- default
mintel-qdrant:
image: qdrant/qdrant:v1.13.2
restart: always
volumes:
- mintel_qdrant_data:/qdrant/storage
networks:
- default
ports:
- "26333:6333"
networks:
default:
name: ${PROJECT_NAME:-mintel-me}-internal
@@ -103,4 +123,5 @@ networks:
volumes:
payload-db-data:
external: true
name: mintel-me_payload-db-data
name: ${PROJECT_NAME:-mintel-me}_payload-db-data
mintel_qdrant_data:

View File

@@ -52,7 +52,8 @@
"pnpm": {
"overrides": {
"@sentry/nextjs": "10.38.0",
"eslint": "10.0.0"
"eslint": "10.0.0",
"zod": "^3.23.8"
},
"onlyBuiltDependencies": [
"@parcel/watcher",

21265
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff