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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user