Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44d3e8585b | |||
| 5652f27c71 | |||
| c769da5f26 | |||
| ef5e749056 | |||
| 9c2344afd9 | |||
| 0b3de9f98c | |||
| 5813b4bd49 | |||
| 33f0238d58 | |||
| d5da64cb76 | |||
| c3111a04d8 | |||
| 2fabfc4445 | |||
| fb62113a32 | |||
| bdde7c242c | |||
| 90f657ce8d | |||
| a168f96f3c | |||
| 2db2a3aff9 | |||
| 2ba67af68a | |||
| b0f088a1dc | |||
| f358492a99 | |||
| 32576b5391 |
@@ -371,17 +371,25 @@ jobs:
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "🔧 Sanitizing payload_migrations table..."
|
echo "🔧 Sanitizing payload_migrations table (if exists)..."
|
||||||
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U payload -d payload -c \"
|
REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||||
DELETE FROM payload_migrations WHERE batch = -1;
|
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||||
INSERT INTO payload_migrations (name, batch)
|
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||||
SELECT name, batch FROM (VALUES
|
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||||
('20260223_195005_products_collection', 1),
|
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
||||||
('20260223_195151_remove_sku_unique', 2),
|
DO \\\$\\\$ BEGIN
|
||||||
('20260225_003500_add_pages_collection', 3)
|
DELETE FROM payload_migrations WHERE batch = -1;
|
||||||
) AS v(name, batch)
|
INSERT INTO payload_migrations (name, batch)
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
SELECT name, batch FROM (VALUES
|
||||||
\""
|
('20260223_195005_products_collection', 1),
|
||||||
|
('20260223_195151_remove_sku_unique', 2),
|
||||||
|
('20260225_003500_add_pages_collection', 3)
|
||||||
|
) AS v(name, batch)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
||||||
|
EXCEPTION WHEN undefined_table THEN
|
||||||
|
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
||||||
|
END \\\$\\\$;
|
||||||
|
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
||||||
|
|
||||||
# Restart app to pick up clean migration state
|
# Restart app to pick up clean migration state
|
||||||
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||||
@@ -419,39 +427,52 @@ jobs:
|
|||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
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
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
id: deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||||
- name: 🚀 OG Image Check
|
- name: 🚀 OG Image Check
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
env:
|
env:
|
||||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
run: pnpm run check:og
|
run: pnpm run check:og
|
||||||
- name: 🌐 Full Sitemap HTTP Validation
|
- name: 🌐 Full Sitemap HTTP Validation
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
run: pnpm run check:http
|
run: pnpm run check:http
|
||||||
|
- name: 🌐 Locale & Language Switcher Validation
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm run check:locale
|
||||||
|
|
||||||
# ── Quality Gates (informational, don't block pipeline) ───────────────
|
# ── Quality Gates (informational, don't block pipeline) ───────────────
|
||||||
- name: 🌐 HTML DOM Validation
|
- name: 🌐 HTML DOM Validation
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
run: pnpm check:html
|
run: pnpm check:html
|
||||||
- name: 🔒 Security Headers Scan
|
- name: 🔒 Security Headers Scan
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
run: pnpm check:security
|
run: pnpm check:security
|
||||||
- name: 🔗 Lychee Deep Link Crawl
|
- name: 🔗 Lychee Deep Link Crawl
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
run: pnpm check:links
|
run: pnpm check:links
|
||||||
- name: 🖼️ Dynamic Asset & Image Integrity Scan
|
- name: 🖼️ Dynamic Asset & Image Integrity Scan
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
@@ -548,12 +569,42 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: 🔔 Gotify
|
- name: 🔔 Gotify
|
||||||
run: |
|
run: |
|
||||||
STATUS="${{ needs.deploy.result }}"
|
DEPLOY="${{ needs.deploy.result }}"
|
||||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||||
TITLE="klz-cables.com: deploy=$STATUS smoke=$SMOKE"
|
PERF="${{ needs.performance.result }}"
|
||||||
[[ "$STATUS" == "success" && "$SMOKE" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
TARGET="${{ needs.prepare.outputs.target }}"
|
||||||
|
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||||
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
|
|
||||||
|
# Gotify priority scale:
|
||||||
|
# 1-3 = low (silent/info)
|
||||||
|
# 4-5 = normal
|
||||||
|
# 6-7 = high (warning)
|
||||||
|
# 8-10 = critical (alarm)
|
||||||
|
if [[ "$DEPLOY" != "success" ]]; then
|
||||||
|
PRIORITY=10
|
||||||
|
EMOJI="🚨"
|
||||||
|
STATUS_LINE="DEPLOY FAILED"
|
||||||
|
elif [[ "$SMOKE" != "success" ]]; then
|
||||||
|
PRIORITY=8
|
||||||
|
EMOJI="⚠️"
|
||||||
|
STATUS_LINE="Smoke tests failed"
|
||||||
|
elif [[ "$PERF" != "success" ]]; then
|
||||||
|
PRIORITY=5
|
||||||
|
EMOJI="📉"
|
||||||
|
STATUS_LINE="Performance degraded"
|
||||||
|
else
|
||||||
|
PRIORITY=2
|
||||||
|
EMOJI="✅"
|
||||||
|
STATUS_LINE="All checks passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TITLE="$EMOJI klz-cables.com $VERSION → $TARGET"
|
||||||
|
MESSAGE="$STATUS_LINE
|
||||||
|
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||||
|
$URL"
|
||||||
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=$TITLE" \
|
-F "title=$TITLE" \
|
||||||
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished.\nDeploy: $STATUS | Smoke: $SMOKE\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
-F "message=$MESSAGE" \
|
||||||
-F "priority=$PRIORITY" || true
|
-F "priority=$PRIORITY" || true
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||||
<TrackedLink
|
<TrackedLink
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||||
eventProperties={{
|
eventProperties={{
|
||||||
location: 'generic_page_support_cta',
|
location: 'generic_page_support_cta',
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/contact`,
|
canonical: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/contact`,
|
de: `${SITE_URL}/de/kontakt`,
|
||||||
en: `${SITE_URL}/en/contact`,
|
en: `${SITE_URL}/en/contact`,
|
||||||
'x-default': `${SITE_URL}/en/contact`,
|
'x-default': `${SITE_URL}/en/contact`,
|
||||||
},
|
},
|
||||||
@@ -34,7 +34,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/contact`,
|
url: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||||
siteName: 'KLZ Cables',
|
siteName: 'KLZ Cables',
|
||||||
locale: `${locale.toUpperCase()}_DE`,
|
locale: `${locale.toUpperCase()}_DE`,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
|
|||||||
@@ -55,14 +55,23 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||||
|
const getLocalizedPath = async (lang: string) => {
|
||||||
|
const parts = await Promise.all([
|
||||||
|
mapFileSlugToTranslated('products', lang),
|
||||||
|
...fileSlugs.map((fs) => mapFileSlugToTranslated(fs, lang)),
|
||||||
|
]);
|
||||||
|
return parts.join('/');
|
||||||
|
};
|
||||||
|
|
||||||
const product = await getProductBySlug(productSlug, locale);
|
const product = await getProductBySlug(productSlug, locale);
|
||||||
if (!product) return {};
|
if (!product) return {};
|
||||||
|
|
||||||
@@ -72,9 +81,9 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -258,7 +267,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
(node.fields?.blockType === 'productTabs' ||
|
(node.fields?.blockType === 'productTabs' ||
|
||||||
node.fields?.blockType === 'productTechnicalData'),
|
node.fields?.blockType === 'productTechnicalData'),
|
||||||
);
|
);
|
||||||
const descriptionChildren = rootChildren.filter(
|
let descriptionChildren = rootChildren.filter(
|
||||||
(node: any) =>
|
(node: any) =>
|
||||||
!(
|
!(
|
||||||
node.type === 'block' &&
|
node.type === 'block' &&
|
||||||
@@ -267,6 +276,23 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If no standalone description nodes, extract from the productTabs block's embedded content
|
||||||
|
if (descriptionChildren.length === 0) {
|
||||||
|
const tabsBlock = rootChildren.find(
|
||||||
|
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||||
|
);
|
||||||
|
if (tabsBlock?.fields?.content?.root?.children) {
|
||||||
|
descriptionChildren = tabsBlock.fields.content.root.children.filter((node: any) => {
|
||||||
|
// Filter out MDX parsing artifacts like `}>`
|
||||||
|
if (node.type === 'paragraph' && node.children?.length === 1) {
|
||||||
|
const text = node.children[0]?.text?.trim();
|
||||||
|
return text !== '}>' && text !== '{' && text !== '}';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const descriptionContent = {
|
const descriptionContent = {
|
||||||
root: {
|
root: {
|
||||||
...product.content.root,
|
...product.content.root,
|
||||||
@@ -402,7 +428,13 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* 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-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
|
||||||
<PayloadRichText data={descriptionContent} />
|
{descriptionChildren.length > 0 ? (
|
||||||
|
<PayloadRichText data={descriptionContent} />
|
||||||
|
) : product.frontmatter.description ? (
|
||||||
|
<p className="text-lg md:text-xl text-text-secondary leading-relaxed">
|
||||||
|
{product.frontmatter.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||||
|
|
||||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||||
|
const contactSlug = await mapFileSlugToTranslated('contact', locale);
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
@@ -230,10 +231,10 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/${contactSlug}`}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
className="group whitespace-nowrap w-full md:w-auto md:h-16 px-6 md:px-10 text-sm md:text-xl"
|
||||||
>
|
>
|
||||||
{t('cta.button')}
|
{t('cta.button')}
|
||||||
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
|
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import { getAllPostsMetadata } from '@/lib/blog';
|
|||||||
import { getAllPagesMetadata } from '@/lib/pages';
|
import { getAllPagesMetadata } from '@/lib/pages';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
|
|
||||||
export const revalidate = 3600; // Revalidate every hour
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = process.env.CI
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
? 'http://klz.localhost'
|
|
||||||
: config.baseUrl || 'https://klz-cables.com';
|
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
|
|
||||||
const sitemapEntries: MetadataRoute.Sitemap = [];
|
const sitemapEntries: MetadataRoute.Sitemap = [];
|
||||||
|
|||||||
@@ -173,12 +173,12 @@ export default function Footer() {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
label: navT('contact'),
|
label: navT('contact'),
|
||||||
href: '/contact',
|
href: locale === 'de' ? '/kontakt' : '/contact',
|
||||||
location: 'footer_company',
|
location: 'footer_company',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export default function Header() {
|
|||||||
const segmentMap: Record<string, Record<string, string>> = {
|
const segmentMap: Record<string, Record<string, string>> = {
|
||||||
de: {
|
de: {
|
||||||
produkte: 'products',
|
produkte: 'products',
|
||||||
|
kontakt: 'contact',
|
||||||
impressum: 'legal-notice',
|
impressum: 'legal-notice',
|
||||||
datenschutz: 'privacy-policy',
|
datenschutz: 'privacy-policy',
|
||||||
agbs: 'terms',
|
agbs: 'terms',
|
||||||
@@ -103,6 +104,7 @@ export default function Header() {
|
|||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
products: 'produkte',
|
products: 'produkte',
|
||||||
|
contact: 'kontakt',
|
||||||
'legal-notice': 'impressum',
|
'legal-notice': 'impressum',
|
||||||
'privacy-policy': 'datenschutz',
|
'privacy-policy': 'datenschutz',
|
||||||
terms: 'agbs',
|
terms: 'agbs',
|
||||||
@@ -183,24 +185,40 @@ export default function Header() {
|
|||||||
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
||||||
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
||||||
>
|
>
|
||||||
<Link
|
{(() => {
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`;
|
||||||
onClick={() => {
|
const isActive =
|
||||||
setIsMobileMenuOpen(false);
|
item.href === '/'
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
label: item.label,
|
: pathname.startsWith(fullHref);
|
||||||
href: item.href,
|
return (
|
||||||
location: 'header_nav',
|
<Link
|
||||||
});
|
href={fullHref}
|
||||||
}}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={cn(
|
onClick={() => {
|
||||||
textColorClass,
|
setIsMobileMenuOpen(false);
|
||||||
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
)}
|
label: item.label,
|
||||||
>
|
href: item.href,
|
||||||
{item.label}
|
location: 'header_nav',
|
||||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
});
|
||||||
</Link>
|
}}
|
||||||
|
className={cn(
|
||||||
|
textColorClass,
|
||||||
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||||
|
isActive && 'text-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -bottom-2 left-0 h-1 bg-accent transition-all duration-500 rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]',
|
||||||
|
isActive ? 'w-full' : 'w-0 group-hover:w-full',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -256,7 +274,7 @@ export default function Header() {
|
|||||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
variant="white"
|
variant="white"
|
||||||
size="md"
|
size="md"
|
||||||
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
||||||
@@ -275,7 +293,7 @@ export default function Header() {
|
|||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50 transition-all duration-300',
|
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300',
|
||||||
textColorClass,
|
textColorClass,
|
||||||
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
|
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
|
||||||
)}
|
)}
|
||||||
@@ -320,7 +338,7 @@ export default function Header() {
|
|||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
'fixed inset-0 bg-primary z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
isMobileMenuOpen
|
isMobileMenuOpen
|
||||||
? 'opacity-100 translate-y-0'
|
? 'opacity-100 translate-y-0'
|
||||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
@@ -344,6 +362,15 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
aria-current={
|
||||||
|
(
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||||
|
)
|
||||||
|
? 'page'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsMobileMenuOpen(false);
|
setIsMobileMenuOpen(false);
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
@@ -352,7 +379,12 @@ export default function Header() {
|
|||||||
location: 'mobile_menu',
|
location: 'mobile_menu',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
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}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -388,7 +420,7 @@ export default function Header() {
|
|||||||
|
|
||||||
<div className="w-full max-w-xs">
|
<div className="w-full max-w-xs">
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||||
|
|||||||
@@ -23,10 +23,82 @@ const jsxConverters: JSXConverters = {
|
|||||||
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
||||||
text: ({ node }: any) => {
|
text: ({ node }: any) => {
|
||||||
const text = node.text;
|
const text = node.text;
|
||||||
|
// Handle markdown-style lists embedded in text nodes from MDX 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 (
|
||||||
|
<>
|
||||||
|
{prefix && (
|
||||||
|
<span dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
|
||||||
|
{!prefix.includes('<') ? prefix : undefined}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (text && (text.includes('<') || text.includes('data-start'))) {
|
if (text && (text.includes('<') || text.includes('data-start'))) {
|
||||||
return <span dangerouslySetInnerHTML={{ __html: text }} />;
|
return <span dangerouslySetInnerHTML={{ __html: text }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle markdown-style links [text](url) from MDX 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>{text}</strong>;
|
if (node.format === 1) return <strong>{text}</strong>;
|
||||||
if (node.format === 2) return <em>{text}</em>;
|
if (node.format === 2) return <em>{text}</em>;
|
||||||
return <span>{text}</span>;
|
return <span>{text}</span>;
|
||||||
|
|||||||
@@ -84,7 +84,8 @@
|
|||||||
"Leitungen",
|
"Leitungen",
|
||||||
"impressum",
|
"impressum",
|
||||||
"datenschutz",
|
"datenschutz",
|
||||||
"agbs"
|
"agbs",
|
||||||
|
"kontakt"
|
||||||
],
|
],
|
||||||
"ignorePaths": [
|
"ignorePaths": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|||||||
47
debug-sitemap.ts
Normal file
47
debug-sitemap.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
console.log('DEBUG SCRIPT STARTING...');
|
||||||
|
|
||||||
|
async function debug() {
|
||||||
|
console.log('Importing dependencies...');
|
||||||
|
try {
|
||||||
|
const { getAllProductsMetadata } = await import('./lib/mdx');
|
||||||
|
const { getAllPostsMetadata } = await import('./lib/blog');
|
||||||
|
const { getAllPagesMetadata } = await import('./lib/pages');
|
||||||
|
|
||||||
|
console.log('Dependencies imported.');
|
||||||
|
|
||||||
|
const locales = ['de', 'en'];
|
||||||
|
for (const locale of locales) {
|
||||||
|
console.log(`--- Locale: ${locale} ---`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const products = await getAllProductsMetadata(locale);
|
||||||
|
console.log(`Products (${locale}): ${products.length}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to get products for ${locale}:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const posts = await getAllPostsMetadata(locale);
|
||||||
|
console.log(`Posts (${locale}): ${posts.length}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to get posts for ${locale}:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pages = await getAllPagesMetadata(locale);
|
||||||
|
console.log(`Pages (${locale}): ${pages.length}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to get pages for ${locale}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Debug failed during setup/imports:', err);
|
||||||
|
}
|
||||||
|
console.log('DEBUG SCRIPT FINISHED.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug().catch((err) => {
|
||||||
|
console.error('Unhandled retransmission error in debug():', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
11
lib/blog.ts
11
lib/blog.ts
@@ -60,13 +60,15 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
|||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: {
|
where: {
|
||||||
slug: { equals: slug },
|
slug: { equals: slug },
|
||||||
locale: { equals: locale },
|
locale: { equals: locale },
|
||||||
|
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
draft: process.env.NODE_ENV === 'development',
|
draft: isDev,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,19 +109,22 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
|||||||
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
// Query only published posts (access checks applied automatically by Payload!)
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: {
|
where: {
|
||||||
locale: {
|
locale: {
|
||||||
equals: locale,
|
equals: locale,
|
||||||
},
|
},
|
||||||
|
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
sort: '-date',
|
sort: '-date',
|
||||||
draft: process.env.NODE_ENV === 'development', // Includes Drafts if running locally
|
draft: isDev,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[Payload] getAllPosts for ${locale}: Found ${docs.length} docs`);
|
||||||
|
|
||||||
return docs.map((doc) => {
|
return docs.map((doc) => {
|
||||||
return {
|
return {
|
||||||
slug: doc.slug,
|
slug: doc.slug,
|
||||||
|
|||||||
59
lib/mdx.ts
59
lib/mdx.ts
@@ -186,35 +186,30 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
|||||||
select: selectFields,
|
select: selectFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
let products: ProductMdx[] = result.docs
|
console.log(`[Payload] getAllProducts for ${locale}: Found ${result.docs.length} docs`);
|
||||||
.filter((doc) => {
|
|
||||||
const resolvedImages = ((doc.images as any[]) || [])
|
|
||||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
|
||||||
.filter(Boolean);
|
|
||||||
return resolvedImages.length > 0;
|
|
||||||
})
|
|
||||||
.map((doc) => {
|
|
||||||
const resolvedImages = ((doc.images as any[]) || [])
|
|
||||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
|
||||||
.filter(Boolean) as string[];
|
|
||||||
|
|
||||||
const plainCategories = Array.isArray(doc.categories)
|
let products: ProductMdx[] = result.docs.map((doc) => {
|
||||||
? doc.categories.map((c: any) => String(c.category))
|
const resolvedImages = ((doc.images as any[]) || [])
|
||||||
: [];
|
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
return {
|
const plainCategories = Array.isArray(doc.categories)
|
||||||
slug: String(doc.slug),
|
? doc.categories.map((c: any) => String(c.category))
|
||||||
frontmatter: {
|
: [];
|
||||||
title: String(doc.title),
|
|
||||||
sku: doc.sku ? String(doc.sku) : '',
|
return {
|
||||||
description: doc.description ? String(doc.description) : '',
|
slug: String(doc.slug),
|
||||||
categories: plainCategories,
|
frontmatter: {
|
||||||
images: resolvedImages,
|
title: String(doc.title),
|
||||||
locale: String(doc.locale),
|
sku: doc.sku ? String(doc.sku) : '',
|
||||||
},
|
description: doc.description ? String(doc.description) : '',
|
||||||
content: null,
|
categories: plainCategories,
|
||||||
};
|
images: resolvedImages,
|
||||||
});
|
locale: String(doc.locale),
|
||||||
|
},
|
||||||
|
content: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Also include English fallbacks for slugs not in this locale
|
// Also include English fallbacks for slugs not in this locale
|
||||||
if (locale !== 'en') {
|
if (locale !== 'en') {
|
||||||
@@ -227,14 +222,12 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
|||||||
select: selectFields,
|
select: selectFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Payload] getAllProducts (en fallbacks) for ${locale}: Found ${enResult.docs.length} docs`,
|
||||||
|
);
|
||||||
|
|
||||||
const fallbacks = enResult.docs
|
const fallbacks = enResult.docs
|
||||||
.filter((doc) => !localeSlugs.has(doc.slug))
|
.filter((doc) => !localeSlugs.has(doc.slug))
|
||||||
.filter((doc) => {
|
|
||||||
const resolvedImages = ((doc.images as any[]) || [])
|
|
||||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
|
||||||
.filter(Boolean);
|
|
||||||
return resolvedImages.length > 0;
|
|
||||||
})
|
|
||||||
.map((doc) => {
|
.map((doc) => {
|
||||||
const resolvedImages = ((doc.images as any[]) || [])
|
const resolvedImages = ((doc.images as any[]) || [])
|
||||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||||
|
|||||||
@@ -415,16 +415,24 @@ const nextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return {
|
||||||
{
|
beforeFiles: [
|
||||||
source: '/de/produkte',
|
{
|
||||||
destination: '/de/products',
|
source: '/de/produkte',
|
||||||
},
|
destination: '/de/products',
|
||||||
{
|
},
|
||||||
source: '/de/produkte/:path*',
|
{
|
||||||
destination: '/de/products/:path*',
|
source: '/de/produkte/:path*',
|
||||||
},
|
destination: '/de/products/:path*',
|
||||||
];
|
},
|
||||||
|
{
|
||||||
|
source: '/de/kontakt',
|
||||||
|
destination: '/de/contact',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
afterFiles: [],
|
||||||
|
fallback: [],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
||||||
"check:html": "tsx ./scripts/check-html.ts",
|
"check:html": "tsx ./scripts/check-html.ts",
|
||||||
"check:http": "tsx ./scripts/check-http.ts",
|
"check:http": "tsx ./scripts/check-http.ts",
|
||||||
|
"check:locale": "tsx ./scripts/check-locale.ts",
|
||||||
"check:spell": "cspell \"content/**/*.{md,mdx}\" \"app/**/*.tsx\" \"components/**/*.tsx\"",
|
"check:spell": "cspell \"content/**/*.{md,mdx}\" \"app/**/*.tsx\" \"components/**/*.tsx\"",
|
||||||
"check:security": "tsx ./scripts/check-security.ts",
|
"check:security": "tsx ./scripts/check-security.ts",
|
||||||
"check:links": "bash ./scripts/check-links.sh",
|
"check:links": "bash ./scripts/check-links.sh",
|
||||||
|
|||||||
191
scripts/check-locale.ts
Normal file
191
scripts/check-locale.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale & Language Switcher Smoke Test
|
||||||
|
*
|
||||||
|
* For every URL in the sitemap:
|
||||||
|
* 1. Fetches the page HTML
|
||||||
|
* 2. Extracts <link rel="alternate" hreflang="..." href="..."> tags
|
||||||
|
* 3. Verifies each alternate URL uses correctly translated slugs
|
||||||
|
* 4. Verifies each alternate URL returns HTTP 200
|
||||||
|
*/
|
||||||
|
|
||||||
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
|
// Expected slug translations: German key → English value
|
||||||
|
const SLUG_MAP: Record<string, string> = {
|
||||||
|
produkte: 'products',
|
||||||
|
kontakt: 'contact',
|
||||||
|
niederspannungskabel: 'low-voltage-cables',
|
||||||
|
mittelspannungskabel: 'medium-voltage-cables',
|
||||||
|
hochspannungskabel: 'high-voltage-cables',
|
||||||
|
solarkabel: 'solar-cables',
|
||||||
|
impressum: 'legal-notice',
|
||||||
|
datenschutz: 'privacy-policy',
|
||||||
|
agbs: 'terms',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reverse map: English → German
|
||||||
|
const REVERSE_SLUG_MAP: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` };
|
||||||
|
|
||||||
|
function getExpectedTranslation(
|
||||||
|
sourcePath: string,
|
||||||
|
sourceLocale: string,
|
||||||
|
targetLocale: string,
|
||||||
|
): string {
|
||||||
|
const segments = sourcePath.split('/').filter(Boolean);
|
||||||
|
// First segment is locale
|
||||||
|
segments[0] = targetLocale;
|
||||||
|
|
||||||
|
const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP;
|
||||||
|
|
||||||
|
return (
|
||||||
|
'/' +
|
||||||
|
segments
|
||||||
|
.map((seg, i) => {
|
||||||
|
if (i === 0) return seg; // locale
|
||||||
|
return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
|
||||||
|
})
|
||||||
|
.join('/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🌐 Starting Locale Smoke Test for: ${targetUrl}\n`);
|
||||||
|
|
||||||
|
// 1. Fetch sitemap
|
||||||
|
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||||
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
const sitemapRes = await axios.get(sitemapUrl, { headers, validateStatus: (s) => s < 400 });
|
||||||
|
const $sitemap = cheerio.load(sitemapRes.data, { xmlMode: true });
|
||||||
|
|
||||||
|
let urls = $sitemap('url loc')
|
||||||
|
.map((_i, el) => $sitemap(el).text())
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const urlPattern = /https?:\/\/[^/]+/;
|
||||||
|
urls = [...new Set(urls)]
|
||||||
|
.filter((u) => u.startsWith('http'))
|
||||||
|
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
console.log(`✅ Found ${urls.length} URLs in sitemap.\n`);
|
||||||
|
|
||||||
|
let totalChecked = 0;
|
||||||
|
let totalPassed = 0;
|
||||||
|
let totalFailed = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
const locale = path.split('/')[1];
|
||||||
|
if (!locale || !['de', 'en'].includes(locale)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { headers, validateStatus: null });
|
||||||
|
if (res.status >= 400) continue; // Skip pages that are already broken (check-http catches those)
|
||||||
|
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
|
||||||
|
// Extract hreflang alternate links
|
||||||
|
const alternates: { hreflang: string; href: string }[] = [];
|
||||||
|
$('link[rel="alternate"][hreflang]').each((_i, el) => {
|
||||||
|
const hreflang = $(el).attr('hreflang') || '';
|
||||||
|
let href = $(el).attr('href') || '';
|
||||||
|
if (href && hreflang && hreflang !== 'x-default') {
|
||||||
|
href = href.replace(urlPattern, targetUrl.replace(/\/$/, ''));
|
||||||
|
alternates.push({ hreflang, href });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (alternates.length === 0) {
|
||||||
|
// Some pages may not have alternates, that's OK
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalChecked++;
|
||||||
|
|
||||||
|
// Validate each alternate
|
||||||
|
let pageOk = true;
|
||||||
|
|
||||||
|
for (const alt of alternates) {
|
||||||
|
if (alt.hreflang === locale) continue; // Same locale, skip
|
||||||
|
|
||||||
|
// 1. Check slug translation is correct
|
||||||
|
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
|
||||||
|
const actualPath = new URL(alt.href).pathname;
|
||||||
|
|
||||||
|
if (actualPath !== expectedPath) {
|
||||||
|
console.error(
|
||||||
|
`❌ SLUG MISMATCH: ${path} → hreflang="${alt.hreflang}" expected ${expectedPath} but got ${actualPath}`,
|
||||||
|
);
|
||||||
|
failures.push(
|
||||||
|
`Slug mismatch: ${path} → ${alt.hreflang}: expected ${expectedPath}, got ${actualPath}`,
|
||||||
|
);
|
||||||
|
pageOk = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check alternate URL returns 200
|
||||||
|
try {
|
||||||
|
const altRes = await axios.get(alt.href, {
|
||||||
|
headers,
|
||||||
|
validateStatus: null,
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
if (altRes.status >= 400) {
|
||||||
|
console.error(`❌ BROKEN ALTERNATE: ${path} → ${alt.href} returned ${altRes.status}`);
|
||||||
|
failures.push(`Broken alternate: ${path} → ${alt.href} (${altRes.status})`);
|
||||||
|
pageOk = false;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ NETWORK ERROR: ${path} → ${alt.href}: ${err.message}`);
|
||||||
|
failures.push(`Network error: ${path} → ${alt.href}: ${err.message}`);
|
||||||
|
pageOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageOk) {
|
||||||
|
console.log(
|
||||||
|
`✅ ${path} — alternates OK (${alternates
|
||||||
|
.map((a) => a.hreflang)
|
||||||
|
.filter((h) => h !== locale)
|
||||||
|
.join(', ')})`,
|
||||||
|
);
|
||||||
|
totalPassed++;
|
||||||
|
} else {
|
||||||
|
totalFailed++;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ NETWORK ERROR fetching ${url}: ${err.message}`);
|
||||||
|
totalFailed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${'─'.repeat(60)}`);
|
||||||
|
console.log(`📊 Locale Smoke Test Results:`);
|
||||||
|
console.log(` Pages checked: ${totalChecked}`);
|
||||||
|
console.log(` Passed: ${totalPassed}`);
|
||||||
|
console.log(` Failed: ${totalFailed}`);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log(`\n❌ Failures:`);
|
||||||
|
failures.forEach((f) => console.log(` • ${f}`));
|
||||||
|
console.log(`\n❌ Locale Smoke Test FAILED.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(`\n✨ All locale alternates are correctly translated and reachable!`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`\n❌ Critical error:`, err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -20,13 +20,17 @@ fi
|
|||||||
DIRECTION="${1:-}" # push | pull
|
DIRECTION="${1:-}" # push | pull
|
||||||
TARGET="${2:-}" # testing | prod
|
TARGET="${2:-}" # testing | prod
|
||||||
SSH_HOST="root@alpha.mintel.me"
|
SSH_HOST="root@alpha.mintel.me"
|
||||||
DB_USER="${PAYLOAD_DB_USER:-payload}"
|
LOCAL_DB_USER="${PAYLOAD_DB_USER:-payload}"
|
||||||
DB_NAME="${PAYLOAD_DB_NAME:-payload}"
|
LOCAL_DB_NAME="${PAYLOAD_DB_NAME:-payload}"
|
||||||
LOCAL_DB_CONTAINER="klz-2026-klz-db-1"
|
LOCAL_DB_CONTAINER="klz-2026-klz-db-1"
|
||||||
LOCAL_MEDIA_DIR="./public/media"
|
LOCAL_MEDIA_DIR="./public/media"
|
||||||
BACKUP_DIR="./backups"
|
BACKUP_DIR="./backups"
|
||||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# Remote credentials (resolved per-target from server env files)
|
||||||
|
REMOTE_DB_USER=""
|
||||||
|
REMOTE_DB_NAME=""
|
||||||
|
|
||||||
# Migration names to insert after restore (keeps Payload from prompting)
|
# Migration names to insert after restore (keeps Payload from prompting)
|
||||||
MIGRATIONS=(
|
MIGRATIONS=(
|
||||||
"20260223_195005_products_collection:1"
|
"20260223_195005_products_collection:1"
|
||||||
@@ -42,12 +46,14 @@ resolve_target() {
|
|||||||
REMOTE_DB_CONTAINER="klz-testing-klz-db-1"
|
REMOTE_DB_CONTAINER="klz-testing-klz-db-1"
|
||||||
REMOTE_APP_CONTAINER="klz-testing-klz-app-1"
|
REMOTE_APP_CONTAINER="klz-testing-klz-app-1"
|
||||||
REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-testing_klz_media_data/_data"
|
REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-testing_klz_media_data/_data"
|
||||||
|
REMOTE_SITE_DIR="/home/deploy/sites/testing.klz-cables.com"
|
||||||
;;
|
;;
|
||||||
prod|production)
|
prod|production)
|
||||||
REMOTE_PROJECT="klz-cablescom"
|
REMOTE_PROJECT="klz-cablescom"
|
||||||
REMOTE_DB_CONTAINER="klz-cablescom-klz-db-1"
|
REMOTE_DB_CONTAINER="klz-cablescom-klz-db-1"
|
||||||
REMOTE_APP_CONTAINER="klz-cablescom-klz-app-1"
|
REMOTE_APP_CONTAINER="klz-cablescom-klz-app-1"
|
||||||
REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-cablescom_klz_media_data/_data"
|
REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-cablescom_klz_media_data/_data"
|
||||||
|
REMOTE_SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "❌ Unknown target: $TARGET"
|
echo "❌ Unknown target: $TARGET"
|
||||||
@@ -55,12 +61,40 @@ resolve_target() {
|
|||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Auto-detect remote DB credentials from the env file on the server
|
||||||
|
echo "🔍 Detecting $TARGET database credentials..."
|
||||||
|
REMOTE_DB_USER=$(ssh "$SSH_HOST" "grep -h '^PAYLOAD_DB_USER=' $REMOTE_SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "")
|
||||||
|
REMOTE_DB_NAME=$(ssh "$SSH_HOST" "grep -h '^PAYLOAD_DB_NAME=' $REMOTE_SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "")
|
||||||
|
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||||
|
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||||
|
echo " User: $REMOTE_DB_USER | DB: $REMOTE_DB_NAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Ensure local DB is running ─────────────────────────────────────────────
|
||||||
|
ensure_local_db() {
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q "$LOCAL_DB_CONTAINER"; then
|
||||||
|
echo "⏳ Local DB container not running. Starting..."
|
||||||
|
docker compose up -d klz-db
|
||||||
|
echo "⏳ Waiting for local DB to be ready..."
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if docker exec "$LOCAL_DB_CONTAINER" pg_isready -U "$LOCAL_DB_USER" -q 2>/dev/null; then
|
||||||
|
echo "✅ Local DB is ready."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "❌ Local DB failed to start."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Sanitize migrations table ──────────────────────────────────────────────
|
# ── Sanitize migrations table ──────────────────────────────────────────────
|
||||||
sanitize_migrations() {
|
sanitize_migrations() {
|
||||||
local container="$1"
|
local container="$1"
|
||||||
local exec_prefix="$2" # "" for local, "ssh $SSH_HOST" for remote
|
local db_user="$2"
|
||||||
|
local db_name="$3"
|
||||||
|
local is_remote="$4" # "true" or "false"
|
||||||
|
|
||||||
echo "🔧 Sanitizing payload_migrations table..."
|
echo "🔧 Sanitizing payload_migrations table..."
|
||||||
local SQL="DELETE FROM payload_migrations WHERE batch = -1;"
|
local SQL="DELETE FROM payload_migrations WHERE batch = -1;"
|
||||||
@@ -70,10 +104,10 @@ sanitize_migrations() {
|
|||||||
SQL="$SQL INSERT INTO payload_migrations (name, batch) SELECT '$name', $batch WHERE NOT EXISTS (SELECT 1 FROM payload_migrations WHERE name = '$name');"
|
SQL="$SQL INSERT INTO payload_migrations (name, batch) SELECT '$name', $batch WHERE NOT EXISTS (SELECT 1 FROM payload_migrations WHERE name = '$name');"
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$exec_prefix" ]; then
|
if [ "$is_remote" = "true" ]; then
|
||||||
docker exec "$container" psql -U "$DB_USER" -d "$DB_NAME" -c "$SQL"
|
ssh "$SSH_HOST" "docker exec $container psql -U $db_user -d $db_name -c \"$SQL\""
|
||||||
else
|
else
|
||||||
$exec_prefix "docker exec $container psql -U $DB_USER -d $DB_NAME -c \"$SQL\""
|
docker exec "$container" psql -U "$db_user" -d "$db_name" -c "$SQL"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,14 +116,14 @@ backup_local_db() {
|
|||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
local file="$BACKUP_DIR/payload_pre_sync_${TIMESTAMP}.sql.gz"
|
local file="$BACKUP_DIR/payload_pre_sync_${TIMESTAMP}.sql.gz"
|
||||||
echo "📦 Creating safety backup of local DB → $file"
|
echo "📦 Creating safety backup of local DB → $file"
|
||||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$file"
|
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$file"
|
||||||
echo "✅ Backup: $file ($(du -h "$file" | cut -f1))"
|
echo "✅ Backup: $file ($(du -h "$file" | cut -f1))"
|
||||||
}
|
}
|
||||||
|
|
||||||
backup_remote_db() {
|
backup_remote_db() {
|
||||||
local file="/tmp/payload_pre_sync_${TIMESTAMP}.sql.gz"
|
local file="/tmp/payload_pre_sync_${TIMESTAMP}.sql.gz"
|
||||||
echo "📦 Creating safety backup of $TARGET DB → $SSH_HOST:$file"
|
echo "📦 Creating safety backup of $TARGET DB → $SSH_HOST:$file"
|
||||||
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER -d $DB_NAME --clean --if-exists | gzip > $file"
|
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > $file"
|
||||||
echo "✅ Remote backup: $file"
|
echo "✅ Remote backup: $file"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,27 +140,34 @@ do_push() {
|
|||||||
echo ""
|
echo ""
|
||||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||||
|
|
||||||
|
# 0. Ensure local DB is running
|
||||||
|
ensure_local_db
|
||||||
|
|
||||||
# 1. Safety backup of remote
|
# 1. Safety backup of remote
|
||||||
backup_remote_db
|
backup_remote_db
|
||||||
|
|
||||||
# 2. Dump local DB
|
# 2. Dump local DB
|
||||||
echo "📤 Dumping local database..."
|
echo "📤 Dumping local database..."
|
||||||
local dump="/tmp/payload_push_${TIMESTAMP}.sql.gz"
|
local dump="/tmp/payload_push_${TIMESTAMP}.sql.gz"
|
||||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$dump"
|
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$dump"
|
||||||
|
|
||||||
# 3. Transfer and restore
|
# 3. Transfer and restore
|
||||||
echo "📤 Transferring to $SSH_HOST..."
|
echo "📤 Transferring to $SSH_HOST..."
|
||||||
scp "$dump" "$SSH_HOST:/tmp/payload_push.sql.gz"
|
scp "$dump" "$SSH_HOST:/tmp/payload_push.sql.gz"
|
||||||
|
|
||||||
echo "🔄 Restoring database on $TARGET..."
|
echo "🔄 Restoring database on $TARGET..."
|
||||||
ssh "$SSH_HOST" "gunzip -c /tmp/payload_push.sql.gz | docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER -d $DB_NAME --quiet"
|
ssh "$SSH_HOST" "gunzip -c /tmp/payload_push.sql.gz | docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --quiet"
|
||||||
|
|
||||||
# 4. Sanitize migrations
|
# 4. Sanitize migrations
|
||||||
sanitize_migrations "$REMOTE_DB_CONTAINER" "ssh $SSH_HOST"
|
sanitize_migrations "$REMOTE_DB_CONTAINER" "$REMOTE_DB_USER" "$REMOTE_DB_NAME" "true"
|
||||||
|
|
||||||
# 5. Sync media
|
# 5. Sync media
|
||||||
echo "🖼️ Syncing media files..."
|
echo "🖼️ Syncing media files..."
|
||||||
rsync -az --delete --info=progress2 "$LOCAL_MEDIA_DIR/" "$SSH_HOST:$REMOTE_MEDIA_VOLUME/"
|
rsync -az --delete --progress "$LOCAL_MEDIA_DIR/" "$SSH_HOST:$REMOTE_MEDIA_VOLUME/"
|
||||||
|
|
||||||
|
# Fix ownership: rsync preserves local UID, but container runs as nextjs (1001)
|
||||||
|
echo "🔑 Fixing media file permissions..."
|
||||||
|
ssh "$SSH_HOST" "docker exec -u 0 $REMOTE_APP_CONTAINER chown -R 1001:65533 /app/public/media/ 2>/dev/null || true"
|
||||||
|
|
||||||
# 6. Restart app
|
# 6. Restart app
|
||||||
echo "🔄 Restarting $TARGET app container..."
|
echo "🔄 Restarting $TARGET app container..."
|
||||||
@@ -153,19 +194,22 @@ do_pull() {
|
|||||||
echo ""
|
echo ""
|
||||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||||
|
|
||||||
|
# 0. Ensure local DB is running
|
||||||
|
ensure_local_db
|
||||||
|
|
||||||
# 1. Safety backup of local
|
# 1. Safety backup of local
|
||||||
backup_local_db
|
backup_local_db
|
||||||
|
|
||||||
# 2. Dump remote DB
|
# 2. Dump remote DB
|
||||||
echo "📥 Dumping $TARGET database..."
|
echo "📥 Dumping $TARGET database..."
|
||||||
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER -d $DB_NAME --clean --if-exists | gzip > /tmp/payload_pull.sql.gz"
|
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > /tmp/payload_pull.sql.gz"
|
||||||
|
|
||||||
# 3. Transfer and restore
|
# 3. Transfer and restore
|
||||||
echo "📥 Downloading from $SSH_HOST..."
|
echo "📥 Downloading from $SSH_HOST..."
|
||||||
scp "$SSH_HOST:/tmp/payload_pull.sql.gz" "/tmp/payload_pull.sql.gz"
|
scp "$SSH_HOST:/tmp/payload_pull.sql.gz" "/tmp/payload_pull.sql.gz"
|
||||||
|
|
||||||
echo "🔄 Restoring database locally..."
|
echo "🔄 Restoring database locally..."
|
||||||
gunzip -c "/tmp/payload_pull.sql.gz" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" --quiet
|
gunzip -c "/tmp/payload_pull.sql.gz" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --quiet
|
||||||
|
|
||||||
# 4. Sync media
|
# 4. Sync media
|
||||||
echo "🖼️ Syncing media files..."
|
echo "🖼️ Syncing media files..."
|
||||||
|
|||||||
Reference in New Issue
Block a user