Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3334937b67 | |||
| 246e6540d4 | |||
| aa299b285d | |||
| 2be0d1ea92 | |||
| 0554460153 | |||
| 4ae45b1aa7 | |||
| 76cbdaa37e | |||
| 5b1a2eff7f | |||
| 60422da644 | |||
| fc6da4e9aa | |||
| f7411d3dc4 | |||
| 7be2ec605c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ backups/
|
||||
|
||||
# Local data / backups
|
||||
.data/
|
||||
scratch/
|
||||
|
||||
@@ -266,18 +266,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
let technicalItems = [];
|
||||
try {
|
||||
const tabMatch =
|
||||
typeof product.content === 'string' &&
|
||||
product.content.match(/<Block type="productTabs" data={({.*?})}\s*\/>/s);
|
||||
if (tabMatch && tabMatch[1]) {
|
||||
const data = JSON.parse(tabMatch[1]);
|
||||
technicalItems = data.technicalItems || [];
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore JSON parse errors for AST
|
||||
}
|
||||
let technicalItems: any[] = [];
|
||||
|
||||
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||
const isFallback = (product.frontmatter as any).isFallback;
|
||||
@@ -296,8 +285,20 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
if (typeof product.content === 'string') {
|
||||
const tabBlockMatch = product.content.match(/<Block type="productTabs".*?\/>/s);
|
||||
if (tabBlockMatch) {
|
||||
descriptionContent = product.content.replace(tabBlockMatch[0], '');
|
||||
technicalContent = tabBlockMatch[0];
|
||||
descriptionContent = product.content.replace(tabBlockMatch[0], '');
|
||||
|
||||
const dataMatch =
|
||||
technicalContent.match(/data="({.*?})"/s) || technicalContent.match(/data={({.*?})}/s);
|
||||
if (dataMatch && dataMatch[1]) {
|
||||
try {
|
||||
const rawJsonStr = dataMatch[1].replace(/"/g, '"');
|
||||
const data = JSON.parse(rawJsonStr);
|
||||
technicalItems = data.technicalItems || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to parse productTabs data for page:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,24 +32,18 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
const productName = formData.get('productName') as string | null;
|
||||
|
||||
if (!name || !email || !message) {
|
||||
logger.warn(
|
||||
{
|
||||
name: !!name,
|
||||
email: !!email,
|
||||
message: !!message,
|
||||
},
|
||||
'Missing required fields in contact form',
|
||||
);
|
||||
logger.warn('Missing required fields in contact form', {
|
||||
name: !!name,
|
||||
email: !!email,
|
||||
message: !!message,
|
||||
});
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
email,
|
||||
},
|
||||
'Payload CMS saving skipped because it has been removed',
|
||||
);
|
||||
logger.info('Payload CMS saving skipped because it has been removed', {
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
email,
|
||||
});
|
||||
|
||||
// 1.5. Simple Fail-Safe Backup to Disk
|
||||
// To ensure leads are never lost if email fails or Gotify is down, we append them to a local JSON Lines file.
|
||||
@@ -68,13 +62,13 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
message,
|
||||
};
|
||||
fs.appendFileSync(backupFile, JSON.stringify(leadData) + '\n');
|
||||
logger.info({ backupFile }, 'Successfully saved lead to local backup file');
|
||||
logger.info('Successfully saved lead to local backup file', { backupFile });
|
||||
} catch (backupError) {
|
||||
logger.error({ error: String(backupError) }, 'Failed to write to local leads backup');
|
||||
logger.error('Failed to write to local leads backup', { error: String(backupError) });
|
||||
}
|
||||
|
||||
// 2. Send Emails
|
||||
logger.info({ email, productName }, 'Sending branded emails');
|
||||
logger.info('Sending branded emails', { email, productName });
|
||||
|
||||
const notificationSubject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
@@ -94,7 +88,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
);
|
||||
|
||||
if (!isTestSubmission) {
|
||||
logger.info({ recipients: env.MAIL_RECIPIENTS }, 'Sending internal notification');
|
||||
logger.info('Sending internal notification', { recipients: env.MAIL_RECIPIENTS });
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
@@ -102,21 +96,15 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
});
|
||||
|
||||
if (notificationResult.success) {
|
||||
logger.info(
|
||||
{
|
||||
messageId: notificationResult.messageId,
|
||||
},
|
||||
'Notification email sent successfully',
|
||||
);
|
||||
logger.info('Notification email sent successfully', {
|
||||
messageId: notificationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error(
|
||||
{
|
||||
error: notificationResult.error,
|
||||
subject: notificationSubject,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
},
|
||||
'Notification email DELIVERY FAILED',
|
||||
);
|
||||
logger.error('Notification email DELIVERY FAILED', {
|
||||
error: notificationResult.error,
|
||||
subject: notificationSubject,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
});
|
||||
services.errors.captureException(
|
||||
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||
{
|
||||
@@ -127,7 +115,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info({ email }, 'Skipping notification email for test submission');
|
||||
logger.info('Skipping notification email for test submission', { email });
|
||||
}
|
||||
|
||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||
@@ -140,7 +128,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
);
|
||||
|
||||
if (!isTestSubmission) {
|
||||
logger.info({ to: email }, 'Sending customer confirmation');
|
||||
logger.info('Sending customer confirmation', { to: email });
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
@@ -148,28 +136,22 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
});
|
||||
|
||||
if (confirmationResult.success) {
|
||||
logger.info(
|
||||
{
|
||||
messageId: confirmationResult.messageId,
|
||||
},
|
||||
'Confirmation email sent successfully',
|
||||
);
|
||||
logger.info('Confirmation email sent successfully', {
|
||||
messageId: confirmationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error(
|
||||
{
|
||||
error: confirmationResult.error,
|
||||
subject: confirmationSubject,
|
||||
to: email,
|
||||
},
|
||||
'Confirmation email DELIVERY FAILED',
|
||||
);
|
||||
logger.error('Confirmation email DELIVERY FAILED', {
|
||||
error: confirmationResult.error,
|
||||
subject: confirmationSubject,
|
||||
to: email,
|
||||
});
|
||||
services.errors.captureException(
|
||||
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
||||
{ action: 'sendContactFormAction_confirmation', email },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info({ email }, 'Skipping confirmation email for test submission');
|
||||
logger.info('Skipping confirmation email for test submission', { email });
|
||||
}
|
||||
|
||||
// Notify via Gotify (Internal)
|
||||
@@ -187,13 +169,10 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
{
|
||||
error: errorMsg,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
'Failed to send branded emails',
|
||||
);
|
||||
logger.error('Failed to send branded emails', {
|
||||
error: errorMsg,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import VideoSection from './home/VideoSection';
|
||||
import CTA from './home/CTA';
|
||||
import { AgbHistoryBlock } from './AgbHistoryBlock';
|
||||
import { PDFDownloadBlock } from './PDFDownloadBlock';
|
||||
import ProductTechnicalData from './ProductTechnicalData';
|
||||
function ContactSection(props: any) {
|
||||
return (
|
||||
<div className="p-8 border-2 border-dashed border-primary my-8 text-center text-primary font-bold">
|
||||
@@ -100,6 +101,8 @@ async function Block(props: any) {
|
||||
return <AgbHistoryBlock {...data} />;
|
||||
case 'pdfDownload':
|
||||
return <PDFDownloadBlock {...data} />;
|
||||
case 'productTabs':
|
||||
return <ProductTechnicalData data={data} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
114
lib/mdx-utils.ts
114
lib/mdx-utils.ts
@@ -7,14 +7,36 @@ export function fixMdxDataProps(content: string): string {
|
||||
let endIndex = -1;
|
||||
const startObj = dataIndex + 6; // index of the first '{' in 'data={{'
|
||||
|
||||
let inString = false;
|
||||
let isEscaped = false;
|
||||
|
||||
for (let i = startObj; i < fixedContent.length; i++) {
|
||||
if (fixedContent[i] === '{') {
|
||||
openCount++;
|
||||
} else if (fixedContent[i] === '}') {
|
||||
openCount--;
|
||||
if (openCount === 0) {
|
||||
endIndex = i;
|
||||
break;
|
||||
const char = fixedContent[i];
|
||||
|
||||
if (isEscaped) {
|
||||
isEscaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
isEscaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString) {
|
||||
if (char === '{') {
|
||||
openCount++;
|
||||
} else if (char === '}') {
|
||||
openCount--;
|
||||
if (openCount === 0) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,3 +66,81 @@ export function fixMdxDataProps(content: string): string {
|
||||
|
||||
return fixedContent;
|
||||
}
|
||||
|
||||
export function lexicalToMarkdown(node: any, depth = 0): string {
|
||||
if (!node) return '';
|
||||
|
||||
if (typeof node === 'string') return node;
|
||||
|
||||
if (node.type === 'text') {
|
||||
let text = node.text || '';
|
||||
if (node.format === 1) text = `**${text}**`; // Bold
|
||||
if (node.format === 2) text = `*${text}*`; // Italic
|
||||
return text;
|
||||
}
|
||||
|
||||
if (node.type === 'heading') {
|
||||
const level = parseInt((node.tag || 'h1').replace('h', '')) || 1;
|
||||
const prefix = '#'.repeat(level);
|
||||
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||
return `${prefix} ${text}\n\n`;
|
||||
}
|
||||
|
||||
if (node.type === 'paragraph') {
|
||||
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||
return text.trim() ? `${text}\n\n` : '\n';
|
||||
}
|
||||
|
||||
if (node.type === 'list') {
|
||||
const isOrdered = node.tag === 'ol';
|
||||
const items = (node.children || [])
|
||||
.map((c: any, index: number) => {
|
||||
const prefix = isOrdered ? `${index + 1}.` : '-';
|
||||
return `${prefix} ${lexicalToMarkdown(c, depth + 1).trim()}`;
|
||||
})
|
||||
.join('\n');
|
||||
return `${items}\n\n`;
|
||||
}
|
||||
|
||||
if (node.type === 'listitem') {
|
||||
return (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||
}
|
||||
|
||||
if (node.type === 'quote') {
|
||||
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||
return `> ${text}\n\n`;
|
||||
}
|
||||
|
||||
if (node.type === 'link') {
|
||||
const url = node.fields?.url || node.url || '';
|
||||
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||
return `[${text}](${url})`;
|
||||
}
|
||||
|
||||
if (node.type === 'upload') {
|
||||
const url = node.value?.url || '';
|
||||
const alt = node.value?.alt || node.value?.filename || '';
|
||||
return `\n\n`;
|
||||
}
|
||||
|
||||
if (node.type === 'block') {
|
||||
const blockType = node.fields?.blockType;
|
||||
if (blockType === 'contactSection') {
|
||||
return `<ContactSection showMap={${!!node.fields?.showMap}} showForm={${!!node.fields?.showForm}} showHours={${!!node.fields?.showHours}} />\n\n`;
|
||||
}
|
||||
if (blockType === 'heroSection') {
|
||||
return `<HeroSection badge="${node.fields?.badge || ''}" title="${node.fields?.title || ''}" subtitle="${node.fields?.subtitle || ''}" alignment="${node.fields?.alignment || 'left'}" />\n\n`;
|
||||
}
|
||||
if (blockType === 'features') {
|
||||
return `<FeaturesSection layout="${node.fields?.layout || ''}" />\n\n`;
|
||||
}
|
||||
// Generic MDX block wrapper if unknown
|
||||
return `<Block type="${blockType}" data={${JSON.stringify(node.fields)}} />\n\n`;
|
||||
}
|
||||
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
return node.children.map((c: any) => lexicalToMarkdown(c, depth)).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
ignoreErrors: [
|
||||
'ChunkLoadError',
|
||||
'Failed to fetch dynamically imported module',
|
||||
'Failed to find Server Action',
|
||||
],
|
||||
});
|
||||
}
|
||||
return Sentry;
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.3.29",
|
||||
"version": "2.3.35",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
47
tests/glitchtip-error-reporting.test.ts
Normal file
47
tests/glitchtip-error-reporting.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GlitchtipErrorReportingService } from '../lib/services/errors/glitchtip-error-reporting-service';
|
||||
|
||||
// Mock the LoggerService
|
||||
const mockLogger = {
|
||||
child: vi.fn().mockReturnThis(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const mockSentryInit = vi.fn();
|
||||
|
||||
vi.mock('@sentry/nextjs', () => ({
|
||||
init: (...args: any[]) => mockSentryInit(...args),
|
||||
captureException: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('GlitchtipErrorReportingService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should ignore version drift errors (ChunkLoadError and Server Action) in Sentry config', async () => {
|
||||
// Simulate window to force client-side init behavior instantly
|
||||
const originalWindow = global.window;
|
||||
global.window = {
|
||||
requestIdleCallback: (cb: Function) => cb(),
|
||||
} as any;
|
||||
|
||||
const service = new GlitchtipErrorReportingService({ enabled: true }, mockLogger);
|
||||
|
||||
// Give the dynamic import a moment to resolve
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockSentryInit).toHaveBeenCalledOnce();
|
||||
const initCallArgs = mockSentryInit.mock.calls[0][0];
|
||||
|
||||
expect(initCallArgs.ignoreErrors).toBeDefined();
|
||||
expect(initCallArgs.ignoreErrors).toContain('ChunkLoadError');
|
||||
expect(initCallArgs.ignoreErrors).toContain('Failed to find Server Action');
|
||||
|
||||
global.window = originalWindow;
|
||||
});
|
||||
});
|
||||
27
tests/lexical.test.ts
Normal file
27
tests/lexical.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { lexicalToMarkdown } from '../lib/mdx-utils';
|
||||
|
||||
describe('lexicalToMarkdown', () => {
|
||||
it('should convert lexical AST to markdown', () => {
|
||||
const ast = {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{ type: 'text', text: 'Hello ' },
|
||||
{ type: 'text', text: 'World', format: 1 }, // bold
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
tag: 'h2',
|
||||
children: [{ type: 'text', text: 'Title' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = lexicalToMarkdown(ast);
|
||||
expect(result).toBe('Hello **World**\n\n## Title\n\n');
|
||||
});
|
||||
});
|
||||
14
tests/mdx-content-mapping.test.tsx
Normal file
14
tests/mdx-content-mapping.test.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('MDXContent Mapping', () => {
|
||||
it('should have productTabs in the switch statement to prevent Unknown Block Type errors', () => {
|
||||
const filePath = path.join(process.cwd(), 'components/MDXContent.tsx');
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Check if the switch case for productTabs exists
|
||||
expect(fileContent).toContain("case 'productTabs':");
|
||||
expect(fileContent).toContain('<ProductTechnicalData');
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,15 @@ describe('MDX Data Props Fixer', () => {
|
||||
|
||||
const result = fixMdxDataProps(mdxInput);
|
||||
|
||||
// The expected output should have the entire JSON object enclosed in data="{...}"
|
||||
// and NO trailing characters left over from the regex truncating early.
|
||||
const expected = `<Block type="productTabs" data="{"content":{"root":{"children":[]}},"id":"123"}" />`;
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should not prematurely truncate if } appears inside a string literal', () => {
|
||||
const mdxInput = `<Block type="productTabs" data={{"content":{"text":"}>"}}} />`;
|
||||
const result = fixMdxDataProps(mdxInput);
|
||||
const expected = `<Block type="productTabs" data="{"content":{"text":"}>"}}" />`;
|
||||
expect(result).toBe(expected.replace('>', '>'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user