From 70c2627d93b3f45998c2274b7e04d4e3cdb96d4a Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Tue, 25 Aug 2026 11:19:30 +0330 Subject: [PATCH] feat(seo): implement B2B dedicated route, structured data schemas, archive optimizations, and SEO database backfill --- backend/package.json | 3 +- backend/scripts/seo-backfill.ts | 127 + backend/src/blogs/blogs.service.ts | 34 +- frontend/application/app/b2b/page.tsx | 38 + frontend/application/app/contact/page.tsx | 66 +- frontend/application/app/layout.tsx | 51 +- frontend/application/app/shop/page.tsx | 27 +- frontend/application/app/videos/page.tsx | 64 +- frontend/application/app/wiki/[slug]/page.tsx | 168 +- .../application/components/ArchivePage.tsx | 29 + .../components/B2BLandingClient.tsx | 442 + frontend/application/components/Footer.tsx | 8 +- frontend/application/components/Header.tsx | 8 +- frontend/application/lib/schema.ts | 233 + graphify-out/.graphify_labels.json | 93 +- graphify-out/.graphify_labels.json.sig | 2 +- graphify-out/2026-08-25/.graphify_labels.json | 126 +- graphify-out/2026-08-25/GRAPH_REPORT.md | 448 +- graphify-out/2026-08-25/graph.json | 14669 +++++++++------- graphify-out/2026-08-25/manifest.json | 1216 +- graphify-out/GRAPH_REPORT.md | 425 +- ...476f3b08fb3dcf5710289e85af9bf50ec39d5.json | 1 + graphify-out/cache/stat-index.json | 2 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 14397 ++++++++------- graphify-out/manifest.json | 1212 +- 26 files changed, 18570 insertions(+), 15327 deletions(-) create mode 100644 backend/scripts/seo-backfill.ts create mode 100644 frontend/application/app/b2b/page.tsx create mode 100644 frontend/application/components/B2BLandingClient.tsx create mode 100644 frontend/application/lib/schema.ts create mode 100644 graphify-out/cache/ast/v0.9.44/164822617eb449e27d8e6a9ff13476f3b08fb3dcf5710289e85af9bf50ec39d5.json diff --git a/backend/package.json b/backend/package.json index 0c9132a..a2064ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,7 +18,8 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "docs:generate": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts" + "docs:generate": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts", + "seo:backfill": "ts-node -r tsconfig-paths/register scripts/seo-backfill.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", diff --git a/backend/scripts/seo-backfill.ts b/backend/scripts/seo-backfill.ts new file mode 100644 index 0000000..8c6f6a7 --- /dev/null +++ b/backend/scripts/seo-backfill.ts @@ -0,0 +1,127 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +function stripHtml(html: string): string { + return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); +} + +async function runSeoBackfill() { + console.log('🚀 Starting SEO & Content Backfill Script for Canina Iran...\n'); + + // 1. Backfill Products + const products = await prisma.product.findMany(); + console.log(`📦 Found ${products.length} products to check...`); + + let updatedProducts = 0; + + for (const product of products) { + const dataToUpdate: Record = {}; + + if (!product.metaTitle || product.metaTitle.trim() === '') { + dataToUpdate.metaTitle = `${product.nameFa} (${product.packageSize} ${product.unit}) | کنینا ایران`; + } + + if (!product.metaDescription || product.metaDescription.trim() === '') { + const baseDesc = product.shortDescription || stripHtml(product.description || ''); + const cleanDesc = baseDesc.substring(0, 150).trim(); + dataToUpdate.metaDescription = cleanDesc + ? `${cleanDesc} | خرید اینترنتی با ضمانت اصالت کنینا آلمان.` + : `خرید آنلاین ${product.nameFa} با ضمانت ۱۰۰٪ اصالت آلمان و ارسال سریع در کنینا ایران.`; + } + + if (!product.featuredImageAlt || product.featuredImageAlt.trim() === '') { + dataToUpdate.featuredImageAlt = `مکمل دارویی ${product.nameFa} Canina آلمان`; + } + + if (!product.keywords || product.keywords.trim() === '') { + const tags = [ + product.nameFa, + product.nameEn, + `مکمل ${product.suitableFor}`, + 'کنینا آلمان', + 'Canina', + product.categorySlug, + ].filter(Boolean); + dataToUpdate.keywords = tags.join(', '); + } + + if (Object.keys(dataToUpdate).length > 0) { + await prisma.product.update({ + where: { id: product.id }, + data: dataToUpdate, + }); + updatedProducts++; + } + } + + console.log(`✅ Products backfill complete: ${updatedProducts} products updated.\n`); + + // 2. Backfill Blogs & Cross-Linking + const blogs = await prisma.blog.findMany(); + console.log(`📰 Found ${blogs.length} blog posts to check...`); + + let updatedBlogs = 0; + + for (const blog of blogs) { + const dataToUpdate: Record = {}; + + if (!blog.metaTitle || blog.metaTitle.trim() === '') { + dataToUpdate.metaTitle = `${blog.title} | مجله تخصصی کنینا`; + } + + if (!blog.metaDescription || blog.metaDescription.trim() === '') { + const baseExcerpt = blog.excerpt || stripHtml(blog.content || ''); + dataToUpdate.metaDescription = baseExcerpt.substring(0, 155).trim(); + } + + if (!blog.imageAlt || blog.imageAlt.trim() === '') { + dataToUpdate.imageAlt = `تصویر راهنمای تخصصی ${blog.title}`; + } + + // Auto Cross-Linking: If relatedProductIds is empty, match top products by category or title keywords + if (!blog.relatedProductIds || blog.relatedProductIds.length === 0) { + const keywords = [blog.title, blog.keywords].filter(Boolean).join(' '); + const matchingProducts = await prisma.product.findMany({ + where: { + OR: [ + { categorySlug: { contains: blog.categoryId || '', mode: 'insensitive' } }, + { nameFa: { contains: blog.title.split(' ')[0] || '', mode: 'insensitive' } }, + { description: { contains: blog.title.split(' ')[0] || '', mode: 'insensitive' } }, + ], + }, + take: 3, + select: { id: true }, + }); + + if (matchingProducts.length > 0) { + dataToUpdate.relatedProductIds = matchingProducts.map((p) => p.id); + } else { + // Fallback: pick top 2 featured products + const topProducts = await prisma.product.findMany({ take: 2, select: { id: true } }); + dataToUpdate.relatedProductIds = topProducts.map((p) => p.id); + } + } + + if (Object.keys(dataToUpdate).length > 0) { + await prisma.blog.update({ + where: { id: blog.id }, + data: dataToUpdate, + }); + updatedBlogs++; + } + } + + console.log(`✅ Blog posts backfill complete: ${updatedBlogs} posts updated with SEO & cross-links.\n`); + + console.log('🎉 SEO Backfill and Internal Cross-Linking finished successfully!'); +} + +runSeoBackfill() + .catch((e) => { + console.error('❌ SEO Backfill failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/blogs/blogs.service.ts b/backend/src/blogs/blogs.service.ts index ccfe175..d7cceb8 100644 --- a/backend/src/blogs/blogs.service.ts +++ b/backend/src/blogs/blogs.service.ts @@ -149,7 +149,10 @@ export class BlogsService { decodedSlug = rawSlug; } - const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(decodedSlug); + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + decodedSlug, + ); const blog = await this.prisma.blog.findFirst({ where: { @@ -181,9 +184,17 @@ export class BlogsService { // Fetch related products safely let relatedProducts: any[] = []; - if (blog.relatedProductIds && Array.isArray(blog.relatedProductIds) && blog.relatedProductIds.length > 0) { + if ( + blog.relatedProductIds && + Array.isArray(blog.relatedProductIds) && + blog.relatedProductIds.length > 0 + ) { const validProductUuids = blog.relatedProductIds.filter( - (id) => typeof id === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) + (id) => + typeof id === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + id, + ), ); if (validProductUuids.length > 0) { try { @@ -208,9 +219,17 @@ export class BlogsService { // Fetch related blogs safely let relatedBlogs: any[] = []; - if (blog.relatedBlogIds && Array.isArray(blog.relatedBlogIds) && blog.relatedBlogIds.length > 0) { + if ( + blog.relatedBlogIds && + Array.isArray(blog.relatedBlogIds) && + blog.relatedBlogIds.length > 0 + ) { const validBlogUuids = blog.relatedBlogIds.filter( - (id) => typeof id === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) + (id) => + typeof id === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + id, + ), ); if (validBlogUuids.length > 0) { try { @@ -257,7 +276,10 @@ export class BlogsService { }, }); } catch (err) { - console.warn('[BlogsService] Error fetching fallback related blogs:', err); + console.warn( + '[BlogsService] Error fetching fallback related blogs:', + err, + ); } } diff --git a/frontend/application/app/b2b/page.tsx b/frontend/application/app/b2b/page.tsx new file mode 100644 index 0000000..ae6202d --- /dev/null +++ b/frontend/application/app/b2b/page.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import type { Metadata } from "next"; +import B2BLandingClient from "@/components/B2BLandingClient"; +import { getPageMetadata } from "@/lib/seo"; +import { generateB2BServiceSchema, generateBreadcrumbSchema } from "@/lib/schema"; + +export async function generateMetadata(): Promise { + return getPageMetadata('b2b', { + canonicalPath: '/b2b', + fallbackTitle: 'خرید عمده مکمل‌های پت کنینا | نمایندگی رسمی و همکاری B2B', + fallbackDesc: 'تأمین عمده و همکاری تجاری مکمل‌های دارویی کنینا آلمان برای کلینیک‌ها، بیمارستان‌های دامپزشکی و پت‌شاپ‌ها با گواهی اصالت و بیشترین حاشیه سود.', + }); +} + +export default function B2BPage() { + const b2bServiceSchema = generateB2BServiceSchema({ + siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://canina.ir', + }); + + const breadcrumbs = generateBreadcrumbSchema([ + { name: 'صفحه اصلی', url: 'https://canina.ir' }, + { name: 'همکاری تجاری و خرید عمده (B2B)', url: 'https://canina.ir/b2b' }, + ]); + + return ( + <> +