feat(blog): Phase 5 SEO/GEO content with 5 new blog posts

Add 5 long-form Thai blog posts (1,200-2,500 words each) with SEO + GEO
optimization for the dealplustech water-systems site. Each post targets
a specific audience (contractors, engineers, project managers) and
follows a content-quality workflow: source real product specs, verify
Thai text, dedupe images, link back to product pages.

## New blog posts (src/content/blog/)
- thermobreak-guide.md (Thermobreak closed-cell insulation overview)
- plastic-grilles-guide.md (ABS plastic grilles for HVAC)
- ppr-pipe-guide.md (PPR pipe properties + heat-fusion welding)
- ppr-vs-hdpe-vs-upvc.md (3-way pipe comparison with PE80/PE100)
- thermobreak-series-guide.md (Thermobreak LS vs Solar series)
- 10-things-checklist-pipe-ordering.md (10-point pre-order checklist)

## Removed legacy posts
- pipe-knowledge.md, valve-guide.md, welcome-post.md (orphans)

## Hero images (public/images/blog/)
~20 product photos sourced from manufacturers (Thermobreak, Thai PPR,
thaiconsupply) plus Nano Banana Pro infographics. All resized to
3:2 aspect ratio per user preference. Source folder preserved for
re-derivation.

## Astro layout/SEO work
- src/components/seo/SEO.astro, JsonLd.astro (new SEO components)
- src/layouts/BaseLayout.astro, Layout.astro (OG/Twitter/JSON-LD wiring)
- src/pages/404.astro
- Product pages (8): added #pricelist anchors + schema work
- src/styles/global.css: scroll-padding for sticky-header anchors

## Automation scripts (scripts/)
- build_og_image.py (OG image builder)
- inject_faq_schema.py, inject_product_schema.py (JSON-LD injection)

## Misc
- public/robots.txt, public/images/og/default-og.jpg
- .gitignore: exclude scripts/__pycache__/
This commit is contained in:
hermes
2026-06-08 12:45:32 +07:00
parent 7c905bdb00
commit b34f8fc2fb
81 changed files with 4031 additions and 282 deletions

View File

