From 82670fbffbc75134317a4f67a88b25a70b32b09f Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Fri, 3 Jul 2026 17:04:29 +0200 Subject: [PATCH 1/4] feat: integrations faq --- .../content-types/faq-item/schema.json | 23 ++++++ .../src/api/faq-item/controllers/faq-item.ts | 3 + apps/cms/src/api/faq-item/routes/faq-item.ts | 3 + .../cms/src/api/faq-item/services/faq-item.ts | 3 + .../content-types/integration/schema.json | 5 ++ .../src/components/sections/faq-items.json | 17 ++++ apps/cms/src/migration/integrations.ts | 80 ++++++++++++++++-- apps/cms/types/generated/components.d.ts | 12 +++ apps/cms/types/generated/contentTypes.d.ts | 31 +++++++ apps/web/src/components/content/faq/faq.tsx | 82 +++++++++++++++++++ apps/web/src/components/content/faq/index.ts | 1 + .../features/cms/pages/integrations/page.tsx | 5 ++ .../cms/pages/integrations/template.tsx | 25 ++++++ 13 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 apps/cms/src/api/faq-item/content-types/faq-item/schema.json create mode 100644 apps/cms/src/api/faq-item/controllers/faq-item.ts create mode 100644 apps/cms/src/api/faq-item/routes/faq-item.ts create mode 100644 apps/cms/src/api/faq-item/services/faq-item.ts create mode 100644 apps/cms/src/components/sections/faq-items.json create mode 100644 apps/web/src/components/content/faq/faq.tsx create mode 100644 apps/web/src/components/content/faq/index.ts diff --git a/apps/cms/src/api/faq-item/content-types/faq-item/schema.json b/apps/cms/src/api/faq-item/content-types/faq-item/schema.json new file mode 100644 index 0000000..5a6b8f3 --- /dev/null +++ b/apps/cms/src/api/faq-item/content-types/faq-item/schema.json @@ -0,0 +1,23 @@ +{ + "kind": "collectionType", + "collectionName": "faq_items", + "info": { + "singularName": "faq-item", + "pluralName": "faq-items", + "displayName": "FAQ Items" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "question": { + "type": "string", + "required": true + }, + "answer": { + "type": "richtext", + "required": true + } + } +} diff --git a/apps/cms/src/api/faq-item/controllers/faq-item.ts b/apps/cms/src/api/faq-item/controllers/faq-item.ts new file mode 100644 index 0000000..b3d8480 --- /dev/null +++ b/apps/cms/src/api/faq-item/controllers/faq-item.ts @@ -0,0 +1,3 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreController("api::faq-item.faq-item"); diff --git a/apps/cms/src/api/faq-item/routes/faq-item.ts b/apps/cms/src/api/faq-item/routes/faq-item.ts new file mode 100644 index 0000000..77e0ae8 --- /dev/null +++ b/apps/cms/src/api/faq-item/routes/faq-item.ts @@ -0,0 +1,3 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreRouter("api::faq-item.faq-item"); diff --git a/apps/cms/src/api/faq-item/services/faq-item.ts b/apps/cms/src/api/faq-item/services/faq-item.ts new file mode 100644 index 0000000..859a23f --- /dev/null +++ b/apps/cms/src/api/faq-item/services/faq-item.ts @@ -0,0 +1,3 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreService("api::faq-item.faq-item"); diff --git a/apps/cms/src/api/integration/content-types/integration/schema.json b/apps/cms/src/api/integration/content-types/integration/schema.json index 8b749c8..5d48000 100644 --- a/apps/cms/src/api/integration/content-types/integration/schema.json +++ b/apps/cms/src/api/integration/content-types/integration/schema.json @@ -76,6 +76,11 @@ "type": "component", "component": "shared.labels", "repeatable": true + }, + "faq_items_section": { + "type": "component", + "component": "sections.faq-items", + "repeatable": false } } } diff --git a/apps/cms/src/components/sections/faq-items.json b/apps/cms/src/components/sections/faq-items.json new file mode 100644 index 0000000..b2a8173 --- /dev/null +++ b/apps/cms/src/components/sections/faq-items.json @@ -0,0 +1,17 @@ +{ + "collectionName": "components_sections_faq_items", + "info": { + "displayName": "FAQ Items" + }, + "options": {}, + "attributes": { + "title": { + "type": "string" + }, + "items": { + "type": "relation", + "relation": "oneToMany", + "target": "api::faq-item.faq-item" + } + } +} diff --git a/apps/cms/src/migration/integrations.ts b/apps/cms/src/migration/integrations.ts index 9bbfe22..a4dff93 100644 --- a/apps/cms/src/migration/integrations.ts +++ b/apps/cms/src/migration/integrations.ts @@ -1,40 +1,98 @@ import { createCategories, uploadFromUrl, uploadMarkdownImages } from "./utils"; +const FAQ_POPULATE = "populate[slices][populate][categories][populate]=*"; + +const extractFaqQuestions = (slices) => { + if (!Array.isArray(slices)) return []; + return slices + .filter((s) => s.__component === "slices.faq") + .flatMap((slice) => + (slice.categories || []).flatMap((cat) => + (cat.questions || []).filter((q) => q.question && q.answer), + ), + ); +}; + +const getFaqSectionTitle = (slices) => { + if (!Array.isArray(slices)) return "Frequently Asked Questions"; + const faqSlice = slices.find((s) => s.__component === "slices.faq"); + return faqSlice?.categories?.[0]?.name || "Frequently Asked Questions"; +}; + +const createFaqItems = async (slices) => { + const questions = extractFaqQuestions(slices); + const documentIds: string[] = []; + for (const q of questions) { + const item = await strapi.documents("api::faq-item.faq-item").create({ + data: { question: q.question, answer: q.answer }, + }); + documentIds.push(item.documentId); + } + return documentIds; +}; + export const migrateIntegrations = async () => { strapi.log.info("Starting integrations migration..."); let migrated = 0; + let updated = 0; let skipped = 0; let failed = 0; const firstPage = (await fetch( - "https://api-prod.strapi.io/api/integrations?pagination[pageSize]=100", + `https://api-prod.strapi.io/api/integrations?pagination[pageSize]=100&${FAQ_POPULATE}`, ).then((res) => res.json())) as any; const allIntegrations = [...firstPage.data]; for (let page = 2; page <= firstPage.meta.pagination.pageCount; page++) { const nextPage = (await fetch( - `https://api-prod.strapi.io/api/integrations?pagination[pageSize]=100&pagination[page]=${page}`, + `https://api-prod.strapi.io/api/integrations?pagination[pageSize]=100&pagination[page]=${page}&${FAQ_POPULATE}`, ).then((res) => res.json())) as any; allIntegrations.push(...nextPage.data); } for (const integration of allIntegrations) { try { - let icon = null; - let image = null; - const existing = await strapi .documents("api::integration.integration") .findFirst({ filters: { slug: integration.attributes.slug }, + populate: { faq_items_section: { populate: { items: true } } }, }); + const slices = integration.attributes.slices || []; + if (existing) { - skipped++; + // Already migrated — update FAQ items if missing + const hasFaqItems = existing.faq_items_section?.items?.length > 0; + + if (hasFaqItems) { + skipped++; + continue; + } + + const faqItemIds = await createFaqItems(slices); + if (faqItemIds.length > 0) { + await strapi.documents("api::integration.integration").update({ + documentId: existing.documentId, + status: "published", + data: { + faq_items_section: { + title: getFaqSectionTitle(slices), + items: faqItemIds, + }, + }, + }); + updated++; + } else { + skipped++; + } continue; } + let icon = null; + let image = null; + if (integration.attributes.logo?.media?.data?.attributes?.url) { icon = await uploadFromUrl( integration.attributes.logo.media.data.attributes.url, @@ -58,6 +116,8 @@ export const migrateIntegrations = async () => { integration.attributes.content || "", ); + const faqItemIds = await createFaqItems(slices); + await strapi.documents("api::integration.integration").create({ status: "published", data: { @@ -78,6 +138,12 @@ export const migrateIntegrations = async () => { keywords: integration.attributes.seo?.keywords, metaImage: image?.id, }, + ...(faqItemIds.length > 0 && { + faq_items_section: { + title: getFaqSectionTitle(slices), + items: faqItemIds, + }, + }), }, }); migrated++; @@ -91,6 +157,6 @@ export const migrateIntegrations = async () => { } strapi.log.info( - `Integrations migration finished. Migrated: ${migrated}, Skipped: ${skipped}, Failed: ${failed}`, + `Integrations migration finished. Migrated: ${migrated}, Updated (FAQ): ${updated}, Skipped: ${skipped}, Failed: ${failed}`, ); }; diff --git a/apps/cms/types/generated/components.d.ts b/apps/cms/types/generated/components.d.ts index e7569ab..64ca468 100644 --- a/apps/cms/types/generated/components.d.ts +++ b/apps/cms/types/generated/components.d.ts @@ -45,6 +45,17 @@ export interface SectionsCta extends Struct.ComponentSchema { }; } +export interface SectionsFaqItems extends Struct.ComponentSchema { + collectionName: 'components_sections_faq_items'; + info: { + displayName: 'FAQ Items'; + }; + attributes: { + items: Schema.Attribute.Relation<'oneToMany', 'api::faq-item.faq-item'>; + title: Schema.Attribute.String; + }; +} + export interface SectionsHighlights extends Struct.ComponentSchema { collectionName: 'components_sections_highlights'; info: { @@ -215,6 +226,7 @@ declare module '@strapi/strapi' { 'sections.card-grid': SectionsCardGrid; 'sections.card-grid-item': SectionsCardGridItem; 'sections.cta': SectionsCta; + 'sections.faq-items': SectionsFaqItems; 'sections.highlights': SectionsHighlights; 'sections.search': SectionsSearch; 'shared.button': SharedButton; diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index b8e88e1..28d6b0f 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -563,6 +563,35 @@ export interface ApiEmailTemplateEmailTemplate }; } +export interface ApiFaqItemFaqItem extends Struct.CollectionTypeSchema { + collectionName: 'faq_items'; + info: { + displayName: 'FAQ Items'; + pluralName: 'faq-items'; + singularName: 'faq-item'; + }; + options: { + draftAndPublish: false; + }; + attributes: { + answer: Schema.Attribute.RichText & Schema.Attribute.Required; + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'api::faq-item.faq-item' + > & + Schema.Attribute.Private; + publishedAt: Schema.Attribute.DateTime; + question: Schema.Attribute.String & Schema.Attribute.Required; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + }; +} + export interface ApiHelpPageHelpPage extends Struct.CollectionTypeSchema { collectionName: 'help_pages'; info: { @@ -714,6 +743,7 @@ export interface ApiIntegrationIntegration extends Struct.CollectionTypeSchema { createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; description: Schema.Attribute.Text & Schema.Attribute.Required; + faq_items_section: Schema.Attribute.Component<'sections.faq-items', false>; image: Schema.Attribute.Media<'images'>; labels: Schema.Attribute.Component<'shared.labels', true>; locale: Schema.Attribute.String & Schema.Attribute.Private; @@ -2554,6 +2584,7 @@ declare module '@strapi/strapi' { 'api::country.country': ApiCountryCountry; 'api::cta.cta': ApiCtaCta; 'api::email-template.email-template': ApiEmailTemplateEmailTemplate; + 'api::faq-item.faq-item': ApiFaqItemFaqItem; 'api::help-page.help-page': ApiHelpPageHelpPage; 'api::home.home': ApiHomeHome; 'api::integration-category.integration-category': ApiIntegrationCategoryIntegrationCategory; diff --git a/apps/web/src/components/content/faq/faq.tsx b/apps/web/src/components/content/faq/faq.tsx new file mode 100644 index 0000000..8867c49 --- /dev/null +++ b/apps/web/src/components/content/faq/faq.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; +import { Markdown } from "@/components/content/markdown"; +import { cn } from "@/lib/utils"; + +type FaqItem = { + id: string; + question: string; + answer: string; +}; + +type Props = { + title?: string | null; + items: FaqItem[]; + className?: string; +}; + +const Faq = ({ title, items, className }: Props) => { + const [openId, setOpenId] = useState(null); + + if (items.length === 0) return null; + + return ( +
+ {title && ( +

+ {title} +

+ )} +
+ {items.map((item) => { + const isOpen = openId === item.id; + return ( +
+ +
+
+
+
+ +
+
+
+
+
+ ); + })} +
+
+ ); +}; + +export type { FaqItem }; +export { Faq }; diff --git a/apps/web/src/components/content/faq/index.ts b/apps/web/src/components/content/faq/index.ts new file mode 100644 index 0000000..a1789e3 --- /dev/null +++ b/apps/web/src/components/content/faq/index.ts @@ -0,0 +1 @@ +export * from "./faq"; diff --git a/apps/web/src/features/cms/pages/integrations/page.tsx b/apps/web/src/features/cms/pages/integrations/page.tsx index 27475d2..93757e6 100644 --- a/apps/web/src/features/cms/pages/integrations/page.tsx +++ b/apps/web/src/features/cms/pages/integrations/page.tsx @@ -21,6 +21,11 @@ const query = { icon: true, }, }, + faq_items_section: { + populate: { + items: true, + }, + }, }, } satisfies GetQueryParams; diff --git a/apps/web/src/features/cms/pages/integrations/template.tsx b/apps/web/src/features/cms/pages/integrations/template.tsx index 5712bf1..05e6c2b 100644 --- a/apps/web/src/features/cms/pages/integrations/template.tsx +++ b/apps/web/src/features/cms/pages/integrations/template.tsx @@ -2,6 +2,7 @@ import { Button, Container } from "@repo/strapi-ui"; import Image from "next/image"; import Link from "next/link"; import { ContentCard } from "@/components/content/card/card"; +import { Faq } from "@/components/content/faq"; import { Markdown } from "@/components/content/markdown"; import { PageNav } from "@/components/content/page-nav"; import { Breadcrumbs } from "@/components/layout/breadcrumbs"; @@ -138,6 +139,30 @@ const IntegrationTemplate = ({ document }: Props) => { + {(document.faq_items_section?.items?.length ?? 0) > 0 && ( +
+ +
+ + item.question && item.answer + ? [ + { + id: item.documentId, + question: item.question, + answer: item.answer, + }, + ] + : [], + )} + /> +
+
+
+ )} ); }; From 453c8035f04c27cf1eb3a1dcd45322b12feafb9e Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Fri, 3 Jul 2026 17:25:24 +0200 Subject: [PATCH 2/4] fix: integration issues --- .../components/content/page-nav/page-nav.tsx | 15 +- .../cms/pages/integrations/template.tsx | 219 +++++++++++------- .../features/cms/pages/package/template.tsx | 2 +- .../features/cms/pages/template/template.tsx | 2 +- 4 files changed, 143 insertions(+), 95 deletions(-) diff --git a/apps/web/src/components/content/page-nav/page-nav.tsx b/apps/web/src/components/content/page-nav/page-nav.tsx index beb9da0..9a860c1 100644 --- a/apps/web/src/components/content/page-nav/page-nav.tsx +++ b/apps/web/src/components/content/page-nav/page-nav.tsx @@ -79,19 +79,24 @@ const PageNav = ({ return () => observerRef.current?.disconnect(); }, [navItems]); + const NAVBAR_HEIGHT = 96; + const handleClick = (e: React.MouseEvent, id: string) => { e.preventDefault(); - document - .getElementById(id) - ?.scrollIntoView({ behavior: "smooth", block: "start" }); + const el = document.getElementById(id); + if (el) { + const top = + el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT; + window.scrollTo({ top, behavior: "smooth" }); + } setActiveId(id); }; if (!navItems.length) return null; return ( -
-
+
+

{title}

diff --git a/apps/web/src/features/cms/pages/integrations/template.tsx b/apps/web/src/features/cms/pages/integrations/template.tsx index 05e6c2b..11866ea 100644 --- a/apps/web/src/features/cms/pages/integrations/template.tsx +++ b/apps/web/src/features/cms/pages/integrations/template.tsx @@ -1,4 +1,5 @@ import { Button, Container } from "@repo/strapi-ui"; +import { BookOpen, Puzzle } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { ContentCard } from "@/components/content/card/card"; @@ -8,6 +9,7 @@ import { PageNav } from "@/components/content/page-nav"; import { Breadcrumbs } from "@/components/layout/breadcrumbs"; import { Hero, HeroSection } from "@/components/layout/hero"; import { Navigation } from "@/components/layout/navigation"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cmsImageUrl } from "@/features/cms/lib/image-url"; import type { IntegrationPageData } from "@/features/cms/pages/integrations"; @@ -18,14 +20,16 @@ type Props = { const IntegrationTemplate = ({ document }: Props) => { const hasPackages = (document.packages?.length ?? 0) > 0; const hasTemplates = (document.templates?.length ?? 0) > 0; + const hasExtensions = hasPackages || hasTemplates; + const hasContent = !!document.content; return (
-
-
+
+
@@ -42,103 +46,142 @@ const IntegrationTemplate = ({ document }: Props) => { alt={document.name!} width={150} height={150} - className="mt-10 mr-14 h-fit rounded-lg object-cover ml-auto" + className="sm:ml-auto shrink-0 rounded-lg object-cover" /> )}
+ -
- {hasPackages && ( -
-
-

- Packages built for {document.name} -

- -
-
- {document.packages?.map((pkg) => ( - - ))} -
-
- )} - {hasTemplates && ( -
-

- Templates built with {document.name} -

-
- {document.templates?.map((tpl) => ( - - ))} -
-
- )} -
-
-
-
- {document.content && ( -
- + {hasExtensions ? ( + + + {hasContent && ( + } + > + Guide + + )} + } + > + Extensions + + + + {hasContent && ( + +
+
+
+
+ +
+
+ +
+
+ )} + + + {hasPackages && ( +
+
+

+ Packages built for {document.name} +

+ +
+
+ {document.packages?.map((pkg) => ( + + ))} +
+
)} -
- + {hasTemplates && ( +
+

+ Templates built with {document.name} +

+
+ {document.templates?.map((tpl) => ( + + ))} +
+
+ )} + + + ) : hasContent ? ( +
+
+
+
+ +
+
+ +
-
+ ) : null} + {(document.faq_items_section?.items?.length ?? 0) > 0 && (
diff --git a/apps/web/src/features/cms/pages/package/template.tsx b/apps/web/src/features/cms/pages/package/template.tsx index 0b96644..a8bf1d0 100644 --- a/apps/web/src/features/cms/pages/package/template.tsx +++ b/apps/web/src/features/cms/pages/package/template.tsx @@ -97,7 +97,7 @@ const PackageTemplate = ({ document, communityCta }: Props) => { {/* ── Sidebar ── */}