Compare commits
22 Commits
v1.0.0-rc.
...
e615d88fd8
| Author | SHA1 | Date | |
|---|---|---|---|
| e615d88fd8 | |||
| 3d498f3df8 | |||
| d9a7cf6a77 | |||
| cd7be080d7 | |||
| 4e602da15d | |||
| e47982d394 | |||
| 877108020b | |||
| 0fff5ae52a | |||
| 459716d09c | |||
| a0d4023f89 | |||
| 9746416146 | |||
| fc9746335d | |||
| 4058abab13 | |||
| 6074747b34 | |||
| 319b2b3e0c | |||
| d7f5504149 | |||
| 0f705b474b | |||
| 67046b9301 | |||
| 0b6211cf5f | |||
| c7f2c3fdfe | |||
| f30c93ffce | |||
| 3e6bbe9a93 |
@@ -85,10 +85,10 @@ jobs:
|
||||
|
||||
# Standardize Traefik Rule
|
||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
|
||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\"%s\")%s", $i, (i==NF?"":" || ")}')
|
||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||
else
|
||||
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)"
|
||||
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||
fi
|
||||
|
||||
@@ -112,16 +112,28 @@ jobs:
|
||||
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||
|
||||
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||
echo "⏳ This release depends on @mintel v$UPSTREAM_VERSION. Waiting for upstream build..."
|
||||
# Fetch script from monorepo (main)
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||
chmod +x wait-for-upstream.sh
|
||||
# 1. Discovery (Works without token for public repositories)
|
||||
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||
|
||||
# Patch script to allow unauthenticated tag lookup if token is restricted
|
||||
sed -i 's|TARGET_SHA=$(echo "$TAG_INFO" | jq -r ".commit.sha // empty")|TARGET_SHA=$(echo "$TAG_INFO" | jq -r ".commit.sha // empty"); [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]] \&\& TARGET_SHA=$(curl -s "https://git.infra.mintel.me/api/v1/repos/$REPO/tags/$TAG" | jq -r ".commit.sha // empty")|' wait-for-upstream.sh
|
||||
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||
|
||||
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
|
||||
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||
|
||||
GITEA_TOKEN=${{ secrets.GITHUB_TOKEN }} ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||
if [[ -n "$POLL_TOKEN" ]]; then
|
||||
echo "⏳ GITEA_PAT found. Checking upstream build status..."
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||
chmod +x wait-for-upstream.sh
|
||||
GITEA_TOKEN="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||
else
|
||||
echo "ℹ️ No GITEA_PAT secret found. Skipping build status wait (Actions API is restricted)."
|
||||
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -186,6 +198,8 @@ jobs:
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||
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' }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache
|
||||
@@ -234,6 +248,10 @@ jobs:
|
||||
|
||||
# Gatekeeper
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
|
||||
# Analytics
|
||||
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' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -263,51 +281,59 @@ jobs:
|
||||
# Gatekeeper Origin
|
||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||
|
||||
cat > .env.deploy << EOF
|
||||
# Generated by CI - $TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||
MAIL_FROM=$MAIL_FROM
|
||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||
{
|
||||
echo "# Generated by CI - $TARGET"
|
||||
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
||||
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
||||
echo "SENTRY_DSN=$SENTRY_DSN"
|
||||
echo "LOG_LEVEL=$LOG_LEVEL"
|
||||
echo "MAIL_HOST=$MAIL_HOST"
|
||||
echo "MAIL_PORT=$MAIL_PORT"
|
||||
echo "MAIL_USERNAME=$MAIL_USERNAME"
|
||||
echo "MAIL_PASSWORD=$MAIL_PASSWORD"
|
||||
echo "MAIL_FROM=$MAIL_FROM"
|
||||
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
|
||||
echo ""
|
||||
echo "# Directus"
|
||||
echo "DIRECTUS_URL=$DIRECTUS_URL"
|
||||
echo "DIRECTUS_HOST=$DIRECTUS_HOST"
|
||||
echo "DIRECTUS_KEY=$DIRECTUS_KEY"
|
||||
echo "DIRECTUS_SECRET=$DIRECTUS_SECRET"
|
||||
echo "DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL"
|
||||
echo "DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD"
|
||||
echo "DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME"
|
||||
echo "DIRECTUS_DB_USER=$DIRECTUS_DB_USER"
|
||||
echo "DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD"
|
||||
echo "DIRECTUS_DB_CLIENT=pg"
|
||||
echo "DIRECTUS_DB_HOST=directus-db"
|
||||
echo "DIRECTUS_DB_PORT=5432"
|
||||
echo "DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN"
|
||||
echo "INTERNAL_DIRECTUS_URL=http://directus:8055"
|
||||
echo ""
|
||||
echo "# Gatekeeper"
|
||||
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
|
||||
echo "AUTH_COOKIE_NAME=klz_gatekeeper_session"
|
||||
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||
echo ""
|
||||
echo "# Analytics"
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||
echo "ENV_FILE=$ENV_FILE"
|
||||
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
||||
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
||||
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||
} > .env.deploy
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=$DIRECTUS_URL
|
||||
DIRECTUS_HOST=$DIRECTUS_HOST
|
||||
DIRECTUS_KEY=$DIRECTUS_KEY
|
||||
DIRECTUS_SECRET=$DIRECTUS_SECRET
|
||||
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
|
||||
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
|
||||
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
||||
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
||||
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
||||
DIRECTUS_DB_CLIENT=pg
|
||||
DIRECTUS_DB_HOST=directus-db
|
||||
DIRECTUS_DB_PORT=5432
|
||||
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
||||
|
||||
# Gatekeeper
|
||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||
AUTH_COOKIE_NAME=klz_gatekeeper_session
|
||||
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||
|
||||
TARGET=$TARGET
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
TRAEFIK_HOST_RULE='$TRAEFIK_RULE'
|
||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||
ENV_FILE=$ENV_FILE
|
||||
COMPOSE_PROFILES=$COMPOSE_PROFILES
|
||||
AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE
|
||||
AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED
|
||||
EOF
|
||||
echo "--- Generated .env.deploy ---"
|
||||
cat .env.deploy
|
||||
echo "----------------------------"
|
||||
|
||||
- name: 🚀 SSH Deploy
|
||||
shell: bash
|
||||
@@ -341,11 +367,43 @@ jobs:
|
||||
run: docker builder prune -f --filter "until=1h"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: Notifications
|
||||
# JOB 5: Smoke Test (OG Images)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
smoke_test:
|
||||
name: 🧪 Smoke Test
|
||||
needs: [prepare, deploy]
|
||||
if: needs.deploy.result == 'success'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🚀 Run OG Image Check
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm run check:og
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy]
|
||||
needs: [prepare, deploy, smoke_test]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
|
||||
@@ -6,12 +6,16 @@ WORKDIR /app
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG UMAMI_WEBSITE_ID
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG NPM_TOKEN
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV CI=true
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
||||
export default async function Image({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
if (!pageData) {
|
||||
@@ -15,17 +20,14 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={pageData.frontmatter.title}
|
||||
description={pageData.frontmatter.excerpt}
|
||||
label="Information"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={pageData.frontmatter.title}
|
||||
description={pageData.frontmatter.excerpt}
|
||||
label="Information"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface PageProps {
|
||||
@@ -50,7 +49,6 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
url: `${SITE_URL}/${locale}/${slug}`,
|
||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
||||
@@ -5,13 +5,15 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ locale: string }> },
|
||||
) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
const { locale } = await params;
|
||||
|
||||
@@ -58,7 +60,7 @@ export async function GET(
|
||||
const featuredImage = product.frontmatter.images?.[0]
|
||||
? product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0]
|
||||
: `${origin}${product.frontmatter.images[0]}`
|
||||
: `${SITE_URL}${product.frontmatter.images[0]}`
|
||||
: undefined;
|
||||
|
||||
return new ImageResponse(
|
||||
|
||||
@@ -7,10 +7,11 @@ import { SITE_URL } from '@/lib/schema';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({
|
||||
params: { locale, slug },
|
||||
params,
|
||||
}: {
|
||||
params: { locale: string; slug: string };
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
|
||||
if (!post) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
interface BlogPostProps {
|
||||
@@ -45,7 +44,6 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['KLZ Cables'],
|
||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
||||
@@ -5,21 +5,16 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Blog"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import Image from 'next/image';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface BlogIndexProps {
|
||||
@@ -31,7 +31,6 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('description'),
|
||||
url: `${SITE_URL}/${locale}/blog`,
|
||||
images: getOGImageMetadata('blog', t('title'), locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
||||
@@ -5,7 +5,8 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
@@ -13,16 +14,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label="Contact"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/contact`,
|
||||
siteName: 'KLZ Cables',
|
||||
images: getOGImageMetadata('contact', title, locale),
|
||||
locale: `${locale.toUpperCase()}_DE`,
|
||||
type: 'website',
|
||||
},
|
||||
@@ -43,7 +42,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -4,6 +4,8 @@ import JsonLd from '@/components/JsonLd';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
import { FeedbackOverlay } from '@mintel/next-feedback';
|
||||
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
||||
import { RecordModeOverlay } from '@/components/record-mode/RecordModeOverlay';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
@@ -98,16 +100,19 @@ export default async function LocaleLayout({
|
||||
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||
<Footer />
|
||||
<CMSConnectivityNotice />
|
||||
<RecordModeProvider>
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||
<Footer />
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsProvider />
|
||||
</Suspense>
|
||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsProvider />
|
||||
</Suspense>
|
||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||
<RecordModeOverlay />
|
||||
</RecordModeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,22 +5,20 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Reliable Energy Infrastructure"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Reliable Energy Infrastructure"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
@@ -13,17 +14,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label="Products"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={title} description={description} label="Products" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface ProductsPageProps {
|
||||
@@ -35,7 +34,6 @@ export async function generateMetadata({ params }: ProductsPageProps): Promise<M
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/products`,
|
||||
images: getOGImageMetadata('products', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
||||
@@ -5,7 +5,8 @@ import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
@@ -13,17 +14,10 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label="Our Team"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Metadata } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import Image from 'next/image';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Gallery from '@/components/team/Gallery';
|
||||
@@ -34,7 +33,6 @@ export async function generateMetadata({ params }: TeamPageProps): Promise<Metad
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/team`,
|
||||
images: getOGImageMetadata('team', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
||||
178
components/record-mode/RecordModeContext.tsx
Normal file
178
components/record-mode/RecordModeContext.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { RecordEvent, RecordingSession } from '@/types/record-mode';
|
||||
|
||||
interface RecordModeContextType {
|
||||
isActive: boolean;
|
||||
setIsActive: (active: boolean) => void;
|
||||
isRecording: boolean;
|
||||
startRecording: () => void;
|
||||
stopRecording: () => void;
|
||||
events: RecordEvent[];
|
||||
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
||||
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
||||
removeEvent: (id: string) => void;
|
||||
clearEvents: () => void;
|
||||
isPlaying: boolean;
|
||||
playEvents: () => void;
|
||||
stopPlayback: () => void;
|
||||
currentSession: RecordingSession | null;
|
||||
saveSession: (name: string) => void;
|
||||
}
|
||||
|
||||
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||
|
||||
export function useRecordMode() {
|
||||
const context = useContext(RecordModeContext);
|
||||
if (!context) {
|
||||
throw new Error('useRecordMode must be used within a RecordModeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function RecordModeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [events, setEvents] = useState<RecordEvent[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentSession, setCurrentSession] = useState<RecordingSession | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
|
||||
const startRecording = () => {
|
||||
setIsRecording(true);
|
||||
setEvents([]);
|
||||
startTimeRef.current = Date.now();
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
const addEvent = (eventData: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
||||
const timestamp = Date.now() - startTimeRef.current;
|
||||
const newEvent: RecordEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp,
|
||||
...eventData,
|
||||
};
|
||||
setEvents((prev) => [...prev, newEvent]);
|
||||
};
|
||||
|
||||
const updateEvent = (id: string, updates: Partial<RecordEvent>) => {
|
||||
setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...updates } : e)));
|
||||
};
|
||||
|
||||
const removeEvent = (id: string) => {
|
||||
setEvents((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
const clearEvents = () => {
|
||||
setEvents([]);
|
||||
};
|
||||
|
||||
const playEvents = async () => {
|
||||
if (events.length === 0) return;
|
||||
setIsPlaying(true);
|
||||
|
||||
// Simple playback logic mostly for preview
|
||||
const startPlayTime = Date.now();
|
||||
|
||||
// Sort events by timestamp just in case
|
||||
const sortedEvents = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
if (!isPlaying) break; // Check if stopped
|
||||
|
||||
const targetTime = startPlayTime + event.timestamp;
|
||||
const now = Date.now();
|
||||
const delay = targetTime - now;
|
||||
|
||||
if (delay > 0) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
|
||||
// Execute event visual feedback
|
||||
if (document) {
|
||||
if (event.selector) {
|
||||
const el = document.querySelector(event.selector);
|
||||
if (el) {
|
||||
// Highlight or scroll to element
|
||||
if (event.type === 'scroll') {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else if (event.type === 'click') {
|
||||
// Visualize click
|
||||
const rect = el.getBoundingClientRect();
|
||||
const clickMarker = document.createElement('div');
|
||||
clickMarker.style.position = 'fixed';
|
||||
clickMarker.style.left = `${rect.left + rect.width / 2}px`;
|
||||
clickMarker.style.top = `${rect.top + rect.height / 2}px`;
|
||||
clickMarker.style.width = '20px';
|
||||
clickMarker.style.height = '20px';
|
||||
clickMarker.style.borderRadius = '50%';
|
||||
clickMarker.style.backgroundColor = 'rgba(255, 0, 0, 0.5)';
|
||||
clickMarker.style.transform = 'translate(-50%, -50%)';
|
||||
clickMarker.style.zIndex = '99999';
|
||||
document.body.appendChild(clickMarker);
|
||||
setTimeout(() => clickMarker.remove(), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const stopPlayback = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const saveSession = (name: string) => {
|
||||
const session: RecordingSession = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
events,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setCurrentSession(session);
|
||||
// Ideally save to local storage or API
|
||||
localStorage.setItem('klz-record-session', JSON.stringify(session));
|
||||
};
|
||||
|
||||
// Load session on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('klz-record-session');
|
||||
if (saved) {
|
||||
try {
|
||||
setCurrentSession(JSON.parse(saved));
|
||||
} catch (e) {
|
||||
console.error('Failed to load session', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RecordModeContext.Provider
|
||||
value={{
|
||||
isActive,
|
||||
setIsActive,
|
||||
isRecording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
clearEvents,
|
||||
isPlaying,
|
||||
playEvents,
|
||||
stopPlayback,
|
||||
currentSession,
|
||||
saveSession,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecordModeContext.Provider>
|
||||
);
|
||||
}
|
||||
352
components/record-mode/RecordModeOverlay.tsx
Normal file
352
components/record-mode/RecordModeOverlay.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import { finder } from '@medv/finder';
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
MousePointer2,
|
||||
Scroll,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Eye,
|
||||
Edit2,
|
||||
X,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { RecordEvent } from '@/types/record-mode';
|
||||
|
||||
export function RecordModeOverlay() {
|
||||
const {
|
||||
isActive,
|
||||
setIsActive,
|
||||
isRecording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
isPlaying,
|
||||
playEvents,
|
||||
saveSession,
|
||||
clearEvents,
|
||||
} = useRecordMode();
|
||||
|
||||
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
if (pickingMode) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.record-mode-ui')) return;
|
||||
setHoveredElement(target);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (pickingMode && hoveredElement) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const selector = finder(hoveredElement);
|
||||
|
||||
if (pickingMode === 'click') {
|
||||
addEvent({
|
||||
type: 'click',
|
||||
selector,
|
||||
duration: 1000,
|
||||
zoom: 1,
|
||||
description: `Click on ${hoveredElement.tagName.toLowerCase()}`,
|
||||
motionBlur: false,
|
||||
});
|
||||
} else if (pickingMode === 'scroll') {
|
||||
addEvent({
|
||||
type: 'scroll',
|
||||
selector,
|
||||
duration: 1000,
|
||||
zoom: 1,
|
||||
description: `Scroll to ${hoveredElement.tagName.toLowerCase()}`,
|
||||
motionBlur: false,
|
||||
});
|
||||
}
|
||||
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (pickingMode) {
|
||||
window.addEventListener('mouseover', handleMouseOver);
|
||||
window.addEventListener('click', handleClick, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseover', handleMouseOver);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, [isActive, pickingMode, hoveredElement, addEvent]);
|
||||
|
||||
const startEditing = (event: RecordEvent) => {
|
||||
setEditingEventId(event.id);
|
||||
setEditForm({ ...event });
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingEventId && editForm) {
|
||||
updateEvent(editingEventId, editForm);
|
||||
setEditingEventId(null);
|
||||
setEditForm({});
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingEventId(null);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
if (!isActive) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsActive(true)}
|
||||
className="fixed bottom-4 right-4 z-[9999] bg-red-600 text-white p-3 rounded-full shadow-lg hover:scale-110 transition-transform record-mode-ui"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] pointer-events-none">
|
||||
{/* Hover Highlighter */}
|
||||
{pickingMode && hoveredElement && (
|
||||
<div
|
||||
className="fixed pointer-events-none border-2 border-red-500 bg-red-500/20 transition-all z-[9998]"
|
||||
style={{
|
||||
top: hoveredElement.getBoundingClientRect().top,
|
||||
left: hoveredElement.getBoundingClientRect().left,
|
||||
width: hoveredElement.getBoundingClientRect().width,
|
||||
height: hoveredElement.getBoundingClientRect().height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Control Panel */}
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 bg-black/80 backdrop-blur-md text-white p-4 rounded-xl shadow-2xl pointer-events-auto record-mode-ui border border-white/10 w-[600px] max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-gray-500'}`}
|
||||
/>
|
||||
Record Mode
|
||||
</h3>
|
||||
<button onClick={() => setIsActive(false)} className="text-white/50 hover:text-white">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4 overflow-x-auto pb-2">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center gap-2 bg-red-600 px-4 py-2 rounded-lg hover:bg-red-700 whitespace-nowrap"
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full bg-white" />
|
||||
Start Rec
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-2 bg-gray-700 px-4 py-2 rounded-lg hover:bg-gray-600 whitespace-nowrap"
|
||||
>
|
||||
<Square size={16} fill="currentColor" />
|
||||
Stop Rec
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="w-px h-8 bg-white/20 mx-2" />
|
||||
|
||||
<button
|
||||
disabled={!isRecording}
|
||||
onClick={() => setPickingMode('click')}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'click' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
|
||||
>
|
||||
<MousePointer2 size={16} />
|
||||
Add Click
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={!isRecording}
|
||||
onClick={() => setPickingMode('scroll')}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'scroll' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
|
||||
>
|
||||
<Scroll size={16} />
|
||||
Add Scroll
|
||||
</button>
|
||||
|
||||
<div className="w-px h-8 bg-white/20 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={playEvents}
|
||||
disabled={isRecording || events.length === 0}
|
||||
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Play size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => saveSession('Session 1')}
|
||||
disabled={events.length === 0}
|
||||
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Save size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
disabled={events.length === 0}
|
||||
className="p-2 hover:bg-red-500/20 text-red-400 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
{editingEventId && (
|
||||
<div className="bg-blue-900/40 p-3 rounded-lg mb-4 border border-blue-500/30">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-bold text-sm text-blue-300">Edit Event</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
className="p-1 hover:bg-green-500/20 text-green-400 rounded"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 hover:bg-red-500/20 text-red-400 rounded"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<label className="block text-white/50 mb-1">Type</label>
|
||||
<select
|
||||
value={editForm.type}
|
||||
onChange={(e) => setEditForm({ ...editForm, type: e.target.value as any })}
|
||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
||||
>
|
||||
<option value="click">Click</option>
|
||||
<option value="scroll">Scroll</option>
|
||||
<option value="wait">Wait</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/50 mb-1">Duration (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.duration}
|
||||
onChange={(e) => setEditForm({ ...editForm, duration: parseInt(e.target.value) })}
|
||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/50 mb-1">Zoom (x)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editForm.zoom}
|
||||
onChange={(e) => setEditForm({ ...editForm, zoom: parseFloat(e.target.value) })}
|
||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/50 mb-1">Motion Blur</label>
|
||||
<button
|
||||
onClick={() => setEditForm({ ...editForm, motionBlur: !editForm.motionBlur })}
|
||||
className={`w-full p-1 rounded text-center border ${editForm.motionBlur ? 'bg-blue-500/20 border-blue-500 text-blue-300' : 'bg-black/40 border-white/10 text-white/50'}`}
|
||||
>
|
||||
{editForm.motionBlur ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-white/50 mb-1">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.description || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Timeline */}
|
||||
<div className="bg-white/5 rounded-lg p-2 flex-1 overflow-y-auto space-y-2 min-h-[200px]">
|
||||
{events.length === 0 && (
|
||||
<div className="text-center text-white/30 text-sm py-4">No events recorded yet.</div>
|
||||
)}
|
||||
{events.map((event, index) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`flex items-center gap-3 bg-white/5 p-2 rounded text-sm group cursor-pointer hover:bg-white/10 border border-transparent ${editingEventId === event.id ? 'border-blue-500 bg-blue-500/10' : ''}`}
|
||||
onClick={() => startEditing(event)}
|
||||
>
|
||||
<span className="text-white/30 w-6 text-center">{index + 1}</span>
|
||||
{event.type === 'click' && <MousePointer2 size={14} className="text-blue-400" />}
|
||||
{event.type === 'scroll' && <Scroll size={14} className="text-green-400" />}
|
||||
|
||||
<div className="flex-1 truncate">
|
||||
<span className="font-mono text-white/50 text-xs mr-2">{event.selector}</span>
|
||||
{event.motionBlur && (
|
||||
<span className="text-[10px] bg-purple-500/20 text-purple-300 px-1 rounded ml-1">
|
||||
Blur
|
||||
</span>
|
||||
)}
|
||||
{event.zoom && event.zoom !== 1 && (
|
||||
<span className="text-[10px] bg-yellow-500/20 text-yellow-300 px-1 rounded ml-1">
|
||||
x{event.zoom}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-white/40">{(event.timestamp / 1000).toFixed(1)}s</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeEvent(event.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-white/10 rounded text-red-400"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Picking Instructions */}
|
||||
{pickingMode && (
|
||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 bg-blue-600 text-white px-6 py-2 rounded-full shadow-xl z-[10000] animate-bounce">
|
||||
Select element to {pickingMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,24 +10,25 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(\"klz-cables.com\")}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
||||
# HTTPS router (Standard)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(\"klz-cables.com\")}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
||||
|
||||
# HTTPS router (Unprotected - for Analytics & Errors)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)} && PathPrefix(`/stats`, `/errors`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.service=${PROJECT_NAME:-klz-cables}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
||||
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.rule=(${TRAEFIK_HOST_RULE:-Host(\"klz-cables.com\")}) && (PathPrefix(\"/health\", \"/sitemap.xml\", \"/robots.txt\", \"/manifest.webmanifest\", \"/api/og\") || PathRegexp(\".*opengraph-image.*\") || PathRegexp(\".*sitemap.*\"))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.service=${PROJECT_NAME:-klz-cables}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.priority=2000"
|
||||
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
|
||||
@@ -77,7 +78,7 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=infra"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-testing.klz-cables.com}`) && PathPrefix(`/gatekeeper`))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=(Host(\"${TRAEFIK_HOST:-testing.klz-cables.com}\") && PathPrefix(\"/gatekeeper\"))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
|
||||
@@ -119,7 +120,7 @@ services:
|
||||
- ./directus/migrations:/directus/migrations
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(`${DIRECTUS_HOST}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(\"${DIRECTUS_HOST}\")"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
|
||||
|
||||
18
lib/env.ts
18
lib/env.ts
@@ -22,6 +22,24 @@ const envExtension = {
|
||||
|
||||
INFRA_DIRECTUS_URL: z.string().url().optional(),
|
||||
INFRA_DIRECTUS_TOKEN: z.string().optional(),
|
||||
|
||||
// Analytics
|
||||
UMAMI_WEBSITE_ID: z.string().optional(),
|
||||
UMAMI_API_ENDPOINT: z.string().optional(),
|
||||
|
||||
// Mail Configuration
|
||||
MAIL_HOST: z.string().optional(),
|
||||
MAIL_PORT: z.coerce.number().optional(),
|
||||
MAIL_USERNAME: z.string().optional(),
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
MAIL_RECIPIENTS: z.string().optional(),
|
||||
|
||||
// Directus Authentication
|
||||
DIRECTUS_URL: z.string().url().optional(),
|
||||
DIRECTUS_ADMIN_EMAIL: z.string().email().optional(),
|
||||
DIRECTUS_ADMIN_PASSWORD: z.string().optional(),
|
||||
DIRECTUS_API_TOKEN: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,37 +6,41 @@ import { join } from 'path';
|
||||
* Since we are using runtime = 'nodejs', we can read them from the filesystem.
|
||||
*/
|
||||
export async function getOgFonts() {
|
||||
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.ttf');
|
||||
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.ttf');
|
||||
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.woff');
|
||||
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.woff');
|
||||
|
||||
try {
|
||||
const boldFont = readFileSync(boldFontPath);
|
||||
const regularFont = readFileSync(regularFontPath);
|
||||
try {
|
||||
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
||||
const boldFont = readFileSync(boldFontPath);
|
||||
const regularFont = readFileSync(regularFontPath);
|
||||
console.log(
|
||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: boldFont,
|
||||
weight: 700 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: regularFont,
|
||||
weight: 400 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Failed to load OG fonts from filesystem, falling back to system fonts:', error);
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: boldFont,
|
||||
weight: 700 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: regularFont,
|
||||
weight: 400 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common configuration for OG images
|
||||
*/
|
||||
export const OG_IMAGE_SIZE = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,18 @@ const intlMiddleware = createMiddleware({
|
||||
|
||||
export default function middleware(request: NextRequest) {
|
||||
const { method, url, headers } = request;
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Explicit bypass for infrastructure routes to avoid locale redirects/interception
|
||||
if (
|
||||
pathname.startsWith('/stats') ||
|
||||
pathname.startsWith('/errors') ||
|
||||
pathname.startsWith('/health') ||
|
||||
pathname.startsWith('/api/og') ||
|
||||
pathname.includes('opengraph-image')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build header object for logging
|
||||
const headerObj: Record<string, string> = {};
|
||||
@@ -62,5 +74,5 @@ export default function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
// Match only internationalized pathnames
|
||||
matcher: ['/((?!api|_next|_vercel|stats|errors|health|.*\\..*).*)', '/', '/(de|en)/:path*'],
|
||||
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)', '/', '/(de|en)/:path*'],
|
||||
};
|
||||
|
||||
@@ -322,6 +322,15 @@ const nextConfig = {
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
},
|
||||
async rewrites() {
|
||||
const umamiUrl =
|
||||
process.env.UMAMI_API_ENDPOINT ||
|
||||
process.env.UMAMI_SCRIPT_URL ||
|
||||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
|
||||
'https://analytics.infra.mintel.me';
|
||||
const glitchtipUrl = process.env.SENTRY_DSN
|
||||
? new URL(process.env.SENTRY_DSN).origin
|
||||
: 'https://errors.infra.mintel.me';
|
||||
|
||||
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
||||
|
||||
return [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"@medv/finder": "^4.0.2",
|
||||
"@mintel/mail": "1.7.12",
|
||||
"@mintel/next-config": "1.7.12",
|
||||
"@mintel/next-feedback": "1.7.12",
|
||||
@@ -80,6 +81,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:og": "vitest run tests/og-image.test.ts",
|
||||
"check:og": "tsx scripts/check-og-images.ts",
|
||||
"cms:branding:local": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@directus/sdk':
|
||||
specifier: ^21.0.0
|
||||
version: 21.1.0
|
||||
'@medv/finder':
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
'@mintel/mail':
|
||||
specifier: 1.7.12
|
||||
version: 1.7.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -1028,6 +1031,9 @@ packages:
|
||||
'@types/react': '>=16'
|
||||
react: '>=16'
|
||||
|
||||
'@medv/finder@4.0.2':
|
||||
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
||||
|
||||
'@mintel/eslint-config@1.7.12':
|
||||
resolution: {integrity: sha512-ofX68JWCW8ztD9tt/1MDb6pSr9MJKq3js3Vny7VoT/bObjpR/iO9tJp0ekiq12Ps8VTEgDh1qwwmf2wrJJBRpQ==}
|
||||
|
||||
@@ -7473,6 +7479,8 @@ snapshots:
|
||||
'@types/react': 19.2.13
|
||||
react: 19.2.4
|
||||
|
||||
'@medv/finder@4.0.2': {}
|
||||
|
||||
'@mintel/eslint-config@1.7.12(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint/eslintrc': 3.3.3
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
public/fonts/Inter-Bold.woff
Normal file
BIN
public/fonts/Inter-Bold.woff
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
public/fonts/Inter-Regular.woff
Normal file
BIN
public/fonts/Inter-Regular.woff
Normal file
Binary file not shown.
74
scripts/check-og-images.ts
Normal file
74
scripts/check-og-images.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { SITE_URL } from '../lib/schema.js';
|
||||
|
||||
const BASE_URL = process.env.TEST_URL || SITE_URL;
|
||||
|
||||
console.log(`\n🚀 Starting OG Image Verification for ${BASE_URL}\n`);
|
||||
|
||||
const routes = [
|
||||
'/de/opengraph-image',
|
||||
'/en/opengraph-image',
|
||||
'/de/blog/opengraph-image',
|
||||
'/de/api/og/product?slug=nay2y',
|
||||
'/en/api/og/product?slug=medium-voltage-cables',
|
||||
];
|
||||
|
||||
async function verifyImage(path: string): Promise<boolean> {
|
||||
const url = `${BASE_URL}${path}`;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
console.log(`Checking ${url}...`);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType?.includes('image/png')) {
|
||||
const body = await response.text();
|
||||
console.log(` Headers: ${JSON.stringify(Object.fromEntries(response.headers))}`);
|
||||
throw new Error(
|
||||
`Content-Type: ${contentType}. Body preview: ${body.substring(0, 500).replace(/\n/g, ' ')}...`,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
// PNG Signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) {
|
||||
throw new Error('Invalid PNG signature');
|
||||
}
|
||||
|
||||
if (bytes.length < 10000) {
|
||||
throw new Error(`Image too small (${bytes.length} bytes), likely blank`);
|
||||
}
|
||||
|
||||
console.log(` ✅ OK (${bytes.length} bytes, ${duration}ms)`);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error(` ❌ FAILED:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let allOk = true;
|
||||
for (const route of routes) {
|
||||
const ok = await verifyImage(route);
|
||||
if (!ok) allOk = false;
|
||||
}
|
||||
|
||||
if (allOk) {
|
||||
console.log('\n✨ All OG images verified successfully!\n');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('\n⚠️ Some OG images failed verification.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -9,7 +9,7 @@ if [ -z "$ENV" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//')
|
||||
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//' | sed 's/-nextjs$//')
|
||||
|
||||
case $ENV in
|
||||
local)
|
||||
@@ -25,7 +25,10 @@ case $ENV in
|
||||
case $ENV in
|
||||
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
|
||||
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
|
||||
production) PROJECT_NAME="${PRJ_ID}-prod" ;;
|
||||
production)
|
||||
PROJECT_NAME="${PRJ_ID}-prod"
|
||||
OLD_PROJECT_NAME="${PRJ_ID}com" # Fallback for legacy naming
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "📤 Uploading snapshot to $ENV..."
|
||||
@@ -34,8 +37,16 @@ case $ENV in
|
||||
echo "🔍 Detecting remote container..."
|
||||
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus")
|
||||
|
||||
if [ -z "$REMOTE_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
|
||||
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
|
||||
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus")
|
||||
if [ -n "$REMOTE_CONTAINER" ]; then
|
||||
PROJECT_NAME=$OLD_PROJECT_NAME
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE_CONTAINER" ]; then
|
||||
echo "❌ Remote container for $ENV not found."
|
||||
echo "❌ Remote container for $ENV not found (checked $PROJECT_NAME)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
17
types/record-mode.ts
Normal file
17
types/record-mode.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface RecordEvent {
|
||||
id: string;
|
||||
type: 'click' | 'scroll' | 'wait' | 'hover';
|
||||
selector?: string; // CSS selector
|
||||
timestamp: number; // Time in ms since start of recording
|
||||
duration: number; // Duration of the action (e.g. scroll duration)
|
||||
zoom?: number; // Zoom level during event
|
||||
description?: string; // Optional label
|
||||
motionBlur?: boolean; // Enable motion blur effect
|
||||
}
|
||||
|
||||
export interface RecordingSession {
|
||||
id: string;
|
||||
name: string;
|
||||
events: RecordEvent[];
|
||||
createdAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user