Merge branch 'main' into uplink/prod

This commit is contained in:
2026-02-26 03:29:41 +00:00
10 changed files with 142 additions and 267 deletions

View File

@@ -129,7 +129,10 @@ If needed, you can run the same deploy logic directly on the server:
| Command | Description |
| ----------------- | ------------------------ |
| `npm run dev` | Start Vite dev server |
| `npm run build` | Production build |
| `npm run seo:validate` | Validate SEO catalog integrity (families/slugs/keywords) |
| `npm run seo:artifacts` | Generate sitemap artifacts in `web/public/sitemaps/` |
| `npm run seo:prerender` | Pre-render SEO routes into `web/dist/` |
| `npm run build` | Production build + SEO validate/artifacts/prerender |
| `npm run lint` | ESLint check |
| `npm run format` | Format with Prettier |
| `npm run preview` | Preview production build |

View File

@@ -35,6 +35,13 @@ server {
add_header Cache-Control "public, immutable";
}
# Explicitly prevent indexing on auth entry routes (including legacy aliases)
location ~ ^/(login|register|auth/login|auth/register)/?$ {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
add_header X-Robots-Tag "noindex, follow" always;
}
# SPA fallback — serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;

View File

@@ -1,10 +1,11 @@
User-agent: *
Allow: /
# Auth routes are intentionally crawlable so bots can observe
# page/server-level noindex directives (meta robots + X-Robots-Tag).
Disallow: /dashboard
Disallow: /customers
Disallow: /settings
Disallow: /onboarding
Disallow: /login
Disallow: /register
Sitemap: https://pulsescore.app/sitemap.xml

View File

