diff --git a/AGENTS.md b/AGENTS.md index a34e4b36c..7802bc36e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,7 +282,8 @@ These are hard rules. Do not work around them — flag and stop instead. - **Guest Contributors:** still open. Ship as an additive optional `contributorType: 'staff' | 'guest'` field on the existing `authors` collection — do **not** create a separate collection. - **Knowledge Hub:** `knowledgeBase` (versioned + drafts, slug-change-redirect hook, SEO field group) + `knowledgeCategories` (hierarchical with self-referencing `parent`). - **Case studies:** listing **plus** a detail route at `/case-studies/[slug]` (reversed 2026-09-21; the collection was listing-only before). `summary` is the card blurb, `body` (Lexical) is the article. `outcomes` and `glance` are optional arrays the detail page reshapes around; `quote`/`quoteAuthor`/`quoteRole` are per-study and are **not** read from the homepage testimonial list; `featured` picks the listing spotlight, falling back to newest. Carries the full routed-collection machinery (slug-change redirect, SEO group, IndexNow, search sync) — adding a detail route without those breaks indexed URLs on rename. -- **Blog sidebar CTAs:** `blogs` carries two optional editor picks, `ctaResource` (relationship to published `resources`, the Learn step) and `ctaImage` (a catalog image slug, the Explore step). Both are overrides: unset, `apps/web/src/lib/blog-cta/` matches from the post's title, abstract, category and headings. A pick that is unpublished or not in the catalog is ignored, never linked. The POC step always points at `/book-a-demo` with `source` params. The admin labels them "Sidebar resource (Learn step)" and "Images portal entry (Explore step)" (not "image", which reads as a picture) and shows the automatic pick under each (`BlogCtaAutoPick`, via the CMS endpoint `GET /api/blog-cta-suggestion?id=`, which asks the web route of the same name using `WEB_REVALIDATE_URL` and `WEB_REVALIDATE_SECRET`); the scoring lives in apps/web only, so do not copy it into the CMS. The other layouts that were reviewed, and how to revive the Edge Dock (kept under the tag `design/blog-cta-edge-dock`), are in `docs/web/BLOG-CTA-VARIANTS.md`. +- **Blog sidebar CTAs:** `blogs` carries two optional editor picks, `ctaResource` (relationship to published `resources`, the Learn step) and `ctaImage` (a catalog image slug, the Explore step). Both are overrides: unset, `apps/web/src/lib/blog-cta/` matches from the post's title, abstract, category and headings. A pick that is unpublished or not in the catalog is ignored, never linked. The POC step always points at `/book-a-demo` with `source` params. The admin labels them "Sidebar resource (Learn step)" and "Images portal entry (Explore step)" (not "image", which reads as a picture) and shows the automatic pick under each (`BlogCtaAutoPick`, via the CMS endpoint `GET /api/blog-cta-suggestion?id=`, which asks the web route of the same name using `WEB_REVALIDATE_URL` and `WEB_REVALIDATE_SECRET`); the scoring lives in apps/web only, so do not copy it into the CMS. The other layouts that were reviewed, and how to revive the Edge Dock (kept under the tag `design/blog-cta-edge-dock`), are in `docs/web/BLOG-CTA-VARIANTS.md`. Advisories use the same Ladder and bottom bar through `getAdvisoryCtas` in `lib/blog-cta/resolve.ts`, and carry the same two editor picks (`ctaResource`, `ctaImage`, migration `20261102_120000_advisories_cta_picks`). On advisories the image pick is a searchable picker (`CatalogImagePicker`, fed by `GET /api/catalog-images`) instead of a typed name; the automatic-pick hint under both fields works for either collection (`/api/blog-cta-suggestion?collection=advisories`). Unset, the advisory's `technology` label names the catalog image first (`matchImageHint`), then the text decides. Click events are `advisory_ladder_rail_`; the blog's `blog_ladder_*` names are unchanged. +- **Videos in articles:** the rich-text editor has a `videoEmbed` block (`apps/cms/src/payload/blocks/VideoEmbedBlock.ts`, inserted from the slash and Add menus) that stores only a relationship to a published `videos` doc plus an optional caption. Pages call `resolveLexicalVideos` (`apps/web/src/lib/lexical-videos.ts`) after `highlightLexical`, because the blog body renders inside a client component and cannot fetch; it writes `fields._video` on each block and removes a block whose video is gone, private or unpublished. The renderer draws it as `ArticleVideo` (poster first, YouTube only after a click). Wired on blog, guide, advisory, news, resource and knowledge hub pages; any other `RenderLexical` page renders the block as nothing until it adds the pass. A video edit does not purge articles: they refresh on their normal revalidate window. - **Integrations (Phase J1):** `config` field encrypted at rest via `lib/integrations/secrets.ts`. Per-row `routing` group (`events[]`, `collections[]`, `formSlugs[]`, `minLeadScore`). Admin endpoints: `/api/integrations/:id/test`, `/health`, `/audit` (file: `payload/endpoints/integrations-actions.ts`). Dead-letter retry reuses `WebhookDeadLetter`. Router: `lib/integrations/router.ts`. --- diff --git a/CLAUDE.md b/CLAUDE.md index 1f6079f24..6500d5280 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,7 +273,8 @@ These are hard rules. Do not work around them — flag and stop instead. - **Guest Contributors:** still open. Ship as an additive optional `contributorType: 'staff' | 'guest'` field on the existing `authors` collection — do **not** create a separate collection. - **Knowledge Hub:** `knowledgeBase` (versioned + drafts, slug-change-redirect hook, SEO field group) + `knowledgeCategories` (hierarchical with self-referencing `parent`). - **Case studies:** listing **plus** a detail route at `/case-studies/[slug]` (reversed 2026-09-21; the collection was listing-only before). `summary` is the card blurb, `body` (Lexical) is the article. `outcomes` and `glance` are optional arrays the detail page reshapes around; `quote`/`quoteAuthor`/`quoteRole` are per-study and are **not** read from the homepage testimonial list; `featured` picks the listing spotlight, falling back to newest. Carries the full routed-collection machinery (slug-change redirect, SEO group, IndexNow, search sync) — adding a detail route without those breaks indexed URLs on rename. -- **Blog sidebar CTAs:** `blogs` carries two optional editor picks, `ctaResource` (relationship to published `resources`, the Learn step) and `ctaImage` (a catalog image slug, the Explore step). Both are overrides: unset, `apps/web/src/lib/blog-cta/` matches from the post's title, abstract, category and headings. A pick that is unpublished or not in the catalog is ignored, never linked. The POC step always points at `/book-a-demo` with `source` params. The admin labels them "Sidebar resource (Learn step)" and "Images portal entry (Explore step)" (not "image", which reads as a picture) and shows the automatic pick under each (`BlogCtaAutoPick`, via the CMS endpoint `GET /api/blog-cta-suggestion?id=`, which asks the web route of the same name using `WEB_REVALIDATE_URL` and `WEB_REVALIDATE_SECRET`); the scoring lives in apps/web only, so do not copy it into the CMS. The other layouts that were reviewed, and how to revive the Edge Dock (kept under the tag `design/blog-cta-edge-dock`), are in `docs/web/BLOG-CTA-VARIANTS.md`. +- **Blog sidebar CTAs:** `blogs` carries two optional editor picks, `ctaResource` (relationship to published `resources`, the Learn step) and `ctaImage` (a catalog image slug, the Explore step). Both are overrides: unset, `apps/web/src/lib/blog-cta/` matches from the post's title, abstract, category and headings. A pick that is unpublished or not in the catalog is ignored, never linked. The POC step always points at `/book-a-demo` with `source` params. The admin labels them "Sidebar resource (Learn step)" and "Images portal entry (Explore step)" (not "image", which reads as a picture) and shows the automatic pick under each (`BlogCtaAutoPick`, via the CMS endpoint `GET /api/blog-cta-suggestion?id=`, which asks the web route of the same name using `WEB_REVALIDATE_URL` and `WEB_REVALIDATE_SECRET`); the scoring lives in apps/web only, so do not copy it into the CMS. The other layouts that were reviewed, and how to revive the Edge Dock (kept under the tag `design/blog-cta-edge-dock`), are in `docs/web/BLOG-CTA-VARIANTS.md`. Advisories use the same Ladder and bottom bar through `getAdvisoryCtas` in `lib/blog-cta/resolve.ts`, and carry the same two editor picks (`ctaResource`, `ctaImage`, migration `20261102_120000_advisories_cta_picks`). On advisories the image pick is a searchable picker (`CatalogImagePicker`, fed by `GET /api/catalog-images`) instead of a typed name; the automatic-pick hint under both fields works for either collection (`/api/blog-cta-suggestion?collection=advisories`). Unset, the advisory's `technology` label names the catalog image first (`matchImageHint`), then the text decides. Click events are `advisory_ladder_rail_`; the blog's `blog_ladder_*` names are unchanged. +- **Videos in articles:** the rich-text editor has a `videoEmbed` block (`apps/cms/src/payload/blocks/VideoEmbedBlock.ts`, inserted from the slash and Add menus) that stores only a relationship to a published `videos` doc plus an optional caption. Pages call `resolveLexicalVideos` (`apps/web/src/lib/lexical-videos.ts`) after `highlightLexical`, because the blog body renders inside a client component and cannot fetch; it writes `fields._video` on each block and removes a block whose video is gone, private or unpublished. The renderer draws it as `ArticleVideo` (poster first, YouTube only after a click). Wired on blog, guide, advisory, news, resource and knowledge hub pages; any other `RenderLexical` page renders the block as nothing until it adds the pass. A video edit does not purge articles: they refresh on their normal revalidate window. - **Integrations (Phase J1):** `config` field encrypted at rest via `lib/integrations/secrets.ts`. Per-row `routing` group (`events[]`, `collections[]`, `formSlugs[]`, `minLeadScore`). Admin endpoints: `/api/integrations/:id/test`, `/health`, `/audit` (file: `payload/endpoints/integrations-actions.ts`). Dead-letter retry reuses `WebhookDeadLetter`. Router: `lib/integrations/router.ts`. --- diff --git a/apps/cms/src/app/(payload)/admin/importMap.js b/apps/cms/src/app/(payload)/admin/importMap.js index 3a76a6b31..32c7fdebf 100644 --- a/apps/cms/src/app/(payload)/admin/importMap.js +++ b/apps/cms/src/app/(payload)/admin/importMap.js @@ -156,6 +156,7 @@ import { VideoLengthCell as VideoLengthCell_4ae28695c16ecdf12c15a176eb6e0774 } f import { VideoStateCell as VideoStateCell_70d255ecab4ca7e768a3cff708566031 } from '@/payload/admin/components/VideoStateCell.tsx' import { VideoTopicSuggestions as VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591 } from '@/payload/admin/components/VideoTopicSuggestions.tsx' import { UseDescriptionSummary as UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232 } from '@/payload/admin/components/UseDescriptionSummary.tsx' +import { CatalogImagePicker as CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049 } from '@/payload/admin/components/CatalogImagePicker.tsx' /** @type import('payload').ImportMap */ export const importMap = { @@ -317,4 +318,5 @@ export const importMap = { "@/payload/admin/components/VideoStateCell.tsx#VideoStateCell": VideoStateCell_70d255ecab4ca7e768a3cff708566031, "@/payload/admin/components/VideoTopicSuggestions.tsx#VideoTopicSuggestions": VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591, "@/payload/admin/components/UseDescriptionSummary.tsx#UseDescriptionSummary": UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232, + "@/payload/admin/components/CatalogImagePicker.tsx#CatalogImagePicker": CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049, } diff --git a/apps/cms/src/migrations/20261102_120000_advisories_cta_picks.ts b/apps/cms/src/migrations/20261102_120000_advisories_cta_picks.ts new file mode 100644 index 000000000..f5cc474d9 --- /dev/null +++ b/apps/cms/src/migrations/20261102_120000_advisories_cta_picks.ts @@ -0,0 +1,47 @@ +import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-postgres' + +/** + * Advisories gain the two optional editor picks blogs already have, for the + * sidebar CTAs on the advisory page: `cta_resource_id` (the resource offered + * in the Learn step) and `cta_image` (the catalog image offered in the Explore + * step). Both are overrides; the web app falls back to matching from the + * advisory when unset. + * + * Mirrored on `_advisories_v` because advisories are versioned with drafts. + * Names follow `20261005_090000_add_blog_cta_picks.ts`, which follows what + * Payload itself generated for the same shape on blogs. + * + * Hand-written rather than generated, for the reason spelled out in + * `20260921_100000_add_case_study_detail_fields.ts`: the newest `.json` schema + * snapshot is from June, so `payload migrate:create` would emit every schema + * change made since. + * + * Idempotent throughout: local databases run `push: true` and already have + * these columns. + */ + +const UP = [ + sql`ALTER TABLE "advisories" ADD COLUMN IF NOT EXISTS "cta_resource_id" integer;`, + sql`ALTER TABLE "advisories" ADD COLUMN IF NOT EXISTS "cta_image" varchar;`, + sql`ALTER TABLE "_advisories_v" ADD COLUMN IF NOT EXISTS "version_cta_resource_id" integer;`, + sql`ALTER TABLE "_advisories_v" ADD COLUMN IF NOT EXISTS "version_cta_image" varchar;`, + + sql`DO $$ BEGIN ALTER TABLE "advisories" ADD CONSTRAINT "advisories_cta_resource_id_resources_id_fk" FOREIGN KEY ("cta_resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;`, + sql`DO $$ BEGIN ALTER TABLE "_advisories_v" ADD CONSTRAINT "_advisories_v_version_cta_resource_id_resources_id_fk" FOREIGN KEY ("version_cta_resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;`, + + sql`CREATE INDEX IF NOT EXISTS "advisories_cta_resource_idx" ON "advisories" USING btree ("cta_resource_id");`, + sql`CREATE INDEX IF NOT EXISTS "_advisories_v_version_version_cta_resource_idx" ON "_advisories_v" USING btree ("version_cta_resource_id");`, +] + +export async function up({ db }: MigrateUpArgs): Promise { + for (const statement of UP) { + await db.execute(statement) + } +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql`ALTER TABLE "advisories" DROP COLUMN IF EXISTS "cta_resource_id";`) + await db.execute(sql`ALTER TABLE "advisories" DROP COLUMN IF EXISTS "cta_image";`) + await db.execute(sql`ALTER TABLE "_advisories_v" DROP COLUMN IF EXISTS "version_cta_resource_id";`) + await db.execute(sql`ALTER TABLE "_advisories_v" DROP COLUMN IF EXISTS "version_cta_image";`) +} diff --git a/apps/cms/src/migrations/index.ts b/apps/cms/src/migrations/index.ts index 9befeccc6..1ade98091 100644 --- a/apps/cms/src/migrations/index.ts +++ b/apps/cms/src/migrations/index.ts @@ -67,6 +67,7 @@ import * as migration_20261027_120000_advisories_structured_fields from './20261 import * as migration_20261028_120000_advisories_cover_title from './20261028_120000_advisories_cover_title'; import * as migration_20261026_120000_add_missing_job_log_task_slugs from './20261026_120000_add_missing_job_log_task_slugs'; import * as migration_20261031_100000_add_videos from './20261031_100000_add_videos'; +import * as migration_20261102_120000_advisories_cta_picks from './20261102_120000_advisories_cta_picks'; export const migrations = [ { @@ -389,4 +390,9 @@ export const migrations = [ down: migration_20261031_100000_add_videos.down, name: '20261031_100000_add_videos', }, + { + up: migration_20261102_120000_advisories_cta_picks.up, + down: migration_20261102_120000_advisories_cta_picks.down, + name: '20261102_120000_advisories_cta_picks', + }, ]; diff --git a/apps/cms/src/payload-types.ts b/apps/cms/src/payload-types.ts index ae29724ca..fb60f0d1f 100644 --- a/apps/cms/src/payload-types.ts +++ b/apps/cms/src/payload-types.ts @@ -1941,6 +1941,11 @@ export interface Advisory { }; [k: string]: unknown; } | null; + /** + * Optional. The resource offered in the "Learn" step of the advisory sidebar. If unset, the page picks the published resource most related to this advisory, which is a keyword match, so choose one here when none of the automatic picks fits. + */ + ctaResource?: (number | null) | Resource; + ctaImage?: string | null; heroImage?: (number | null) | Media; faqs?: | { @@ -10423,6 +10428,8 @@ export interface AdvisoriesSelect { body?: T; impact?: T; resolution?: T; + ctaResource?: T; + ctaImage?: T; heroImage?: T; faqs?: | T diff --git a/apps/cms/src/payload.config.ts b/apps/cms/src/payload.config.ts index c7ab9ad22..8f05e9d1a 100644 --- a/apps/cms/src/payload.config.ts +++ b/apps/cms/src/payload.config.ts @@ -60,6 +60,7 @@ import { SearchLog } from './payload/collections/SearchLog'; import { Users } from './payload/collections/Users'; import { Webinars } from './payload/collections/Webinars'; import { blogCtaSuggestionEndpoint } from './payload/endpoints/blog-cta-suggestion'; +import { catalogImagesEndpoint } from './payload/endpoints/catalog-images'; import { cachePurgeEndpoint } from './payload/endpoints/cache-purge'; import { cacheSearchEndpoint } from './payload/endpoints/cache-search'; import { canonicalCheckEndpoint } from './payload/endpoints/canonical-check'; @@ -488,6 +489,7 @@ export default buildConfig({ canonicalCheckEndpoint, cachePurgeEndpoint, blogCtaSuggestionEndpoint, + catalogImagesEndpoint, cacheSearchEndpoint, searchQueryEndpoint, searchAnalyticsEndpoint, diff --git a/apps/cms/src/payload/admin/components/AddMenu/AddMenuToolbarButton.tsx b/apps/cms/src/payload/admin/components/AddMenu/AddMenuToolbarButton.tsx index 008df5d15..d0c4a3109 100644 --- a/apps/cms/src/payload/admin/components/AddMenu/AddMenuToolbarButton.tsx +++ b/apps/cms/src/payload/admin/components/AddMenu/AddMenuToolbarButton.tsx @@ -89,6 +89,12 @@ const ICONS = { ), + video: ( + + ), code: (
+ {label} + {description ? {description} : null} +
+ + {chosen ? {chosen} : Automatic, click to choose one} + + + {chosen ? ( + + ) : null} +
+ + + + + { + setValue(name); + close(); + }} + loading={names.phase === 'loading'} + emptyLabel={names.phase === 'failed' ? 'The catalog could not be loaded just now.' : 'No images match.'} + autoFocus + ariaLabel="Search catalog images" + /> + + +
+ ); +}; diff --git a/apps/cms/src/payload/admin/components/SlashMenuPlugin.tsx b/apps/cms/src/payload/admin/components/SlashMenuPlugin.tsx index 1ea9c1828..e73304798 100644 --- a/apps/cms/src/payload/admin/components/SlashMenuPlugin.tsx +++ b/apps/cms/src/payload/admin/components/SlashMenuPlugin.tsx @@ -124,6 +124,12 @@ const ICON = { ), + video: ( + + ), code: (
- {legacy ? ( - - ) : hasContent(body) ? ( -
- -
- ) : null} - - +
+ {legacy ? ( + + ) : hasContent(body) ? ( +
+ +
+ ) : null} + + +
+ +
+