Compare commits
4 Commits
ec013a32a2
...
v2.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| a2cf9791ae | |||
| aa4e3aab4f | |||
| ce719a1d70 | |||
| bd2f92125b |
38
.env
Normal file
38
.env
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
||||||
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
|
LOG_LEVEL=info
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||||
|
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||||
|
|
||||||
|
# SMTP Configuration
|
||||||
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=postmaster@mg.mintel.me
|
||||||
|
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
||||||
|
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
||||||
|
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Payload Infrastructure (Dockerized)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
||||||
|
# by docker-compose.yml using these base DB credentials, so you don't need to
|
||||||
|
# manually write the connection strings here.
|
||||||
|
PAYLOAD_DB_NAME=payload
|
||||||
|
PAYLOAD_DB_USER=payload
|
||||||
|
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Hetzner S3 Object Storage
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||||
|
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
|
||||||
|
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
|
||||||
|
S3_BUCKET=mintel
|
||||||
|
S3_REGION=fsn1
|
||||||
|
S3_PREFIX=klz-cables
|
||||||
@@ -261,12 +261,6 @@ jobs:
|
|||||||
# Analytics
|
# Analytics
|
||||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
|
||||||
# Search & AI
|
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
|
|
||||||
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
|
|
||||||
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
|
|
||||||
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -325,12 +319,6 @@ jobs:
|
|||||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
echo ""
|
echo ""
|
||||||
echo "# Search & AI"
|
|
||||||
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
|
||||||
echo "QDRANT_URL=$QDRANT_URL"
|
|
||||||
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
|
|
||||||
echo "REDIS_URL=$REDIS_URL"
|
|
||||||
echo ""
|
|
||||||
echo "TARGET=$TARGET"
|
echo "TARGET=$TARGET"
|
||||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -28,5 +28,3 @@ html-errors*.json
|
|||||||
reference/
|
reference/
|
||||||
# Database backups
|
# Database backups
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
.env
|
|
||||||
4
.npmrc
4
.npmrc
@@ -1,2 +1,2 @@
|
|||||||
@mintel:registry=https://npm.infra.mintel.me/
|
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
|
||||||
|
|||||||
@@ -322,6 +322,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[DEBUG PAGE] Slug: ${productSlug}, children count: ${descriptionChildren.length}`);
|
||||||
|
|
||||||
const descriptionContent = {
|
const descriptionContent = {
|
||||||
root: {
|
root: {
|
||||||
...product.content.root,
|
...product.content.root,
|
||||||
@@ -353,29 +355,31 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
sku={product.frontmatter.sku}
|
sku={product.frontmatter.sku}
|
||||||
/>
|
/>
|
||||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
{/* Background Decorative Elements */}
|
{/* Background Decorative Elements */}
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
<nav className="flex flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/${productsSlug}`}
|
href={`/${locale}/${productsSlug}`}
|
||||||
className="hover:text-accent transition-colors"
|
className="hover:text-accent transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
||||||
className="hover:text-accent transition-colors"
|
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate"
|
||||||
>
|
>
|
||||||
{categoryTitle}
|
{categoryTitle}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
<span className="text-white/90 truncate max-w-[140px] md:max-w-none">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||||
@@ -386,7 +390,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{t('englishVersion')}
|
{t('englishVersion')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-3 mb-8">
|
<div className="flex flex-wrap gap-2 mb-4 md:mb-8">
|
||||||
{product.frontmatter.categories.map((cat, idx) => (
|
{product.frontmatter.categories.map((cat, idx) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -397,10 +401,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Heading level={1} className="text-white mb-8 uppercase">
|
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase">
|
||||||
{product.frontmatter.title}
|
{product.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||||
{product.frontmatter.description}
|
{product.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,11 +418,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* Large Product Image Section */}
|
{/* Large Product Image Section */}
|
||||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="relative -mt-32 mb-32 animate-slide-up"
|
className="relative md:-mt-32 mb-8 md:mb-32 animate-slide-up"
|
||||||
style={{ animationDelay: '200ms' }}
|
style={{ animationDelay: '200ms' }}
|
||||||
>
|
>
|
||||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24">
|
||||||
<div className="relative w-full aspect-[21/9]">
|
<div className="relative w-full aspect-[4/3] md:aspect-[21/9]">
|
||||||
<Image
|
<Image
|
||||||
src={product.frontmatter.images[0]}
|
src={product.frontmatter.images[0]}
|
||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
@@ -453,10 +457,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20">
|
||||||
{/* Description Area Next to Sidebar */}
|
{/* Description Area Next to Sidebar */}
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
|
<div className="max-w-none prose prose-primary prose-base md:prose-lg xl:prose-xl mb-8 md:mb-16 pb-8 md:pb-16 border-b border-neutral-dark/5">
|
||||||
{descriptionChildren.length > 0 ? (
|
{descriptionChildren.length > 0 ? (
|
||||||
<PayloadRichText data={descriptionContent} />
|
<PayloadRichText data={descriptionContent} />
|
||||||
) : product.frontmatter.description ? (
|
) : product.frontmatter.description ? (
|
||||||
@@ -464,6 +468,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{product.frontmatter.description}
|
{product.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{product.application?.root?.children?.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<PayloadRichText data={product.application} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -472,7 +482,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full-width Technical Data Below */}
|
{/* Full-width Technical Data Below */}
|
||||||
<div className="mt-16 pt-16 border-t-0">
|
<div className="mt-8 md:mt-16 pt-8 md:pt-16 border-t-0">
|
||||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
||||||
<PayloadRichText data={technicalContent} />
|
<PayloadRichText data={technicalContent} />
|
||||||
</div>
|
</div>
|
||||||
@@ -530,7 +540,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products Section */}
|
{/* Related Products Section */}
|
||||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5">
|
||||||
<RelatedProducts
|
<RelatedProducts
|
||||||
currentSlug={productSlug}
|
currentSlug={productSlug}
|
||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { searchProducts } from '../../../src/lib/qdrant';
|
|
||||||
import redis from '../../../src/lib/redis';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
// Config and constants
|
|
||||||
const RATE_LIMIT_POINTS = 5; // 5 requests
|
|
||||||
const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute
|
|
||||||
|
|
||||||
const requestSchema = z.object({
|
|
||||||
query: z.string().min(1).max(500),
|
|
||||||
_honeypot: z.string().max(0).optional(), // Honeypot trap: must be empty
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
// 1. IP extraction for Rate Limiting
|
|
||||||
const forwardedFor = req.headers.get('x-forwarded-for');
|
|
||||||
const realIp = req.headers.get('x-real-ip');
|
|
||||||
const ip = forwardedFor?.split(',')[0] || realIp || 'anon';
|
|
||||||
const rateLimitKey = `rate_limit:ai_search:${ip}`;
|
|
||||||
|
|
||||||
// Redis Rate Limiting
|
|
||||||
try {
|
|
||||||
const current = await redis.incr(rateLimitKey);
|
|
||||||
if (current === 1) {
|
|
||||||
await redis.expire(rateLimitKey, RATE_LIMIT_DURATION);
|
|
||||||
}
|
|
||||||
if (current > RATE_LIMIT_POINTS) {
|
|
||||||
return NextResponse.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429 });
|
|
||||||
}
|
|
||||||
} catch (redisError) {
|
|
||||||
console.warn('Redis error during rate limiting:', redisError);
|
|
||||||
// Fallback: proceed if Redis is down, to maintain availability
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Validate request
|
|
||||||
const json = await req.json().catch(() => ({}));
|
|
||||||
const parseResult = requestSchema.safeParse(json);
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { query, _honeypot } = parseResult.data;
|
|
||||||
|
|
||||||
// 3. Honeypot check
|
|
||||||
// If the honeypot field has any content, this is a bot.
|
|
||||||
if (_honeypot && _honeypot.length > 0) {
|
|
||||||
// Return a fake success mask
|
|
||||||
return NextResponse.json({ answer: 'Searching...' }, { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Qdrant Context Retrieval
|
|
||||||
const searchResults = await searchProducts(query, 5);
|
|
||||||
|
|
||||||
// Build context block
|
|
||||||
const contextText = searchResults.map((res: any) => {
|
|
||||||
const payload = res.payload;
|
|
||||||
return `Product ID: ${payload?.id}
|
|
||||||
Name: ${payload?.title}
|
|
||||||
SKU: ${payload?.sku}
|
|
||||||
Description: ${payload?.description}
|
|
||||||
Slug: ${payload?.slug}
|
|
||||||
---`;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
// 5. OpenRouter Integration (gemini-3-flash-preview)
|
|
||||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
|
||||||
if (!openRouterKey) {
|
|
||||||
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt = `You are the KLZ Cables AI Search Assistant, an intelligent, helpful, and highly specialized assistant strictly for the KLZ Cables website.
|
|
||||||
Your primary goal is to help users find the correct industrial cables and products based ONLY on the context provided.
|
|
||||||
Follow these strict rules:
|
|
||||||
1. ONLY answer questions related to products, search queries, cables, or industrial electronics.
|
|
||||||
2. If the user asks a question entirely unrelated to products or the company (e.g., "What is the capital of France?", "Write a poem", "What is 2+2?"), REFUSE to answer it. Instead, reply with a funny, sarcastic, or humorous comment about how you only know about cables and wires.
|
|
||||||
3. Base your product answers strictly on the CONTEXT provided below. Do not hallucinate products.
|
|
||||||
4. Output your response as a valid JSON object matching this schema exactly, do not use Markdown codeblocks, output RAW JSON:
|
|
||||||
{
|
|
||||||
"answerText": "A friendly description or answer based on the search.",
|
|
||||||
"products": [
|
|
||||||
{ "id": "Context Product ID", "title": "Product Title", "sku": "Product SKU", "slug": "slug" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
If you find relevant products in the context, add them to the "products" array. If no products match, use an empty array.
|
|
||||||
|
|
||||||
CONTEXT:
|
|
||||||
${contextText}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const response = 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://klz-cables.com',
|
|
||||||
'X-Title': 'KLZ Cables Search AI',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'google/gemini-3-flash-preview',
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: query }
|
|
||||||
],
|
|
||||||
response_format: { type: "json_object" }
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = await response.text();
|
|
||||||
throw new Error(`OpenRouter error: ${response.status} ${errorBody}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const completion = await response.json();
|
|
||||||
const rawContent = completion.choices?.[0]?.message?.content;
|
|
||||||
|
|
||||||
let answerJson;
|
|
||||||
try {
|
|
||||||
// Remove any potential markdown json block markers
|
|
||||||
const sanitizedObjStr = rawContent.replace(/^```json\s*/, '').replace(/\s*```$/, '');
|
|
||||||
answerJson = JSON.parse(sanitizedObjStr);
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Failed to parse AI response:', rawContent);
|
|
||||||
answerJson = {
|
|
||||||
answerText: rawContent || "Sorry, I had trouble thinking about cables right now.",
|
|
||||||
products: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(answerJson);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('AI Search API Error:', error);
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasErrors = Object.values(checks).some((v) => v.startsWith('error'));
|
const hasErrors = Object.values(checks).some(v => v.startsWith('error'));
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
||||||
{ status: hasErrors ? 503 : 200 },
|
{ status: hasErrors ? 503 : 200 },
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
|
|
||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
@@ -16,14 +15,14 @@ export default function Footer() {
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
|
<footer className="bg-primary text-white py-14 md:py-24 relative overflow-hidden content-visibility-auto">
|
||||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<h2 className="sr-only">Footer Navigation</h2>
|
<h2 className="sr-only">Footer Navigation</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20">
|
||||||
{/* Brand Column */}
|
{/* Brand Column – full width on mobile */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-block group"
|
className="inline-block group"
|
||||||
@@ -68,9 +67,9 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links Columns */}
|
{/* Legal Column */}
|
||||||
<div className="lg:col-span-2">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('legal')}
|
{t('legal')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
@@ -122,8 +121,9 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
{/* Company Column */}
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('company')}
|
{t('company')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
@@ -190,9 +190,9 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Posts Column */}
|
{/* Recent Posts Column – full width on mobile */}
|
||||||
<div className="lg:col-span-4">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4">
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('recentPosts')}
|
{t('recentPosts')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-6 list-none m-0 p-0">
|
<ul className="space-y-6 list-none m-0 p-0">
|
||||||
@@ -243,7 +243,7 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 text-xs md:text-sm font-medium">
|
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
||||||
<p>{t('copyright', { year: currentYear })}</p>
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<Link
|
<Link
|
||||||
@@ -276,48 +276,6 @@ export default function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Brand & Quality Sub-Footer */}
|
|
||||||
<div className="pt-8 mt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-white/40 text-[10px] sm:text-xs">
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://mintel.me"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
|
||||||
target: 'mintel_agency',
|
|
||||||
location: 'sub_footer',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="hover:text-white/80 transition-colors flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
Website entwickelt von Marc Mintel
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap justify-center md:justify-end gap-x-6 gap-y-3">
|
|
||||||
<div className="flex items-center gap-1.5" title="SSL Secured">
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
|
||||||
<span>SSL Secured</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="Green Hosting">
|
|
||||||
<Leaf className="w-3.5 h-3.5" />
|
|
||||||
<span>Green Hosting</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="DSGVO Compliant">
|
|
||||||
<Lock className="w-3.5 h-3.5" />
|
|
||||||
<span>DSGVO Compliant</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="WCAG">
|
|
||||||
<Accessibility className="w-3.5 h-3.5" />
|
|
||||||
<span>WCAG</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="PageSpeed 90+">
|
|
||||||
<Zap className="w-3.5 h-3.5" />
|
|
||||||
<span>PageSpeed 90+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
</Container>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { cn } from './ui';
|
import { cn } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
import { Search } from 'lucide-react';
|
|
||||||
import { AISearchResults } from './search/AISearchResults';
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const t = useTranslations('Navigation');
|
const t = useTranslations('Navigation');
|
||||||
@@ -18,7 +16,6 @@ export default function Header() {
|
|||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
||||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
@@ -144,7 +141,8 @@ export default function Header() {
|
|||||||
{
|
{
|
||||||
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
||||||
isHomePage && !isScrolled && !isMobileMenuOpen,
|
isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||||
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
|
||||||
|
!isHomePage || isScrolled || isMobileMenuOpen,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -155,9 +153,7 @@ export default function Header() {
|
|||||||
<>
|
<>
|
||||||
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
<div
|
<div className="flex-shrink-0 group touch-target fill-mode-both">
|
||||||
className="flex-shrink-0 group touch-target fill-mode-both"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}`}
|
href={`/${currentLocale}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -277,19 +273,6 @@ export default function Header() {
|
|||||||
<div
|
<div
|
||||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsSearchOpen(true)}
|
|
||||||
className="hover:text-accent transition-colors p-2"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
|
||||||
<Search className="w-5 h-5 md:w-6 md:h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
|
||||||
style={{ animationDuration: '600ms', animationDelay: '800ms' }}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
@@ -352,120 +335,115 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-0 bg-primary z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
|
||||||
isMobileMenuOpen
|
|
||||||
? 'opacity-100 translate-y-0'
|
|
||||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
|
||||||
)}
|
|
||||||
id="mobile-menu"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t('menu')}
|
|
||||||
ref={mobileMenuRef}
|
|
||||||
inert={isMobileMenuOpen ? undefined : true}
|
|
||||||
>
|
|
||||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
|
||||||
{menuItems.map((item, idx) => (
|
|
||||||
<div
|
|
||||||
key={item.href}
|
|
||||||
className={cn(
|
|
||||||
'transition-all duration-500 transform',
|
|
||||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
|
||||||
)}
|
|
||||||
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
|
||||||
aria-current={
|
|
||||||
(
|
|
||||||
item.href === '/'
|
|
||||||
? pathname === `/${currentLocale}` || pathname === '/'
|
|
||||||
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
|
||||||
)
|
|
||||||
? 'page'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
setIsMobileMenuOpen(false);
|
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
|
||||||
label: item.label,
|
|
||||||
href: item.href,
|
|
||||||
location: 'mobile_menu',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
|
||||||
(item.href === '/'
|
|
||||||
? pathname === `/${currentLocale}` || pathname === '/'
|
|
||||||
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
|
||||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
|
||||||
)}
|
|
||||||
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href={getPathForLocale('en')}
|
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-6 bg-white/30" />
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href={getPathForLocale('de')}
|
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
|
||||||
>
|
|
||||||
DE
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full max-w-xs">
|
|
||||||
<Button
|
|
||||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
|
||||||
variant="accent"
|
|
||||||
size="lg"
|
|
||||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
|
||||||
>
|
|
||||||
{t('contact')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Branding */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'p-12 flex justify-center transition-all duration-700',
|
|
||||||
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
|
||||||
)}
|
|
||||||
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
|
||||||
>
|
|
||||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<AISearchResults
|
{/* Mobile Menu Overlay */}
|
||||||
isOpen={isSearchOpen}
|
<div
|
||||||
onClose={() => setIsSearchOpen(false)}
|
className={cn(
|
||||||
/>
|
'fixed inset-0 bg-primary/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
|
isMobileMenuOpen
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
|
)}
|
||||||
|
id="mobile-menu"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('menu')}
|
||||||
|
ref={mobileMenuRef}
|
||||||
|
inert={isMobileMenuOpen ? undefined : true}
|
||||||
|
>
|
||||||
|
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||||
|
{menuItems.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item.href}
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-500 transform',
|
||||||
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||||
|
)}
|
||||||
|
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
aria-current={
|
||||||
|
(
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||||
|
)
|
||||||
|
? 'page'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
location: 'mobile_menu',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
||||||
|
(item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
||||||
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||||
|
)}
|
||||||
|
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('en')}
|
||||||
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-white/30" />
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('de')}
|
||||||
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-xs">
|
||||||
|
<Button
|
||||||
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
{t('contact')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Branding */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-12 flex justify-center transition-all duration-700',
|
||||||
|
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
||||||
|
)}
|
||||||
|
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
||||||
|
>
|
||||||
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,95 +39,17 @@ import CTA from '@/components/home/CTA';
|
|||||||
const jsxConverters: JSXConverters = {
|
const jsxConverters: JSXConverters = {
|
||||||
...defaultJSXConverters,
|
...defaultJSXConverters,
|
||||||
// Let the default converters handle text nodes to preserve valid formatting
|
// Let the default converters handle text nodes to preserve valid formatting
|
||||||
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
|
||||||
text: ({ node }: any) => {
|
|
||||||
const text = node.text;
|
|
||||||
// Handle markdown-style lists embedded in text nodes from Markdown migration
|
|
||||||
if (text && text.includes('\n- ')) {
|
|
||||||
const parts = text.split('\n- ').filter((p: string) => p.trim() !== '');
|
|
||||||
// If first part doesn't start with "- ", it's a prefix paragraph
|
|
||||||
const startsWithDash = text.trimStart().startsWith('- ');
|
|
||||||
const prefix = startsWithDash ? null : parts.shift();
|
|
||||||
return (
|
|
||||||
<div className="my-4">
|
|
||||||
{prefix && (
|
|
||||||
<div dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
|
|
||||||
{!prefix.includes('<') ? prefix : undefined}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
|
||||||
{parts.map((item: string, i: number) => {
|
|
||||||
const cleanItem = item.trim();
|
|
||||||
if (cleanItem.includes('<')) {
|
|
||||||
return <li key={i} dangerouslySetInnerHTML={{ __html: cleanItem }} />;
|
|
||||||
}
|
|
||||||
return <li key={i}>{cleanItem}</li>;
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text && (text.includes('<') || text.includes('data-start'))) {
|
|
||||||
return <span dangerouslySetInnerHTML={{ __html: text }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle markdown-style links [text](url) from Markdown migration
|
|
||||||
if (text && /\[([^\]]+)\]\(([^)]+)\)/.test(text)) {
|
|
||||||
const parts: React.ReactNode[] = [];
|
|
||||||
const remaining = text;
|
|
||||||
let key = 0;
|
|
||||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
||||||
let match;
|
|
||||||
let lastIndex = 0;
|
|
||||||
while ((match = linkRegex.exec(remaining)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(<span key={key++}>{remaining.slice(lastIndex, match.index)}</span>);
|
|
||||||
}
|
|
||||||
parts.push(
|
|
||||||
<a
|
|
||||||
key={key++}
|
|
||||||
href={match[2]}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary underline decoration-primary/30 hover:decoration-primary transition-colors"
|
|
||||||
>
|
|
||||||
{match[1]}
|
|
||||||
</a>,
|
|
||||||
);
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
}
|
|
||||||
if (lastIndex < remaining.length) {
|
|
||||||
parts.push(<span key={key++}>{remaining.slice(lastIndex)}</span>);
|
|
||||||
}
|
|
||||||
return <>{parts}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle newlines in text nodes — convert to <br> for proper line breaks
|
|
||||||
if (text && text.includes('\n')) {
|
|
||||||
const lines = text.split('\n');
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{lines.map((line: string, i: number) => (
|
|
||||||
<span key={i}>
|
|
||||||
{line}
|
|
||||||
{i < lines.length - 1 && <br />}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.format === 1) return <strong key="bold">{text}</strong>;
|
|
||||||
if (node.format === 2) return <em key="italic">{text}</em>;
|
|
||||||
return <span key="text">{text}</span>;
|
|
||||||
},
|
|
||||||
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
||||||
paragraph: ({ children }: any) => (
|
paragraph: ({ node, nodesToJSX }: any) => {
|
||||||
<div className="mb-6 leading-relaxed text-text-secondary">{children}</div>
|
return (
|
||||||
),
|
<div className="mb-6 leading-relaxed text-text-secondary">
|
||||||
|
{nodesToJSX({ nodes: node.children })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
|
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
|
||||||
heading: ({ node, children }: any) => {
|
heading: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
const tag = node?.tag;
|
const tag = node?.tag;
|
||||||
if (tag === 'h1')
|
if (tag === 'h1')
|
||||||
return (
|
return (
|
||||||
@@ -151,7 +73,8 @@ const jsxConverters: JSXConverters = {
|
|||||||
);
|
);
|
||||||
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
|
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
|
||||||
},
|
},
|
||||||
list: ({ node, children }: any) => {
|
list: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
if (node?.listType === 'number') {
|
if (node?.listType === 'number') {
|
||||||
return (
|
return (
|
||||||
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
|
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
|
||||||
@@ -168,7 +91,8 @@ const jsxConverters: JSXConverters = {
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
listitem: ({ node, children }: any) => {
|
listitem: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
if (node?.checked != null) {
|
if (node?.checked != null) {
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center gap-3 mb-2 leading-relaxed">
|
<li className="flex items-center gap-3 mb-2 leading-relaxed">
|
||||||
@@ -184,12 +108,16 @@ const jsxConverters: JSXConverters = {
|
|||||||
}
|
}
|
||||||
return <li className="mb-2 leading-relaxed">{children}</li>;
|
return <li className="mb-2 leading-relaxed">{children}</li>;
|
||||||
},
|
},
|
||||||
quote: ({ children }: any) => (
|
quote: ({ node, nodesToJSX }: any) => {
|
||||||
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
{children}
|
return (
|
||||||
</blockquote>
|
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
|
||||||
),
|
{children}
|
||||||
link: ({ node, children }: any) => {
|
</blockquote>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
link: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
// Handling Payload CMS link nodes
|
// Handling Payload CMS link nodes
|
||||||
const href = node?.fields?.url || node?.url || '#';
|
const href = node?.fields?.url || node?.url || '#';
|
||||||
const newTab = node?.fields?.newTab || node?.newTab;
|
const newTab = node?.fields?.newTab || node?.newTab;
|
||||||
@@ -1090,6 +1018,10 @@ export default function PayloadRichText({
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (data.root?.children?.length > 0) {
|
||||||
|
console.log('[PayloadRichText DEBUG] received children', data.root.children.length);
|
||||||
|
}
|
||||||
|
|
||||||
const dynamicConverters: JSXConverters = {
|
const dynamicConverters: JSXConverters = {
|
||||||
...jsxConverters,
|
...jsxConverters,
|
||||||
blocks: {
|
blocks: {
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-16">
|
<div className="space-y-8 md:space-y-16">
|
||||||
{technicalItems.length > 0 && (
|
{technicalItems.length > 0 && (
|
||||||
<div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
|
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5">
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
General Data
|
General Data
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
<dl className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||||
{technicalItems.map((item, idx) => (
|
{technicalItems.map((item, idx) => (
|
||||||
<div key={idx} className="flex flex-col group">
|
<div key={idx} className="flex flex-col group">
|
||||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||||
@@ -72,7 +72,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
@@ -83,7 +83,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{table.metaItems.length > 0 && (
|
{table.metaItems.length > 0 && (
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
<dl className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8 mb-6 md:mb-12 bg-neutral-light/50 p-4 md:p-8 rounded-xl md:rounded-2xl border border-neutral-dark/5">
|
||||||
{table.metaItems.map((item, mIdx) => (
|
{table.metaItems.map((item, mIdx) => (
|
||||||
<div key={mIdx}>
|
<div key={mIdx}>
|
||||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||||
@@ -98,9 +98,11 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{/* Scroll hint gradient on right edge for mobile */}
|
||||||
|
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
||||||
<div
|
<div
|
||||||
id={`voltage-table-${idx}`}
|
id={`voltage-table-${idx}`}
|
||||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
|
||||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
import { useState } from 'react';
|
|
||||||
import { Search, Sparkles } from 'lucide-react';
|
|
||||||
import { AISearchResults } from '../search/AISearchResults';
|
|
||||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
|
||||||
export default function Hero({ data }: { data?: any }) {
|
export default function Hero({ data }: { data?: any }) {
|
||||||
@@ -16,146 +13,105 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
setIsSearchOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
<div className="max-w-5xl mx-auto md:mx-0">
|
||||||
<div className="max-w-5xl mx-auto md:mx-0">
|
<div>
|
||||||
<div>
|
<Heading
|
||||||
<Heading
|
level={1}
|
||||||
level={1}
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
||||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
|
||||||
>
|
|
||||||
{data?.title ? (
|
|
||||||
<span
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: data.title
|
|
||||||
.replace(
|
|
||||||
/<green>/g,
|
|
||||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
/<\/green>/g,
|
|
||||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t.rich('title', {
|
|
||||||
green: (chunks) => (
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic inline-block">
|
|
||||||
{chunks}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
|
||||||
style={{ animationDelay: '500ms' }}
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</Heading>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
|
|
||||||
{data?.subtitle || t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSearchSubmit}
|
|
||||||
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-full p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent"
|
|
||||||
>
|
>
|
||||||
<Sparkles className="w-6 h-6 text-accent ml-4 hidden sm:block" />
|
{data?.title ? (
|
||||||
<input
|
<span
|
||||||
type="text"
|
dangerouslySetInnerHTML={{
|
||||||
value={searchQuery}
|
__html: data.title
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
.replace(
|
||||||
placeholder="Suchen Sie nach einem Kabel (z.B. N2XY, NYM-J)..."
|
/<green>/g,
|
||||||
className="flex-1 bg-transparent border-none text-white px-4 py-3 placeholder:text-white/60 focus:outline-none text-lg"
|
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
||||||
/>
|
)
|
||||||
|
.replace(
|
||||||
|
/<\/green>/g,
|
||||||
|
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
t.rich('title', {
|
||||||
|
green: (chunks) => (
|
||||||
|
<span className="relative inline-block">
|
||||||
|
<span className="relative z-10 text-accent italic inline-block">
|
||||||
|
{chunks}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
||||||
|
style={{ animationDelay: '500ms' }}
|
||||||
|
>
|
||||||
|
<Scribble variant="circle" />
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||||
|
{data?.subtitle || t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||||
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
href="/contact"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-full px-8 py-3 shrink-0"
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: data?.ctaLabel || t('cta'),
|
||||||
|
location: 'home_hero_primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Search className="w-5 h-5 mr-2 -ml-2" />
|
{data?.ctaLabel || t('cta')}
|
||||||
Suchen
|
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
variant="white"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
||||||
|
location: 'home_hero_secondary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data?.secondaryCtaLabel || t('exploreProducts')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
href="/contact"
|
|
||||||
variant="white"
|
|
||||||
size="lg"
|
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
label: data?.ctaLabel || t('cta'),
|
|
||||||
location: 'home_hero_primary',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{data?.ctaLabel || t('cta')}
|
|
||||||
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg text-white border-white/30 hover:bg-white/10 hover:border-white transition-all"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
|
||||||
location: 'home_hero_secondary',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{data?.secondaryCtaLabel || t('exploreProducts')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
|
||||||
|
|
||||||
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
|
||||||
<HeroIllustration />
|
|
||||||
</div>
|
</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
<div
|
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
<HeroIllustration />
|
||||||
style={{ animationDelay: '2000ms' }}
|
</div>
|
||||||
>
|
|
||||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
<div
|
||||||
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
||||||
</div>
|
style={{ animationDelay: '2000ms' }}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||||
|
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</div>
|
||||||
<AISearchResults
|
</Section>
|
||||||
isOpen={isSearchOpen}
|
|
||||||
onClose={() => setIsSearchOpen(false)}
|
|
||||||
initialQuery={searchQuery}
|
|
||||||
triggerSearch={true}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { Search, Loader2, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react';
|
|
||||||
import { Button, cn } from '@/components/ui';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
|
||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
interface ProductMatch {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
sku: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AIResponse {
|
|
||||||
answerText: string;
|
|
||||||
products: ProductMatch[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComponentProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
initialQuery?: string;
|
|
||||||
triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AISearchResults({ isOpen, onClose, initialQuery = '', triggerSearch = false }: ComponentProps) {
|
|
||||||
const t = useTranslations('Search');
|
|
||||||
const { trackEvent } = useAnalytics();
|
|
||||||
|
|
||||||
const [query, setQuery] = useState(initialQuery);
|
|
||||||
const [honeypot, setHoneypot] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [response, setResponse] = useState<AIResponse | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
// Slight delay to allow animation to start before focus
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
|
|
||||||
if (triggerSearch && initialQuery && !response) {
|
|
||||||
handleSearch(initialQuery);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = 'unset';
|
|
||||||
}
|
|
||||||
return () => { document.body.style.overflow = 'unset'; };
|
|
||||||
}, [isOpen, triggerSearch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setQuery(initialQuery);
|
|
||||||
}, [initialQuery]);
|
|
||||||
|
|
||||||
const handleSearch = async (searchQuery: string = query) => {
|
|
||||||
if (!searchQuery.trim()) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setResponse(null);
|
|
||||||
|
|
||||||
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
|
|
||||||
type: 'ai_search',
|
|
||||||
query: searchQuery
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai-search', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ query: searchQuery, _honeypot: honeypot })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error || 'Failed to fetch search results');
|
|
||||||
}
|
|
||||||
|
|
||||||
setResponse(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
setError(err.message || 'An error occurred while searching. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSearch();
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 bg-primary/95 backdrop-blur-xl transition-all duration-300 animate-in fade-in">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={modalRef}
|
|
||||||
className="relative w-full max-w-4xl bg-[#002b49]/90 border border-white/10 rounded-3xl shadow-2xl shadow-black/50 overflow-hidden flex flex-col h-[75vh] animate-in slide-in-from-bottom-10"
|
|
||||||
>
|
|
||||||
{/* Header - Search Bar */}
|
|
||||||
<div className="p-6 md:p-8 flex items-center border-b border-white/10 relative z-10 bg-gradient-to-r from-primary/80 to-[#00223A]/80">
|
|
||||||
<Sparkles className="w-6 h-6 text-accent shrink-0 mr-4" />
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
placeholder={"What are you looking for?"}
|
|
||||||
className="w-full bg-transparent border-none text-white text-xl md:text-3xl font-extrabold focus:outline-none placeholder:text-white/30"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="hidden"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="w-8 h-8 text-white/50 animate-spin shrink-0 ml-4" />
|
|
||||||
) : query ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSearch()}
|
|
||||||
className="text-white hover:text-accent transition-colors ml-4 shrink-0"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
|
||||||
<Search className="w-8 h-8" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<div className="w-px h-10 bg-white/10 mx-6 hidden md:block" />
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-white/50 hover:text-white transition-colors shrink-0"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X className="w-8 h-8 md:w-10 md:h-10" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 md:p-8 relative">
|
|
||||||
{!response && !isLoading && !error && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center opacity-50 space-y-4">
|
|
||||||
<Search className="w-16 h-16" />
|
|
||||||
<p className="text-xl md:text-2xl font-bold">Describe what you need, and our AI will find it.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-start space-x-4 bg-red-500/10 border border-red-500/20 p-6 rounded-2xl">
|
|
||||||
<MessageSquareWarning className="w-8 h-8 text-red-400 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold text-red-200">Encountered an error</h3>
|
|
||||||
<p className="text-red-300 mt-2">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{response && (
|
|
||||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
{/* AI Answer */}
|
|
||||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 md:p-8 relative overflow-hidden group">
|
|
||||||
<div className="absolute top-0 left-0 w-1 h-full bg-accent" />
|
|
||||||
<Sparkles className="absolute top-4 right-4 w-6h-6 text-accent/20 group-hover:text-accent/40 transition-colors" />
|
|
||||||
<h3 className="text-sm font-bold tracking-widest uppercase text-accent mb-4">AI Assistant</h3>
|
|
||||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed font-medium">
|
|
||||||
{response.answerText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product Matches */}
|
|
||||||
{response.products && response.products.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-bold tracking-widest uppercase text-white/50">Matching Products</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{response.products.map((product, idx) => (
|
|
||||||
<Link
|
|
||||||
key={idx}
|
|
||||||
href={`/produkte/${product.slug}`}
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
target: product.slug,
|
|
||||||
location: 'ai_search_results'
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="group flex flex-col justify-between bg-white text-primary rounded-xl p-6 hover:shadow-2xl hover:-translate-y-1 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-bold text-primary/50 tracking-wider mb-2">{product.sku}</p>
|
|
||||||
<h4 className="text-xl md:text-2xl font-extrabold mb-4 group-hover:text-accent transition-colors">{product.title}</h4>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-sm font-bold tracking-widest uppercase">
|
|
||||||
<span className="group-hover:text-accent transition-colors">Details</span>
|
|
||||||
<ChevronRight className="w-4 h-4 ml-1 group-hover:text-accent transition-colors group-hover:translate-x-1" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
||||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||||
UV_THREADPOOL_SIZE: "4"
|
UV_THREADPOOL_SIZE: "4"
|
||||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||||
CI: "true"
|
CI: "true"
|
||||||
@@ -75,24 +75,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "54322:5432"
|
- "54322:5432"
|
||||||
|
|
||||||
klz-redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
|
|
||||||
klz-qdrant:
|
|
||||||
image: qdrant/qdrant:v1.13.2
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- klz_qdrant_data:/qdrant/storage
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
ports:
|
|
||||||
- "6333:6333"
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
@@ -102,8 +84,6 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
klz_db_data:
|
klz_db_data:
|
||||||
external: false
|
external: false
|
||||||
klz_qdrant_data:
|
|
||||||
external: false
|
|
||||||
klz_node_modules:
|
klz_node_modules:
|
||||||
klz_next_cache:
|
klz_next_cache:
|
||||||
klz_turbo_cache:
|
klz_turbo_cache:
|
||||||
|
|||||||
@@ -100,23 +100,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|
||||||
klz-redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
|
|
||||||
klz-qdrant:
|
|
||||||
image: qdrant/qdrant:v1.13.2
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
QDRANT__SERVICE__HTTP_PORT: 6333
|
|
||||||
QDRANT__SERVICE__GRPC_PORT: 6334
|
|
||||||
volumes:
|
|
||||||
- klz_qdrant_data:/qdrant/storage
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
@@ -128,5 +111,3 @@ volumes:
|
|||||||
external: false
|
external: false
|
||||||
klz_media_data:
|
klz_media_data:
|
||||||
external: false
|
external: false
|
||||||
klz_qdrant_data:
|
|
||||||
external: false
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
||||||
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
||||||
// from being included in the initial JS bundle.
|
// from being included in the initial JS bundle.
|
||||||
export {};
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
@@ -18,6 +18,7 @@ export interface ProductData {
|
|||||||
slug: string;
|
slug: string;
|
||||||
frontmatter: ProductFrontmatter;
|
frontmatter: ProductFrontmatter;
|
||||||
content: any; // Lexical AST from Payload
|
content: any; // Lexical AST from Payload
|
||||||
|
application?: any; // Lexical AST for Application field
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductMetadata(
|
export async function getProductMetadata(
|
||||||
@@ -113,6 +114,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
: 50,
|
: 50,
|
||||||
},
|
},
|
||||||
content: doc.content,
|
content: doc.content,
|
||||||
|
application: doc.application,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +197,7 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
|
|||||||
: 50,
|
: 50,
|
||||||
},
|
},
|
||||||
content: null,
|
content: null,
|
||||||
|
application: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const nextConfig = {
|
|||||||
workerThreads: false,
|
workerThreads: false,
|
||||||
},
|
},
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
|
swcMinify: true,
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
logging: {
|
logging: {
|
||||||
fetches: {
|
fetches: {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.18.3",
|
"packageManager": "pnpm@10.18.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google": "^3.0.31",
|
|
||||||
"@mintel/mail": "^1.8.21",
|
"@mintel/mail": "^1.8.21",
|
||||||
"@mintel/next-config": "^1.8.21",
|
"@mintel/next-config": "^1.8.21",
|
||||||
"@mintel/next-feedback": "^1.8.21",
|
"@mintel/next-feedback": "^1.8.21",
|
||||||
@@ -14,12 +13,10 @@
|
|||||||
"@payloadcms/next": "^3.77.0",
|
"@payloadcms/next": "^3.77.0",
|
||||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||||
"@payloadcms/ui": "^3.77.0",
|
"@payloadcms/ui": "^3.77.0",
|
||||||
"@qdrant/js-client-rest": "^1.17.0",
|
|
||||||
"@react-email/components": "^1.0.7",
|
"@react-email/components": "^1.0.7",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@sentry/nextjs": "^10.39.0",
|
"@sentry/nextjs": "^10.39.0",
|
||||||
"@types/recharts": "^2.0.1",
|
"@types/recharts": "^2.0.1",
|
||||||
"ai": "^6.0.101",
|
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
@@ -27,7 +24,6 @@
|
|||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.3",
|
||||||
"import-in-the-middle": "^1.11.0",
|
"import-in-the-middle": "^1.11.0",
|
||||||
"ioredis": "^5.9.3",
|
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
|||||||
179
pnpm-lock.yaml
generated
179
pnpm-lock.yaml
generated
@@ -12,9 +12,6 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/google':
|
|
||||||
specifier: ^3.0.31
|
|
||||||
version: 3.0.31(zod@3.25.76)
|
|
||||||
'@mintel/mail':
|
'@mintel/mail':
|
||||||
specifier: ^1.8.21
|
specifier: ^1.8.21
|
||||||
version: 1.8.21(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.8.21(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -42,9 +39,6 @@ importers:
|
|||||||
'@payloadcms/ui':
|
'@payloadcms/ui':
|
||||||
specifier: ^3.77.0
|
specifier: ^3.77.0
|
||||||
version: 3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
version: 3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||||
'@qdrant/js-client-rest':
|
|
||||||
specifier: ^1.17.0
|
|
||||||
version: 1.17.0(typescript@5.9.3)
|
|
||||||
'@react-email/components':
|
'@react-email/components':
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -57,9 +51,6 @@ importers:
|
|||||||
'@types/recharts':
|
'@types/recharts':
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1)
|
version: 2.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1)
|
||||||
ai:
|
|
||||||
specifier: ^6.0.101
|
|
||||||
version: 6.0.101(zod@3.25.76)
|
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.13.5
|
specifier: ^1.13.5
|
||||||
version: 1.13.5(debug@4.4.3)
|
version: 1.13.5(debug@4.4.3)
|
||||||
@@ -81,9 +72,6 @@ importers:
|
|||||||
import-in-the-middle:
|
import-in-the-middle:
|
||||||
specifier: ^1.11.0
|
specifier: ^1.11.0
|
||||||
version: 1.15.0
|
version: 1.15.0
|
||||||
ioredis:
|
|
||||||
specifier: ^5.9.3
|
|
||||||
version: 5.9.3
|
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^27.4.0
|
specifier: ^27.4.0
|
||||||
version: 27.4.0
|
version: 27.4.0
|
||||||
@@ -283,28 +271,6 @@ packages:
|
|||||||
'@acemir/cssom@0.9.31':
|
'@acemir/cssom@0.9.31':
|
||||||
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
|
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
|
||||||
|
|
||||||
'@ai-sdk/gateway@3.0.55':
|
|
||||||
resolution: {integrity: sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
peerDependencies:
|
|
||||||
zod: ^3.25.76 || ^4.1.8
|
|
||||||
|
|
||||||
'@ai-sdk/google@3.0.31':
|
|
||||||
resolution: {integrity: sha512-RVNz8WFSIRbXbYDBE6JvlE2escWPJimBCs22LzKEYH7DNfl/X7cHNa1LFho4PsY6Ib0JmbzB8s2+i0wHs/wNCg==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
peerDependencies:
|
|
||||||
zod: ^3.25.76 || ^4.1.8
|
|
||||||
|
|
||||||
'@ai-sdk/provider-utils@4.0.15':
|
|
||||||
resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
peerDependencies:
|
|
||||||
zod: ^3.25.76 || ^4.1.8
|
|
||||||
|
|
||||||
'@ai-sdk/provider@3.0.8':
|
|
||||||
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0':
|
'@alloc/quick-lru@5.2.0':
|
||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1611,9 +1577,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@ioredis/commands@1.5.0':
|
|
||||||
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
|
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1729,7 +1692,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
||||||
|
|
||||||
'@mintel/eslint-config@1.8.21':
|
'@mintel/eslint-config@1.8.21':
|
||||||
resolution: {integrity: sha512-PsPxQk3fsUGLwQCVvHaiNNt0WcjwU/eU9xMxEGGm4SCcPw/ED4UZbaCEYwR78lp9BBGAKSqTFMWBhXfY4PjU8g==}
|
resolution: {integrity: sha512-GH5tm1y89AhD+Lxf95BGCOdy7Nv1OPNLWrUpaTR6jsuKfH2dm9fU66LF7sDH5THmrkfAZ8zSzHJsKPjintv3IA==}
|
||||||
|
|
||||||
'@mintel/mail@1.8.21':
|
'@mintel/mail@1.8.21':
|
||||||
resolution: {integrity: sha512-leZV9gINmxD4eVJ3Ij9KdrQoyib67NVHgL/93J7KcWSUWKbr2HVuKUBpiWImeeEZn3JO0f7JwRbVUzXPBRVeQA==}
|
resolution: {integrity: sha512-leZV9gINmxD4eVJ3Ij9KdrQoyib67NVHgL/93J7KcWSUWKbr2HVuKUBpiWImeeEZn3JO0f7JwRbVUzXPBRVeQA==}
|
||||||
@@ -1738,10 +1701,10 @@ packages:
|
|||||||
react-dom: ^19.0.0
|
react-dom: ^19.0.0
|
||||||
|
|
||||||
'@mintel/next-config@1.8.21':
|
'@mintel/next-config@1.8.21':
|
||||||
resolution: {integrity: sha512-Nwnp32h+eAjZwY9YHXHo2eIWkGrNWAqF6vT8RvyeehU1uJtoajrEpBIQPAf5dWmWSWkIdPSu9vlzEUORu39pBA==}
|
resolution: {integrity: sha512-K4jb9Glf84a212BRZ/zmOUueBphmsikvStFCuDc5lxyFT+Hkj4w8ChmtI7gaUxHMrftooduGPXJ1+NFpKkvc/Q==}
|
||||||
|
|
||||||
'@mintel/next-feedback@1.8.21':
|
'@mintel/next-feedback@1.8.21':
|
||||||
resolution: {integrity: sha512-7WUpX/GMUBO+DYrnCm1Xb3mRQAaWDDaA1DgwavlV2m0lYiwqlPsLGafsBOY9MdGrTFxp2oFuz8lUK8/fkB2/SQ==}
|
resolution: {integrity: sha512-n2KzGDbOvAskuzjbt8h5EOMSEnISxHrsXxJwDdMxCXEgmzfJSvWpP2mAqb684dimOwo1UWHE6DMSAFc1FXeYwg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.0.0
|
react: ^19.0.0
|
||||||
react-dom: ^19.0.0
|
react-dom: ^19.0.0
|
||||||
@@ -1750,7 +1713,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-sr0yDtySGou+3DNvrqY6HWSHCiVIc8nnoRbckyPMSE21AGxk2aJineXGy9BO9tulSBdhStm2SgdC7McMFTszug==}
|
resolution: {integrity: sha512-sr0yDtySGou+3DNvrqY6HWSHCiVIc8nnoRbckyPMSE21AGxk2aJineXGy9BO9tulSBdhStm2SgdC7McMFTszug==}
|
||||||
|
|
||||||
'@mintel/tsconfig@1.8.21':
|
'@mintel/tsconfig@1.8.21':
|
||||||
resolution: {integrity: sha512-V5sY+sZlUv7i5OTqoLph+k7s0hMOzE8G7kB1snFGVuhE71zc8ooi+0WDeP++lwJz3xlFLhQTv/iRznfBnYOCew==}
|
resolution: {integrity: sha512-ePBfBZiijyXKOS6nLIyxkg7QDZEEC1TugzNhmvwwpc0Yh7BmVHyNpvjg6zKsoGj2rok+9Kc8mLH1WihQIs8SKg==}
|
||||||
|
|
||||||
'@monaco-editor/loader@1.7.0':
|
'@monaco-editor/loader@1.7.0':
|
||||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||||
@@ -2218,16 +2181,6 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@qdrant/js-client-rest@1.17.0':
|
|
||||||
resolution: {integrity: sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A==}
|
|
||||||
engines: {node: '>=18.17.0', pnpm: '>=8'}
|
|
||||||
peerDependencies:
|
|
||||||
typescript: '>=4.7'
|
|
||||||
|
|
||||||
'@qdrant/openapi-typescript-fetch@1.2.6':
|
|
||||||
resolution: {integrity: sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==}
|
|
||||||
engines: {node: '>=18.0.0', pnpm: '>=8'}
|
|
||||||
|
|
||||||
'@react-email/body@0.0.11':
|
'@react-email/body@0.0.11':
|
||||||
resolution: {integrity: sha512-ZSD2SxVSgUjHGrB0Wi+4tu3MEpB4fYSbezsFNEJk2xCWDBkFiOeEsjTmR5dvi+CxTK691hQTQlHv0XWuP7ENTg==}
|
resolution: {integrity: sha512-ZSD2SxVSgUjHGrB0Wi+4tu3MEpB4fYSbezsFNEJk2xCWDBkFiOeEsjTmR5dvi+CxTK691hQTQlHv0XWuP7ENTg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3444,10 +3397,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@vercel/oidc@3.1.0':
|
|
||||||
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
|
|
||||||
engines: {node: '>= 20'}
|
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.1.4':
|
'@vitejs/plugin-react@5.1.4':
|
||||||
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
|
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -3596,12 +3545,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
ai@6.0.101:
|
|
||||||
resolution: {integrity: sha512-Ur/NgbgOp1rdhyDiKDk6EOpSgd1g5ADlbcD1cjQJtQsnmhEngz3Rf8nK5JetDh0vnbLy2aEBpaQeL+zvLRWuaA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
peerDependencies:
|
|
||||||
zod: ^3.25.76 || ^4.1.8
|
|
||||||
|
|
||||||
ajv-formats@2.1.1:
|
ajv-formats@2.1.1:
|
||||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4097,10 +4040,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
cluster-key-slot@1.1.2:
|
|
||||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
color-convert@1.9.3:
|
color-convert@1.9.3:
|
||||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||||
|
|
||||||
@@ -4499,10 +4438,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
denque@2.1.0:
|
|
||||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
|
||||||
engines: {node: '>=0.10'}
|
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -5004,10 +4939,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||||
engines: {node: '>=0.8.x'}
|
engines: {node: '>=0.8.x'}
|
||||||
|
|
||||||
eventsource-parser@3.0.6:
|
|
||||||
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
|
|
||||||
engines: {node: '>=18.0.0'}
|
|
||||||
|
|
||||||
execa@5.1.1:
|
execa@5.1.1:
|
||||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5604,10 +5535,6 @@ packages:
|
|||||||
intl-messageformat@11.1.2:
|
intl-messageformat@11.1.2:
|
||||||
resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==}
|
resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==}
|
||||||
|
|
||||||
ioredis@5.9.3:
|
|
||||||
resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==}
|
|
||||||
engines: {node: '>=12.22.0'}
|
|
||||||
|
|
||||||
ip-address@10.1.0:
|
ip-address@10.1.0:
|
||||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
@@ -5935,9 +5862,6 @@ packages:
|
|||||||
json-schema-typed@8.0.2:
|
json-schema-typed@8.0.2:
|
||||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||||
|
|
||||||
json-schema@0.4.0:
|
|
||||||
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
|
|
||||||
|
|
||||||
json-stable-stringify-without-jsonify@1.0.1:
|
json-stable-stringify-without-jsonify@1.0.1:
|
||||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||||
|
|
||||||
@@ -6128,12 +6052,6 @@ packages:
|
|||||||
lodash.camelcase@4.3.0:
|
lodash.camelcase@4.3.0:
|
||||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||||
|
|
||||||
lodash.defaults@4.2.0:
|
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
|
||||||
|
|
||||||
lodash.isarguments@3.1.0:
|
|
||||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
|
||||||
|
|
||||||
lodash.kebabcase@4.1.1:
|
lodash.kebabcase@4.1.1:
|
||||||
resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==}
|
resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==}
|
||||||
|
|
||||||
@@ -7243,14 +7161,6 @@ packages:
|
|||||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
redis-errors@1.2.0:
|
|
||||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
|
|
||||||
redis-parser@3.0.0:
|
|
||||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
|
|
||||||
redux-thunk@3.1.0:
|
redux-thunk@3.1.0:
|
||||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -7602,9 +7512,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
|
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
standard-as-callback@2.1.0:
|
|
||||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
|
||||||
|
|
||||||
start-server-and-test@2.1.3:
|
start-server-and-test@2.1.3:
|
||||||
resolution: {integrity: sha512-k4EcbNjeg0odaDkAMlIeDVDByqX9PIgL4tivgP2tES6Zd8o+4pTq/HgbWCyA3VHIoZopB+wGnNPKYGGSByNriQ==}
|
resolution: {integrity: sha512-k4EcbNjeg0odaDkAMlIeDVDByqX9PIgL4tivgP2tES6Zd8o+4pTq/HgbWCyA3VHIoZopB+wGnNPKYGGSByNriQ==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -8535,30 +8442,6 @@ snapshots:
|
|||||||
|
|
||||||
'@acemir/cssom@0.9.31': {}
|
'@acemir/cssom@0.9.31': {}
|
||||||
|
|
||||||
'@ai-sdk/gateway@3.0.55(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
'@ai-sdk/provider': 3.0.8
|
|
||||||
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
|
|
||||||
'@vercel/oidc': 3.1.0
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
'@ai-sdk/google@3.0.31(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
'@ai-sdk/provider': 3.0.8
|
|
||||||
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
'@ai-sdk/provider-utils@4.0.15(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
'@ai-sdk/provider': 3.0.8
|
|
||||||
'@standard-schema/spec': 1.1.0
|
|
||||||
eventsource-parser: 3.0.6
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
'@ai-sdk/provider@3.0.8':
|
|
||||||
dependencies:
|
|
||||||
json-schema: 0.4.0
|
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
'@apidevtools/json-schema-ref-parser@11.9.3':
|
'@apidevtools/json-schema-ref-parser@11.9.3':
|
||||||
@@ -9713,8 +9596,6 @@ snapshots:
|
|||||||
'@img/sharp-win32-x64@0.34.5':
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@ioredis/commands@1.5.0': {}
|
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
@@ -10691,14 +10572,6 @@ snapshots:
|
|||||||
- react-native-b4a
|
- react-native-b4a
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@qdrant/js-client-rest@1.17.0(typescript@5.9.3)':
|
|
||||||
dependencies:
|
|
||||||
'@qdrant/openapi-typescript-fetch': 1.2.6
|
|
||||||
typescript: 5.9.3
|
|
||||||
undici: 6.23.0
|
|
||||||
|
|
||||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
|
||||||
|
|
||||||
'@react-email/body@0.0.11(react@19.2.4)':
|
'@react-email/body@0.0.11(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -11932,8 +11805,6 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vercel/oidc@3.1.0': {}
|
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
@@ -12121,14 +11992,6 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
ai@6.0.101(zod@3.25.76):
|
|
||||||
dependencies:
|
|
||||||
'@ai-sdk/gateway': 3.0.55(zod@3.25.76)
|
|
||||||
'@ai-sdk/provider': 3.0.8
|
|
||||||
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
|
|
||||||
'@opentelemetry/api': 1.9.0
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
ajv-formats@2.1.1(ajv@8.18.0):
|
ajv-formats@2.1.1(ajv@8.18.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ajv: 8.18.0
|
ajv: 8.18.0
|
||||||
@@ -12663,8 +12526,6 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
cluster-key-slot@1.1.2: {}
|
|
||||||
|
|
||||||
color-convert@1.9.3:
|
color-convert@1.9.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.3
|
color-name: 1.1.3
|
||||||
@@ -13100,8 +12961,6 @@ snapshots:
|
|||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
denque@2.1.0: {}
|
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
@@ -13730,8 +13589,6 @@ snapshots:
|
|||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|
||||||
eventsource-parser@3.0.6: {}
|
|
||||||
|
|
||||||
execa@5.1.1:
|
execa@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
@@ -14402,20 +14259,6 @@ snapshots:
|
|||||||
'@formatjs/icu-messageformat-parser': 3.5.1
|
'@formatjs/icu-messageformat-parser': 3.5.1
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
ioredis@5.9.3:
|
|
||||||
dependencies:
|
|
||||||
'@ioredis/commands': 1.5.0
|
|
||||||
cluster-key-slot: 1.1.2
|
|
||||||
debug: 4.4.3
|
|
||||||
denque: 2.1.0
|
|
||||||
lodash.defaults: 4.2.0
|
|
||||||
lodash.isarguments: 3.1.0
|
|
||||||
redis-errors: 1.2.0
|
|
||||||
redis-parser: 3.0.0
|
|
||||||
standard-as-callback: 2.1.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
ip-address@10.1.0: {}
|
ip-address@10.1.0: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
@@ -14738,8 +14581,6 @@ snapshots:
|
|||||||
|
|
||||||
json-schema-typed@8.0.2: {}
|
json-schema-typed@8.0.2: {}
|
||||||
|
|
||||||
json-schema@0.4.0: {}
|
|
||||||
|
|
||||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||||
|
|
||||||
json5@1.0.2:
|
json5@1.0.2:
|
||||||
@@ -14943,10 +14784,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash.camelcase@4.3.0: {}
|
lodash.camelcase@4.3.0: {}
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
|
||||||
|
|
||||||
lodash.isarguments@3.1.0: {}
|
|
||||||
|
|
||||||
lodash.kebabcase@4.1.1: {}
|
lodash.kebabcase@4.1.1: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
@@ -16309,12 +16146,6 @@ snapshots:
|
|||||||
- '@types/react'
|
- '@types/react'
|
||||||
- redux
|
- redux
|
||||||
|
|
||||||
redis-errors@1.2.0: {}
|
|
||||||
|
|
||||||
redis-parser@3.0.0:
|
|
||||||
dependencies:
|
|
||||||
redis-errors: 1.2.0
|
|
||||||
|
|
||||||
redux-thunk@3.1.0(redux@5.0.1):
|
redux-thunk@3.1.0(redux@5.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
redux: 5.0.1
|
redux: 5.0.1
|
||||||
@@ -16776,8 +16607,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
type-fest: 0.7.1
|
type-fest: 0.7.1
|
||||||
|
|
||||||
standard-as-callback@2.1.0: {}
|
|
||||||
|
|
||||||
start-server-and-test@2.1.3:
|
start-server-and-test@2.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
arg: 5.0.2
|
arg: 5.0.2
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
|
||||||
|
|
||||||
const qdrantUrl = process.env.QDRANT_URL || 'http://localhost:6333';
|
|
||||||
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
|
||||||
|
|
||||||
export const qdrant = new QdrantClient({
|
|
||||||
url: qdrantUrl,
|
|
||||||
apiKey: qdrantApiKey || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const COLLECTION_NAME = 'klz_products';
|
|
||||||
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://klz-cables.com',
|
|
||||||
'X-Title': 'KLZ Cables Search AI',
|
|
||||||
},
|
|
||||||
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 product into Qdrant
|
|
||||||
*/
|
|
||||||
export async function upsertProductVector(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: id,
|
|
||||||
vector,
|
|
||||||
payload,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error writing to Qdrant:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a product from Qdrant
|
|
||||||
*/
|
|
||||||
export async function deleteProductVector(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 products in Qdrant
|
|
||||||
*/
|
|
||||||
export async function searchProducts(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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import Redis from 'ioredis';
|
|
||||||
|
|
||||||
const redisUrl = process.env.REDIS_URL || 'redis://klz-redis:6379';
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
@@ -37,51 +37,6 @@ export const Products: CollectionConfig = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
hooks: {
|
|
||||||
afterChange: [
|
|
||||||
async ({ doc, req, operation }) => {
|
|
||||||
// Run index sync asynchronously to not block the CMS save operation
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
|
|
||||||
|
|
||||||
// Check if product is published
|
|
||||||
if (doc._status !== 'published') {
|
|
||||||
await deleteProductVector(doc.id);
|
|
||||||
req.payload.logger.info(`Removed drafted product ${doc.sku} from Qdrant`);
|
|
||||||
} else {
|
|
||||||
// Serialize payload
|
|
||||||
const contentText = `${doc.title} - SKU: ${doc.sku}\n${doc.description || ''}`;
|
|
||||||
const payload = {
|
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
sku: doc.sku,
|
|
||||||
slug: doc.slug,
|
|
||||||
description: doc.description,
|
|
||||||
featuredImage: doc.featuredImage, // usually just ID or URL depending on depth
|
|
||||||
};
|
|
||||||
await upsertProductVector(doc.id, contentText, payload);
|
|
||||||
req.payload.logger.info(`Upserted product ${doc.sku} to Qdrant`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
req.payload.logger.error({ msg: 'Error syncing product to Qdrant', err: error, productId: doc.id });
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
return doc;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
afterDelete: [
|
|
||||||
async ({ id, req }) => {
|
|
||||||
try {
|
|
||||||
const { deleteProductVector } = await import('../../lib/qdrant');
|
|
||||||
await deleteProductVector(id as string | number);
|
|
||||||
req.payload.logger.info(`Deleted product ${id} from Qdrant`);
|
|
||||||
} catch (error) {
|
|
||||||
req.payload.logger.error({ msg: 'Error deleting product from Qdrant', err: error, productId: id });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
Reference in New Issue
Block a user