diff --git a/apps/cms/emails/contact-confirmation.html b/apps/cms/emails/contact-confirmation.html index 60a632577..77fc67081 100644 --- a/apps/cms/emails/contact-confirmation.html +++ b/apps/cms/emails/contact-confirmation.html @@ -39,7 +39,7 @@ -
Your message reached the CleanStart team. We reply within one business day.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+
Your message reached the CleanStart team and someone will get back to you.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
diff --git a/apps/cms/emails/deal-registration-confirmation.html b/apps/cms/emails/deal-registration-confirmation.html index 1bce8e88a..17ea2dc2c 100644 --- a/apps/cms/emails/deal-registration-confirmation.html +++ b/apps/cms/emails/deal-registration-confirmation.html @@ -58,7 +58,7 @@
@@ -58,7 +58,7 @@

Contact

Thanks, Priya

-

We've received your message and someone from the team will reply within one business day.

+

We've received your message and someone from the team will get back to you soon.

If it is urgent, reply to this email and it will reach us directly.

The CleanStart team

Deal registration

Thanks, Marcus

-

We've received your deal registration for Elena Vasquez — Northgate Security. Our partner team will review it and confirm next steps within one business day.

+

We've received your deal registration for Elena Vasquez — Northgate Security. Our partner team will review it and confirm next steps with you.