@@ -1,16 +1,15 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { readFileSync } from "node:fs";
const SITE_ORIGIN = "https://pulsescore.app";
const FAMILY_PATH_PREFIX = {
templates: "/templates",
integrations: "/integrations",
personas: "/for",
comparisons: "/compare",
glossary: "/glossary",
examples: "/examples",
curation: "/best",
};
const FAMILY_CONFIG = JSON.parse(
readFileSync(new URL("../src/content/seo-family-config.json", import.meta.url), "utf8"),
);
const FAMILY_PATH_PREFIX = Object.fromEntries(
Object.entries(FAMILY_CONFIG).map(([family, config]) => [family, config.path]),
);
const CHANGEFREQ = {
core: "weekly",
@@ -77,13 +76,7 @@ async function main() {
const corePaths = [
"/",
"/pricing",
"/templates",
"/integrations",
"/for",
"/compare",
"/glossary",
"/examples",
"/best",
...Object.values(FAMILY_PATH_PREFIX),
];
const byFamily = {

View File

@@ -1,58 +1,11 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { readFileSync } from "node:fs";
const SITE_ORIGIN = "https://pulsescore.app";
const FAMILY_CONFIG = {
templates: {
path: "/templates",
label: "Templates",
hubTitle: "Customer health templates for lean SaaS teams",
hubDescription:
"Action-ready customer health templates you can use immediately, then operationalize in PulseScore.",
},
integrations: {
path: "/integrations",
label: "Integrations",
hubTitle: "Integration playbooks for customer health scoring",
hubDescription:
"Connect billing, CRM, and support signals to unify churn-risk visibility in one workflow.",
},
personas: {
path: "/for",
label: "Personas",
hubTitle: "PulseScore by persona and growth stage",
hubDescription:
"See how founders, CS, and RevOps teams tailor health scoring to their operating model.",
},
comparisons: {
path: "/compare",
label: "Comparisons",
hubTitle: "PulseScore comparison guides",
hubDescription:
"Balanced comparisons focused on setup speed, pricing, and fit for lean teams.",
},
glossary: {
path: "/glossary",
label: "Glossary",
hubTitle: "Customer success and churn glossary",
hubDescription:
"Clear, practical definitions with examples and implementation context for SaaS operators.",
},
examples: {
path: "/examples",
label: "Examples",
hubTitle: "Real customer health examples",
hubDescription:
"Concrete examples of health scores, thresholds, alerts, and intervention patterns.",
},
curation: {
path: "/best",
label: "Best-of Guides",
hubTitle: "Curated best-of customer success guides",
hubDescription:
"Research-backed shortlists of tools and approaches by team size, stack, and outcomes.",
},
};
const FAMILY_CONFIG = JSON.parse(
readFileSync(new URL("../src/content/seo-family-config.json", import.meta.url), "utf8"),
);
const FAMILY_ORDER = [
"templates",
@@ -134,46 +87,18 @@ function withBrandTitle(base) {
return `${truncatedBase}${suffix}`;
}
function renderTemplate(template, page) {
return template
.replaceAll("{entity}", page.entity)
.replaceAll("{entityLower}", page.entity.toLowerCase());
}
function buildTitle(page) {
switch (page.family) {
case "templates":
return withBrandTitle(`${page.entity} Template`);
case "integrations":
return withBrandTitle(`PulseScore + ${page.entity} Integration`);
case "personas":
return withBrandTitle(`Health Scoring for ${page.entity}`);
case "comparisons":
return withBrandTitle(`${page.entity} Comparison`);
case "glossary":
return withBrandTitle(`${page.entity} Definition`);
case "examples":
return withBrandTitle(`${page.entity} Examples`);
case "curation":
return withBrandTitle(page.entity);
default:
return withBrandTitle(page.entity);
}
return withBrandTitle(renderTemplate(FAMILY_CONFIG[page.family].titleTemplate, page));
}
function buildDescription(page) {
switch (page.family) {
case "templates":
return `Use this ${page.entity.toLowerCase()} template to prioritize risk, define thresholds, and run a repeatable retention workflow for B2B SaaS.`;
case "integrations":
return `Learn how ${page.entity} can feed customer health signals into PulseScore so your team can identify churn risk earlier and act faster.`;
case "personas":
return `See how ${page.entity.toLowerCase()} use PulseScore to monitor account health, reduce churn, and focus effort where it drives revenue retention.`;
case "comparisons":
return `Compare ${page.entity} across pricing, setup speed, integrations, and fit so lean customer success teams can choose with confidence.`;
case "glossary":
return `Understand ${page.entity.toLowerCase()} with clear definitions, formulas, examples, and practical guidance for SaaS customer success workflows.`;
case "examples":
return `Explore ${page.entity.toLowerCase()} with practical patterns, implementation notes, and proven workflows for retention-focused teams.`;
case "curation":
return `Review ${page.entity.toLowerCase()} using transparent criteria: setup complexity, integration depth, pricing, and operational fit for SMB SaaS.`;
default:
return `${page.entity} resources and implementation guidance from PulseScore.`;
}
return renderTemplate(FAMILY_CONFIG[page.family].descriptionTemplate, page);
}
function buildPath(page) {
@@ -181,20 +106,7 @@ function buildPath(page) {
}
function buildHeading(page) {
switch (page.family) {
case "templates":
return `Free ${page.entity} Template for B2B SaaS`;
case "integrations":
return `PulseScore + ${page.entity}`;
case "personas":
return `PulseScore for ${page.entity}`;
case "comparisons":
return page.entity;
case "glossary":
return `What is ${page.entity}?`;
default:
return page.entity;
}
return renderTemplate(FAMILY_CONFIG[page.family].h1Template, page);
}
function toAbsolute(path) {

View File

@@ -1,24 +1,15 @@
import { readFile } from "node:fs/promises";
import { readFileSync } from "node:fs";
const VALID_FAMILIES = new Set([
"templates",
"integrations",
"personas",
"comparisons",
"glossary",
"examples",
"curation",
]);
const FAMILY_CONFIG = JSON.parse(
readFileSync(new URL("../src/content/seo-family-config.json", import.meta.url), "utf8"),
);
const FAMILY_PATH_PREFIX = {
templates: "/templates",
integrations: "/integrations",
personas: "/for",
comparisons: "/compare",
glossary: "/glossary",
examples: "/examples",
curation: "/best",
};
const FAMILY_PATH_PREFIX = Object.fromEntries(
Object.entries(FAMILY_CONFIG).map(([family, config]) => [family, config.path]),
);
const VALID_FAMILIES = new Set(Object.keys(FAMILY_PATH_PREFIX));
const FAMILY_MINIMUMS = {
templates: 20,
@@ -43,19 +34,12 @@ function isSlugValid(slug) {
async function main() {
const raw = await readFile(new URL("../src/content/seo-pages.json", import.meta.url), "utf8");
const pages = JSON.parse(raw);
const families = Object.keys(FAMILY_PATH_PREFIX);
assert(Array.isArray(pages), "SEO catalog must be an array.");
assert(pages.length >= 50, `Expected at least 50 SEO pages, found ${pages.length}.`);
const counts = {
templates: 0,
integrations: 0,
personas: 0,
comparisons: 0,
glossary: 0,
examples: 0,
curation: 0,
};
const counts = Object.fromEntries(families.map((family) => [family, 0]));
const fullPaths = new Set();
const keywords = new Set();

View File

@@ -77,8 +77,16 @@ export default function FooterSection() {
onSubmit={handleSubmit}
className="mt-5 flex max-w-md flex-col gap-2 sm:flex-row"
>
<label htmlFor="footer-updates-email" className="sr-only">
Work email
</label>
<input
id="footer-updates-email"
name="email"
type="email"
autoComplete="email"
spellCheck={false}
required
placeholder="you@company.com"
value={email}
onChange={(event) => setEmail(event.target.value)}

View File

@@ -0,0 +1,72 @@
{
"templates": {
"path": "/templates",
"label": "Templates",
"hubTitle": "Customer health templates for lean SaaS teams",
"hubDescription": "Action-ready customer health templates you can use immediately, then operationalize in PulseScore.",
"hero": "Turn retention strategy into execution with practical templates.",
"titleTemplate": "{entity} Template",
"descriptionTemplate": "Use this {entityLower} template to prioritize risk, define thresholds, and run a repeatable retention workflow for B2B SaaS.",
"h1Template": "Free {entity} Template for B2B SaaS"
},
"integrations": {
"path": "/integrations",
"label": "Integrations",
"hubTitle": "Integration playbooks for customer health scoring",
"hubDescription": "Connect billing, CRM, and support signals to unify churn-risk visibility in one workflow.",
"hero": "Build a single source of truth for customer health signals.",
"titleTemplate": "PulseScore + {entity} Integration",
"descriptionTemplate": "Learn how {entity} can feed customer health signals into PulseScore so your team can identify churn risk earlier and act faster.",
"h1Template": "PulseScore + {entity}"
},
"personas": {
"path": "/for",
"label": "Personas",
"hubTitle": "PulseScore by persona and growth stage",
"hubDescription": "See how founders, CS, and RevOps teams tailor health scoring to their operating model.",
"hero": "Find the exact customer health workflow for your team shape.",
"titleTemplate": "Health Scoring for {entity}",
"descriptionTemplate": "See how {entityLower} use PulseScore to monitor account health, reduce churn, and focus effort where it drives revenue retention.",
"h1Template": "PulseScore for {entity}"
},
"comparisons": {
"path": "/compare",
"label": "Comparisons",
"hubTitle": "PulseScore comparison guides",
"hubDescription": "Balanced comparisons focused on setup speed, pricing, and fit for lean teams.",
"hero": "Choose the right customer success stack with clarity, not guesswork.",
"titleTemplate": "{entity} Comparison",
"descriptionTemplate": "Compare {entity} across pricing, setup speed, integrations, and fit so lean customer success teams can choose with confidence.",
"h1Template": "{entity}"
},
"glossary": {
"path": "/glossary",
"label": "Glossary",
"hubTitle": "Customer success and churn glossary",
"hubDescription": "Clear, practical definitions with examples and implementation context for SaaS operators.",
"hero": "Learn the language of retention, then apply it in your workflow.",
"titleTemplate": "{entity} Definition",
"descriptionTemplate": "Understand {entityLower} with clear definitions, formulas, examples, and practical guidance for SaaS customer success workflows.",
"h1Template": "What is {entity}?"
},
"examples": {
"path": "/examples",
"label": "Examples",
"hubTitle": "Real customer health examples",
"hubDescription": "Concrete examples of health scores, thresholds, alerts, and intervention patterns.",
"hero": "Skip theory—see what good customer health execution looks like.",
"titleTemplate": "{entity} Examples",
"descriptionTemplate": "Explore {entityLower} with practical patterns, implementation notes, and proven workflows for retention-focused teams.",
"h1Template": "{entity}"
},
"curation": {
"path": "/best",
"label": "Best-of Guides",
"hubTitle": "Curated best-of customer success guides",
"hubDescription": "Research-backed shortlists of tools and approaches by team size, stack, and outcomes.",
"hero": "Use transparent evaluation criteria to pick the right tools faster.",
"titleTemplate": "{entity}",
"descriptionTemplate": "Review {entityLower} using transparent criteria: setup complexity, integration depth, pricing, and operational fit for SMB SaaS.",
"h1Template": "{entity}"
}
}

View File

@@ -1,4 +1,5 @@
import rawSeoPages from "@/content/seo-pages.json";
import rawFamilyConfig from "@/content/seo-family-config.json";
export type SeoFamily =
| "templates"
@@ -38,77 +39,15 @@ export interface SeoHub {
interface FamilyConfig {
path: string;
label: string;
titleSuffix: string;
hubTitle: string;
hubDescription: string;
hero: string;
titleTemplate: string;
descriptionTemplate: string;
h1Template: string;
}
const familyConfig: Record<SeoFamily, FamilyConfig> = {
templates: {
path: "/templates",
label: "Templates",
titleSuffix: "Template",
hubTitle: "Customer health templates for lean SaaS teams",
hubDescription:
"Action-ready customer health templates you can use immediately, then operationalize in PulseScore.",
hero: "Turn retention strategy into execution with practical templates.",
},
integrations: {
path: "/integrations",
label: "Integrations",
titleSuffix: "Integration",
hubTitle: "Integration playbooks for customer health scoring",
hubDescription:
"Connect billing, CRM, and support signals to unify churn-risk visibility in one workflow.",
hero: "Build a single source of truth for customer health signals.",
},
personas: {
path: "/for",
label: "Personas",
titleSuffix: "for Teams",
hubTitle: "PulseScore by persona and growth stage",
hubDescription:
"See how founders, CS, and RevOps teams tailor health scoring to their operating model.",
hero: "Find the exact customer health workflow for your team shape.",
},
comparisons: {
path: "/compare",
label: "Comparisons",
titleSuffix: "Comparison",
hubTitle: "PulseScore comparison guides",
hubDescription:
"Balanced comparisons focused on setup speed, pricing, and fit for lean teams.",
hero: "Choose the right customer success stack with clarity, not guesswork.",
},
glossary: {
path: "/glossary",
label: "Glossary",
titleSuffix: "Definition",
hubTitle: "Customer success and churn glossary",
hubDescription:
"Clear, practical definitions with examples and implementation context for SaaS operators.",
hero: "Learn the language of retention, then apply it in your workflow.",
},
examples: {
path: "/examples",
label: "Examples",
titleSuffix: "Examples",
hubTitle: "Real customer health examples",
hubDescription:
"Concrete examples of health scores, thresholds, alerts, and intervention patterns.",
hero: "Skip theory—see what good customer health execution looks like.",
},
curation: {
path: "/best",
label: "Best-of Guides",
titleSuffix: "Best Guide",
hubTitle: "Curated best-of customer success guides",
hubDescription:
"Research-backed shortlists of tools and approaches by team size, stack, and outcomes.",
hero: "Use transparent evaluation criteria to pick the right tools faster.",
},
};
const familyConfig = rawFamilyConfig as Record<SeoFamily, FamilyConfig>;
const seoPageSeeds = rawSeoPages as SeoPageSeed[];
@@ -116,6 +55,12 @@ function buildPath(seed: SeoPageSeed): string {
return `${familyConfig[seed.family].path}/${seed.slug}`;
}
function renderTemplate(template: string, seed: SeoPageSeed): string {
return template
.replaceAll("{entity}", seed.entity)
.replaceAll("{entityLower}", seed.entity.toLowerCase());
}
function withBrandTitle(base: string): string {
const suffix = " | PulseScore";
const maxLength = 60;
@@ -139,66 +84,15 @@ export function toSeoTitle(base: string): string {
}
function buildTitle(seed: SeoPageSeed): string {
switch (seed.family) {
case "templates":
return withBrandTitle(`${seed.entity} Template`);
case "integrations":
return withBrandTitle(`PulseScore + ${seed.entity} Integration`);
case "personas":
return withBrandTitle(`Health Scoring for ${seed.entity}`);
case "comparisons":
return withBrandTitle(`${seed.entity} Comparison`);
case "glossary":
return withBrandTitle(`${seed.entity} Definition`);
case "examples":
return withBrandTitle(`${seed.entity} Examples`);
case "curation":
return withBrandTitle(seed.entity);
default:
return withBrandTitle(seed.entity);
}
return withBrandTitle(renderTemplate(familyConfig[seed.family].titleTemplate, seed));
}
function buildDescription(seed: SeoPageSeed): string {
switch (seed.family) {
case "templates":
return `Use this ${seed.entity.toLowerCase()} template to prioritize risk, define thresholds, and run a repeatable retention workflow for B2B SaaS.`;
case "integrations":
return `Learn how ${seed.entity} can feed customer health signals into PulseScore so your team can identify churn risk earlier and act faster.`;
case "personas":
return `See how ${seed.entity.toLowerCase()} use PulseScore to monitor account health, reduce churn, and focus effort where it drives revenue retention.`;
case "comparisons":
return `Compare ${seed.entity} across pricing, setup speed, integrations, and fit so lean customer success teams can choose with confidence.`;
case "glossary":
return `Understand ${seed.entity.toLowerCase()} with clear definitions, formulas, examples, and practical guidance for SaaS customer success workflows.`;
case "examples":
return `Explore ${seed.entity.toLowerCase()} with practical patterns, implementation notes, and proven workflows for retention-focused teams.`;
case "curation":
return `Review ${seed.entity.toLowerCase()} using transparent criteria: setup complexity, integration depth, pricing, and operational fit for SMB SaaS.`;
default:
return `${seed.entity} resources and implementation guidance from PulseScore.`;
}
return renderTemplate(familyConfig[seed.family].descriptionTemplate, seed);
}
function buildH1(seed: SeoPageSeed): string {
switch (seed.family) {
case "templates":
return `Free ${seed.entity} Template for B2B SaaS`;
case "integrations":
return `PulseScore + ${seed.entity}`;
case "personas":
return `PulseScore for ${seed.entity}`;
case "comparisons":
return seed.entity;
case "glossary":
return `What is ${seed.entity}?`;
case "examples":
return seed.entity;
case "curation":
return seed.entity;
default:
return seed.entity;
}
return renderTemplate(familyConfig[seed.family].h1Template, seed);
}
export const seoHubs: SeoHub[] = (

View File

@@ -267,6 +267,7 @@ export default function SeoProgrammaticPage({
const sections = buildSections(page);
const relatedPages = getRelatedPages(page, 6);
const crossFamilyHubs = getCrossFamilyHubs(family);
const lastUpdated = process.env.REACT_APP_SEO_LAST_UPDATED || undefined;
const structuredData = [
{