@@ -1,6 +1,7 @@
---
import '@/styles/global.css';
import Layout from './Layout.astro';
import JsonLd from '@/components/seo/JsonLd.astro';
const companyInfo = {
name: "ดีล พลัส เทค จำกัด",
@@ -11,6 +12,114 @@ const companyInfo = {
hours: "จันทร์-ศุกร์ 08:00-18:00 เสาร์ 08:00-17:00"
};
interface Props {
title: string;
description?: string;
ogImage?: string;
canonical?: string;
ogType?: 'website' | 'article' | 'product';
publishedTime?: string;
author?: string;
robots?: string;
product?: { name: string; image: string; brand?: string; description?: string; sku?: string };
faq?: { question: string; answer: string }[];
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
/** HowTo schema for installation/selection steps.
* Provide a name, optional description, totalTime (ISO 8601 e.g. PT30M),
* and an array of step strings. */
howTo?: { name: string; description?: string; totalTime?: string; steps: string[] };
product?: {
name?: string;
description?: string;
image: string;
sku?: string;
brand?: string;
priceCurrency?: string;
price?: number;
availability?: 'InStock' | 'OutOfStock' | 'PreOrder';
url?: string;
};
/**
* FAQ schema. Pass an array of {question, answer} pairs to render
* a FAQPage JSON-LD. Answer may be plain text or HTML.
*/
faq?: Array<{ question: string; answer: string }>;
/**
* Override the auto-generated breadcrumb. If omitted, BaseLayout
* builds a breadcrumb automatically from the categories tree.
*/
breadcrumb?: Array<{ name: string; url: string }>;
}
const {
title,
description = "ดีล พลัส เทค - ระบบน้ำคุณภาพสูง ราคาโรงงาน",
ogImage,
canonical,
ogType = 'website',
publishedTime,
author,
robots,
jsonLd,
product: productProp,
faq: faqProp,
breadcrumb: breadcrumbProp,
howTo: howToProp,
} = Astro.props;
// Auto-standardize title suffix: "Foo" -> "Foo | ดีล พลัส เทค"
// Skip when the title already ends with the brand name (case-insensitive).
const BRAND = "ดีล พลัส เทค";
const trimmed = title.trim();
const lowerTitle = trimmed.toLowerCase();
const lowerBrand = BRAND.toLowerCase();
const hasBrandSuffix = lowerTitle.endsWith(lowerBrand)
|| lowerTitle.endsWith(lowerBrand + ' - ' + lowerBrand)
|| lowerTitle === lowerBrand;
const fullTitle = hasBrandSuffix ? trimmed : `${trimmed} | ${BRAND}`;
// Organization JSON-LD (rendered on every page so Google can verify the
// publisher of the site). LocalBusiness subtype for Maps/3-pack SEO.
const siteUrl = 'https://dealplustech.com';
const organizationSchema = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
'@id': `${siteUrl}/#organization`,
name: companyInfo.name,
url: siteUrl,
logo: `${siteUrl}/images/logo/dealplustech-logo.png`,
image: `${siteUrl}/images/og/default-og.jpg`,
description: 'ผู้นำเข้าและจัดจำหน่ายระบบน้ำคุณภาพสูง ราคาโรงงาน',
telephone: companyInfo.phone,
email: companyInfo.email,
priceRange: '฿฿',
address: {
'@type': 'PostalAddress',
streetAddress: '9/70 ซอยนครลุง 17',
addressLocality: 'แขวงบางไผ่',
addressRegion: 'เขตบางแค',
addressCountry: 'TH',
postalCode: '10160',
},
openingHoursSpecification: [
{
'@type': 'OpeningHoursSpecification',
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
opens: '08:00',
closes: '18:00',
},
{
'@type': 'OpeningHoursSpecification',
dayOfWeek: 'Saturday',
opens: '08:00',
closes: '17:00',
},
],
sameAs: [
`https://line.me/ti/p/~${companyInfo.line.replace('@', '')}`,
],
};
const categories = [
{
name: 'ท่อพีพีอาร์',
@@ -80,6 +189,130 @@ const categories = [
},
];
// ---------------------------------------------------------------------------
// Schema builders
// ---------------------------------------------------------------------------
/**
* Build a BreadcrumbList from the current URL by walking the categories tree.
* Pages outside the product tree (e.g. /about-us, /contact-us) get a 1-item
* breadcrumb (Home). Pages in the product tree get: Home > Category > Page.
*/
function buildBreadcrumb() {
if (breadcrumbProp) return breadcrumbProp;
// Normalise pathname: URL-decode (Astro returns encoded in static build),
// then strip trailing slash so it matches the slugs in the categories
// tree (which are stored decoded and have no trailing slash).
const rawPath = Astro.url.pathname;
const decoded = (() => {
try { return decodeURIComponent(rawPath); } catch { return rawPath; }
})();
const path = decoded.replace(/\/+$/, '') || '/';
if (path === '/') return [];
// Find the subcategory whose slug matches the current path
for (const cat of categories) {
const sub = cat.subcategories.find(s => s.slug === path);
if (sub) {
return [
{ name: 'หน้าแรก', url: '/' },
{ name: cat.name, url: cat.slug },
{ name: sub.name, url: sub.slug },
];
}
}
// Page not in product tree → just Home
return [{ name: 'หน้าแรก', url: '/' }];
}
const breadcrumbItems = buildBreadcrumb();
const breadcrumbSchema = breadcrumbItems.length > 0 ? {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbItems.map((item, idx) => ({
'@type': 'ListItem',
position: idx + 1,
name: item.name,
item: `${siteUrl}${item.url}`,
})),
} : null;
/**
* Build a Product JSON-LD from the `product` prop. Uses page title/description
* as defaults so callers only need to pass `image`.
*/
const productSchema = productProp ? {
'@context': 'https://schema.org',
'@type': 'Product',
name: productProp.name ?? trimmed,
description: productProp.description ?? description,
image: /^https?:\/\//.test(productProp.image)
? productProp.image
: `${siteUrl}${productProp.image}`,
...(productProp.sku && { sku: productProp.sku }),
...(productProp.brand && {
brand: { '@type': 'Brand', name: productProp.brand },
}),
offers: {
'@type': 'Offer',
url: productProp.url ?? `${siteUrl}${Astro.url.pathname}`,
priceCurrency: productProp.priceCurrency ?? 'THB',
...(productProp.price !== undefined && { price: productProp.price }),
availability: `https://schema.org/${productProp.availability ?? 'InStock'}`,
seller: { '@id': `${siteUrl}/#organization` },
},
} : null;
/**
* Build a FAQPage JSON-LD from the `faq` prop. Strips HTML from answers
* for the schema text field (Google prefers plain text).
*/
const faqSchema = faqProp && faqProp.length > 0 ? {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqProp.map(item => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer.replace(/<[^>]+>/g, '').trim(),
},
})),
} : null;
// HowTo schema — installation/selection steps as ordered list
const howToSchema = howToProp && howToProp.steps.length > 0 ? {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: howToProp.name,
...(howToProp.description && { description: howToProp.description }),
...(howToProp.totalTime && { totalTime: howToProp.totalTime }),
step: howToProp.steps.map((text, i) => ({
'@type': 'HowToStep',
position: i + 1,
name: `ขั้นตอนที่ ${i + 1}`,
text: text,
})),
} : null;
/**
* Collect all JSON-LD payloads to render on this page.
* Always includes the Organization schema. Plus: breadcrumb, product, faq,
* and any caller-supplied custom JSON-LD.
*/
const jsonLdPayloads: Record<string, unknown>[] = [organizationSchema];
if (breadcrumbSchema) jsonLdPayloads.push(breadcrumbSchema);
if (productSchema) jsonLdPayloads.push(productSchema);
if (faqSchema) jsonLdPayloads.push(faqSchema);
if (howToSchema) jsonLdPayloads.push(howToSchema);
if (jsonLd) {
if (Array.isArray(jsonLd)) {
jsonLdPayloads.push(...jsonLd);
} else {
jsonLdPayloads.push(jsonLd);
}
}
const productLinks = [
{ title: "ท่อพีพีอาร์", href: "/ท่อ-ppr-thai-ppr" },
{ title: "ท่อ HDPE", href: "/ท่อ-hdpe" },
@@ -90,7 +323,10 @@ const productLinks = [
];
---
<Layout title="ดีล พลัส เทค">
<Layout title={fullTitle} description={description} ogImage={ogImage} canonical={canonical} ogType={ogType} publishedTime={publishedTime} author={author} robots={robots}>
{jsonLdPayloads.map((data, idx) => (
<JsonLd data={data} id={idx === 0 ? 'organization-schema' : `schema-${idx}`} />
))}
<!-- Header with Scroll Effects -->
<header id="main-header" class="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-50 transition-all duration-300">
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">