diff --git a/apps/cms/emails/demo-confirmation.html b/apps/cms/emails/demo-confirmation.html index 5a746f00e..7e99ec853 100644 --- a/apps/cms/emails/demo-confirmation.html +++ b/apps/cms/emails/demo-confirmation.html @@ -39,7 +39,7 @@ -
We have your demo request. A specialist will reach out within one business day.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+
We have your demo request. A specialist will reach out to arrange a time.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
Deal Elena Vasquez — Northgate Security
diff --git a/apps/cms/src/payload/lib/deal-registrations/notification-email.ts b/apps/cms/src/payload/lib/deal-registrations/notification-email.ts index 847ecdec9..af23344d3 100644 --- a/apps/cms/src/payload/lib/deal-registrations/notification-email.ts +++ b/apps/cms/src/payload/lib/deal-registrations/notification-email.ts @@ -80,7 +80,7 @@ export const buildDealRegistrationConfirmationEmail = ( blocks: [ { kind: 'paragraph', - text: `We've received your deal registration for ${dealName}. Our partner team will review it and confirm next steps within one business day.`, + text: `We've received your deal registration for ${dealName}. Our partner team will review it and confirm next steps with you.`, }, { kind: 'details', diff --git a/apps/cms/src/payload/lib/email/lead-emails.ts b/apps/cms/src/payload/lib/email/lead-emails.ts index 311d6fbd3..d87d32400 100644 --- a/apps/cms/src/payload/lib/email/lead-emails.ts +++ b/apps/cms/src/payload/lib/email/lead-emails.ts @@ -24,13 +24,13 @@ export const buildDemoConfirmationEmail = ( ): { subject: string; htmlContent: string } => ({ subject: 'Your CleanStart demo request', htmlContent: renderEmail({ - preheader: 'We have your demo request. A specialist will reach out within one business day.', + preheader: 'We have your demo request. A specialist will reach out to arrange a time.', eyebrow: 'Demo request', heading: greeting(input.firstName), blocks: [ { kind: 'paragraph', - text: "We've received your demo request. One of our solutions specialists will reach out within one business day to arrange a time that suits you.", + text: "We've received your demo request. One of our solutions specialists will reach out to arrange a time that suits you.", }, { kind: 'paragraph', @@ -47,13 +47,13 @@ export const buildContactConfirmationEmail = ( ): { subject: string; htmlContent: string } => ({ subject: "We've received your message", htmlContent: renderEmail({ - preheader: 'Your message reached the CleanStart team. We reply within one business day.', + preheader: 'Your message reached the CleanStart team and someone will get back to you.', eyebrow: 'Contact', heading: greeting(input.firstName), blocks: [ { kind: 'paragraph', - text: "We've received your message and someone from the team will reply within one business day.", + text: "We've received your message and someone from the team will get back to you soon.", }, { kind: 'paragraph', text: 'If it is urgent, reply to this email and it will reach us directly.' }, signOff, diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 52fa6492c..7233d6270 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -217,6 +217,19 @@ const nextConfig: NextConfig = { destination: "/news/why-containers-drive-supply-chain-breaches", permanent: true, }, + // Second duplicate news article, same collision-suffix pattern as above. + // Both documents were created 2025-08-26 and render the same story with + // the same 104-character H1 (617 vs 616 words); both returned 200 with + // their own self-canonical. The unsuffixed slug is kept, matching the + // precedent set directly above. The CMS document still exists and is + // still in the sitemap until its `seo.indexable` is set to noindex. + { + source: + "/news/triam-security-rebrands-as-cleanstart-to-reflect-product-led-focus-on-securing-the-software-supply-chain-2", + destination: + "/news/triam-security-rebrands-as-cleanstart-to-reflect-product-led-focus-on-securing-the-software-supply-chain", + permanent: true, + }, ]; }, images: { diff --git a/apps/web/src/app/(legal)/privacy-policy/page.tsx b/apps/web/src/app/(legal)/privacy-policy/page.tsx index 9f46b9aad..c101025a2 100644 --- a/apps/web/src/app/(legal)/privacy-policy/page.tsx +++ b/apps/web/src/app/(legal)/privacy-policy/page.tsx @@ -8,6 +8,8 @@ import { buildPageMetadata } from "@/lib/seo/canonical"; import { resolveCmsSeo } from "@/lib/seo/cms-seo"; import { breadcrumbSchema } from "@/lib/seo/jsonld"; import { JsonLdGraph } from "@/components/JsonLdGraph"; +import { sectionIndexHref } from "@/lib/nav/section-index"; + import { getPageGraph } from "@/lib/seo/compose-page"; /** @@ -60,7 +62,7 @@ export default async function PrivacyPolicyPage(): Promise { const graph = await getPageGraph("/privacy-policy", [ breadcrumbSchema([ { name: "Home", path: "/" }, - { name: "Legal", path: "/legal" }, + { name: "Legal", path: sectionIndexHref("/legal") }, { name: doc.title }, ]), ]); diff --git a/apps/web/src/components/sections/Footer.tsx b/apps/web/src/components/sections/Footer.tsx index bafae9c49..efd1daebb 100644 --- a/apps/web/src/components/sections/Footer.tsx +++ b/apps/web/src/components/sections/Footer.tsx @@ -2,6 +2,8 @@ import React from "react"; import Link from "next/link"; import { CookiePreferencesButton } from "@/components/consent"; +import { sectionIndexHref } from "@/lib/nav/section-index"; + import { FooterSignature } from "@/components/sections/FooterSignature"; /** @@ -74,7 +76,7 @@ const CREDENTIALS: Badge[] = [ ]; const LEGAL_LINKS = [ - { label: "Legal", href: "/legal" }, + { label: "Legal", href: sectionIndexHref("/legal") }, { label: "Privacy Policy", href: "/privacy-policy" }, { label: "Acceptable Use Policy", href: "/legal/acceptable-use-policy" }, ]; @@ -300,10 +302,17 @@ export function Footer({ page, so default in-viewport prefetch fires an RSC (`?_rsc=`) request site-wide — pure crawl-budget waste for low-traffic policy pages. Click nav is unaffected. */} + {/* `before:` lifts the touch target from 21px (12px x + 1.75, no padding) to 27px, clearing the 24x24 WCAG + 2.5.8 AA floor — same pseudo-element trick as the nav + columns, so the utility row's visual rhythm is + untouched. The row uses gap-y-1 (4px) when it wraps, so + 3px of overhang each side still leaves the wrapped + lines from overlapping. */} {link.label} diff --git a/apps/web/src/components/sections/contact/ContactForm.tsx b/apps/web/src/components/sections/contact/ContactForm.tsx index a7d2e8e4d..05ba53cc5 100644 --- a/apps/web/src/components/sections/contact/ContactForm.tsx +++ b/apps/web/src/components/sections/contact/ContactForm.tsx @@ -162,7 +162,7 @@ export function ContactForm() { tone: "success", title: "Message sent", message: - "Thanks, we've received your message and will reply within 24 hours.", + "Thanks, we've received your message and will get back to you soon.", }); window.setTimeout(() => { setSubmitted(false); diff --git a/apps/web/src/components/sections/forms/BookDemoForm.tsx b/apps/web/src/components/sections/forms/BookDemoForm.tsx index ef77f41b3..2dc15ee15 100644 --- a/apps/web/src/components/sections/forms/BookDemoForm.tsx +++ b/apps/web/src/components/sections/forms/BookDemoForm.tsx @@ -150,7 +150,7 @@ export function BookDemoForm(): React.ReactElement { tone: "success", title: "Demo request received", message: - "Thanks, your demo request has been received. Our team will reach out within 24 hours.", + "Thanks, your demo request has been received. Our team will reach out to arrange a time.", }); window.setTimeout(() => setStatus(null), 5000); return; diff --git a/apps/web/src/components/sections/forms/DealRegistrationForm.tsx b/apps/web/src/components/sections/forms/DealRegistrationForm.tsx index a3e43383a..529671013 100644 --- a/apps/web/src/components/sections/forms/DealRegistrationForm.tsx +++ b/apps/web/src/components/sections/forms/DealRegistrationForm.tsx @@ -171,7 +171,7 @@ export function DealRegistrationForm(): React.ReactElement { tone: "success", title: "Deal registration received", message: - "Thanks, your deal registration has been received. We'll be in touch within one business day.", + "Thanks, your deal registration has been received. We'll be in touch about next steps.", }); window.setTimeout(() => setStatus(null), 6000); return; diff --git a/apps/web/src/components/sections/knowledge-hub/KnowledgeHubArticle.tsx b/apps/web/src/components/sections/knowledge-hub/KnowledgeHubArticle.tsx index 04737846f..d10589c65 100644 --- a/apps/web/src/components/sections/knowledge-hub/KnowledgeHubArticle.tsx +++ b/apps/web/src/components/sections/knowledge-hub/KnowledgeHubArticle.tsx @@ -2,6 +2,8 @@ import { HeroReveal, Reveal } from '@/components/ui/Reveal'; import type { KhArticle } from '@/lib/knowledge-hub'; import { RenderLexical } from '@/lib/renderLexical'; import { KnowledgeHubArticleFAQ } from './KnowledgeHubArticleFAQ'; +import { sectionIndexHref } from "@/lib/nav/section-index"; + import Link from 'next/link'; export function KnowledgeHubArticle({ @@ -88,7 +90,7 @@ function Breadcrumb({ category }: { category?: string | null | undefined }): Rea
  • diff --git a/apps/web/src/components/sections/partners/BecomePartnerCta.tsx b/apps/web/src/components/sections/partners/BecomePartnerCta.tsx index 58acb08b9..0dfb1d9fc 100644 --- a/apps/web/src/components/sections/partners/BecomePartnerCta.tsx +++ b/apps/web/src/components/sections/partners/BecomePartnerCta.tsx @@ -227,7 +227,7 @@ function PartnerModal({ open, onClose }: PartnerModalProps): React.ReactElement tone: "success", title: "Request received", message: - "Thanks, your partnership request has been received. Our team will be in touch within one business day.", + "Thanks, your partnership request has been received. Our team will be in touch soon.", }); // Modal UX: show the confirmation briefly, then close the popup. if (closeTimerRef.current != null) window.clearTimeout(closeTimerRef.current); diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index f1398c077..b71c8576f 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -6,6 +6,8 @@ // the href is data-only until the page exists. When a page is marked ✅ in WEB-PAGES.md, // switch its renderer from to in MegaMenu / MobileNav. +import { sectionIndexHref } from "@/lib/nav/section-index"; + export type NavLeaf = { label: string; href: string; @@ -195,7 +197,7 @@ export const NAV_TREE: NavItem[] = [ { label: "Resource Center", href: "/resource-center", icon: "folder" }, { label: "Case Studies", href: "/case-studies", icon: "doc-signed" }, { label: "Newsroom", href: "/news", icon: "newspaper" }, - { label: "Knowledge Hub", href: "/knowledge-hub", icon: "book-open" }, + { label: "Knowledge Hub", href: sectionIndexHref("/knowledge-hub"), icon: "book-open" }, ], }, { diff --git a/apps/web/src/lib/nav/section-index.test.ts b/apps/web/src/lib/nav/section-index.test.ts new file mode 100644 index 000000000..6f735ade1 --- /dev/null +++ b/apps/web/src/lib/nav/section-index.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { + SECTION_INDEX_REDIRECTS, + sectionIndexHref, +} from "./section-index"; + +describe("sectionIndexHref", () => { + it("resolves a section landing path to its canonical document", () => { + expect(sectionIndexHref("/knowledge-hub")).toBe( + "/knowledge-hub/vex-documents", + ); + expect(sectionIndexHref("/legal")).toBe( + "/legal/additional-third-party-terms", + ); + }); + + it("returns any other path unchanged", () => { + expect(sectionIndexHref("/blogs")).toBe("/blogs"); + expect(sectionIndexHref("/")).toBe("/"); + }); + + it("never maps a path to another redirecting path", () => { + for (const target of Object.values(SECTION_INDEX_REDIRECTS)) { + expect(SECTION_INDEX_REDIRECTS[target]).toBeUndefined(); + } + }); +}); diff --git a/apps/web/src/lib/nav/section-index.ts b/apps/web/src/lib/nav/section-index.ts new file mode 100644 index 000000000..e892b991c --- /dev/null +++ b/apps/web/src/lib/nav/section-index.ts @@ -0,0 +1,22 @@ +/** + * Section landing paths that have no standalone index page. Each permanently + * redirects to its canonical first document, issued in `proxy.ts` before any + * rendering so crawlers receive a hard 308. + * + * This is the single source of truth for those pairs. `proxy.ts` reads it to + * issue the redirect; link sites read it through `sectionIndexHref` so no + * internal link ever points at a URL that redirects. Keeping both sides on one + * map is what stops the two drifting apart. + */ +export const SECTION_INDEX_REDIRECTS: Record = { + "/knowledge-hub": "/knowledge-hub/vex-documents", + "/legal": "/legal/additional-third-party-terms", +}; + +/** + * Resolves an internal path to the URL that actually returns 200. Paths with no + * entry in the map are returned unchanged, so this is safe to wrap any href in. + */ +export function sectionIndexHref(path: string): string { + return SECTION_INDEX_REDIRECTS[path] ?? path; +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 499d97fbf..e1c9df0ba 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -23,6 +23,7 @@ import { import { detailRouteExists } from "@/lib/detail-route-not-found-guard"; import { isIndexingAllowed } from "@/lib/seo/indexing"; import { stripLegacyPaginationParams } from "@/lib/seo/legacy-params"; +import { SECTION_INDEX_REDIRECTS } from "@/lib/nav/section-index"; const PRODUCTION_HOST = "www.cleanstart.com"; const APEX_HOST = "cleanstart.com"; @@ -36,11 +37,8 @@ const DRAFT_BYPASS_COOKIE = "__prerender_bypass"; // async Header fetches the CMS), Next downgrades the redirect to a 200 + meta // refresh, which search engines read as a duplicate page (it inherited the // default/home canonical). Targets are displayOrder-pinned, so they're stable -// across re-seeds; the owning pages keep the same slugs as their fallback. -const SECTION_INDEX_REDIRECTS: Record = { - "/knowledge-hub": "/knowledge-hub/vex-documents", - "/legal": "/legal/additional-third-party-terms", -}; +// across re-seeds; the owning pages keep the same slugs as their fallback. The +// map lives in lib/nav so link sites resolve hrefs through the same pairs. // Stays report-only until the CSP burn-in is complete; set CSP_ENFORCE=1 to flip. const CSP_MODE: "report-only" | "enforce" = diff --git a/docs/operations/PRODUCTION-ROLLOUT.md b/docs/operations/PRODUCTION-ROLLOUT.md index e00d3c926..de21316e7 100644 --- a/docs/operations/PRODUCTION-ROLLOUT.md +++ b/docs/operations/PRODUCTION-ROLLOUT.md @@ -6,6 +6,16 @@ **Run mechanism:** prod CMS image lacks `scripts/`; `docker cp` the script into `cleanstart-cms-1` and run via `pnpm exec tsx` (no `--env-file` — env is via container vars). See memory note `prod-backfill-script-run-mechanism`. +**Deploy-order hazard:** `apps/cms` and `apps/web` both deploy from `main`, so one +merge fires the Docker build and the Vercel build together, and Vercel normally +wins by several minutes. Any change where the web app stops sending a field that +the CMS `forms` row still marks required will 400 every submission for the length +of that gap. There is no way to sequence the two from a single merge, so relax the +constraint in prod *before* merging: make the field optional, deploy, then run the +script that finishes the job. Field defs are additive-safe in the other direction, +because `validateFields` iterates the form definition and ignores submitted fields +it does not list. + ### /industries path rename follow-up (2026-09-08) Script: `scripts/repoint-industry-paths.ts` — repoints the CMS rows that key on @@ -38,6 +48,7 @@ docker exec -w /app/apps/cms cleanstart-cms-1 pnpm exec tsx \ | 11 | Webflow media filename cleanup | DONE | 2026-06-09 | | 14 | Legacy Webflow 301 redirects seed | DONE | 2026-06-11 | | 15 | SEO keywords backfill (guides) | DONE | 2026-06-29 | +| 17 | Forms overhaul field changes (`apply-form-field-changes.ts`) | DONE | 2026-09-09 | --- diff --git a/packages/schema/src/builders/jsonld.test.ts b/packages/schema/src/builders/jsonld.test.ts index 6803b00f7..dbfdcfa7c 100644 --- a/packages/schema/src/builders/jsonld.test.ts +++ b/packages/schema/src/builders/jsonld.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { + articleSchema, blogPostingSchema, breadcrumbSchema, eventSchema, jobPostingSchema, caseStudyListSchema, organizationSchema, + newsArticleSchema, reviewSchema, videoObjectSchema, } from "./jsonld"; @@ -193,3 +195,46 @@ describe("reviewSchema", () => { expect(reviewSchema([])).toEqual([]); }); }); + +describe("author fallback (Google Article requirement)", () => { + const ORG = { "@id": "https://www.cleanstart.com/#organization" }; + + it("attributes an Article with no named author to the Organization", () => { + const node = articleSchema({ title: "T", path: "/knowledge-hub/x" }); + expect(node.author).toEqual([ORG]); + }); + + it("attributes a BlogPosting with no named author to the Organization", () => { + const node = blogPostingSchema({ title: "T", path: "/blogs/x" }); + expect(node.author).toEqual([ORG]); + }); + + it("attributes a NewsArticle with no named author to the Organization", () => { + const node = newsArticleSchema({ title: "T", path: "/news/x" }); + expect(node.author).toEqual([ORG]); + }); + + it("prefers named contributors over the Organization fallback", () => { + const node = articleSchema({ + title: "T", + path: "/blogs/x", + authors: [{ name: "Dhanush VM", slug: "dhanush-vm" }], + }); + expect(node.author).toEqual([ + { + "@type": "Person", + name: "Dhanush VM", + url: "https://www.cleanstart.com/author/dhanush-vm", + }, + ]); + }); + + it("still collapses the house byline onto the Organization node", () => { + const node = articleSchema({ + title: "T", + path: "/blogs/x", + authors: [{ name: "CleanStart Security", slug: "cleanstart-security" }], + }); + expect(node.author).toEqual([ORG]); + }); +}); diff --git a/packages/schema/src/builders/jsonld.tsx b/packages/schema/src/builders/jsonld.tsx index 8e622c5da..90a1a37ef 100644 --- a/packages/schema/src/builders/jsonld.tsx +++ b/packages/schema/src/builders/jsonld.tsx @@ -53,6 +53,22 @@ const authorNode = (a: { name: string; slug?: string | undefined }) => name: a.name, ...(a.slug ? { url: absoluteUrl(`/author/${a.slug}`) } : {}), }; + +/** + * `author` is required on Article and its subtypes. Named contributors win when + * the document has them; otherwise the publishing Organization is the honest + * attribution, which is what the house byline already resolves to. Omitting the + * property instead leaves the node failing Google's Article requirements, which + * is what knowledge-hub, news and resources documents used to do. + */ +const authorProperty = ( + authors?: Array<{ name: string; slug?: string | undefined }> | undefined, +) => ({ + author: + authors && authors.length > 0 + ? authors.map(authorNode) + : [{ "@id": ORGANIZATION_ID }], +}); const WEBSITE_ID = `${SITE_URL}/#website`; /** Shared schema.org event-status IRIs (Event + webinar ItemList). */ @@ -361,11 +377,7 @@ export function blogPostingSchema({ ...(imageUrl ? { image: [imageUrl] } : {}), ...(publishedAt ? { datePublished: publishedAt } : {}), ...(lastModified ? { dateModified: lastModified } : {}), - ...(authors && authors.length > 0 - ? { - author: authors.map(authorNode), - } - : {}), + ...authorProperty(authors), ...(category ? { articleSection: category } : {}), ...(cleanRelatedLinks ? { relatedLink: cleanRelatedLinks } : {}), publisher: { "@id": ORGANIZATION_ID }, @@ -404,11 +416,7 @@ export function articleSchema({ ...(imageUrl ? { image: [imageUrl] } : {}), ...(publishedAt ? { datePublished: publishedAt } : {}), ...(lastModified ? { dateModified: lastModified } : {}), - ...(authors && authors.length > 0 - ? { - author: authors.map(authorNode), - } - : {}), + ...authorProperty(authors), ...(type ? { genre: type } : {}), publisher: { "@id": ORGANIZATION_ID }, }; @@ -454,11 +462,7 @@ export function newsArticleSchema({ ...(publishedAt ? { datePublished: publishedAt } : {}), ...(lastModified ? { dateModified: lastModified } : {}), ...(section ? { articleSection: section } : {}), - ...(authors && authors.length > 0 - ? { - author: authors.map(authorNode), - } - : {}), + ...authorProperty(authors), publisher: { "@id": ORGANIZATION_ID }, }; }
  • @@ -58,7 +58,7 @@

    Demo request

    Thanks, Priya

    -

    We've received your demo request. One of our solutions specialists will reach out within one business day to arrange a time that suits you.

    +

    We've received your demo request. One of our solutions specialists will reach out to arrange a time that suits you.

    It helps to know what you would like to see: your current base images, the CVE burden you are carrying, or the audit you are preparing for. Reply to this email with anything you want covered.

    The CleanStart team