diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c2c81c25..8d65cfaa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,30 +1,24 @@ -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - version: 2 updates: - package-ecosystem: "npm" - directory: "/" + directories: + - "/" + - "/apps/*" + - "/packages/*" + - "/qa/tests/playwright" + target-branch: "main" schedule: - interval: "weekly" + interval: "monthly" allow: - - dependency-type: "direct" - # Skip regular update PRs, do only security updates - # https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference#open-pull-requests-limit- - open-pull-requests-limit: 0 - - - package-ecosystem: "npm" - directory: "/apps/ui" - schedule: - interval: "weekly" - allow: - - dependency-type: "direct" - open-pull-requests-limit: 0 - - - package-ecosystem: "npm" - directory: "/apps/strapi" - schedule: - interval: "weekly" - allow: - - dependency-type: "direct" - open-pull-requests-limit: 0 + - dependency-name: "@strapi/*" + update-types: + - "version-update:semver-minor" + - "version-update:semver-patch" + groups: + strapi: + patterns: + - "@strapi/*" + group-by: "dependency-name" + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3481f47..9af535c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - dev + - staging concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} diff --git a/apps/strapi/package.json b/apps/strapi/package.json index 69a51593..2adc6c1f 100644 --- a/apps/strapi/package.json +++ b/apps/strapi/package.json @@ -39,7 +39,7 @@ "@strapi/plugin-color-picker": "5.52.2", "@strapi/plugin-sentry": "5.52.2", "@strapi/plugin-users-permissions": "5.52.2", - "@strapi/strapi": "5.52.2", + "@strapi/strapi": "catalog:", "@strapi/utils": "5.52.2", "jsonwebtoken": "9.0.3", "lodash": "catalog:", diff --git a/apps/strapi/src/api/hubspot-form/content-types/hubspot-form/schema.json b/apps/strapi/src/api/hubspot-form/content-types/hubspot-form/schema.json index 98a31564..8770a0df 100644 --- a/apps/strapi/src/api/hubspot-form/content-types/hubspot-form/schema.json +++ b/apps/strapi/src/api/hubspot-form/content-types/hubspot-form/schema.json @@ -25,6 +25,14 @@ "placeholderHeight": { "type": "integer", "default": 300 + }, + "thankYouImage": { + "type": "component", + "component": "utilities.basic-image", + "repeatable": false + }, + "thankYouMessage": { + "type": "richtext" } } } diff --git a/apps/strapi/src/populateDynamicZone/forms/hubspot-form-ssr.ts b/apps/strapi/src/populateDynamicZone/forms/hubspot-form-ssr.ts index cd675ee1..51c61068 100644 --- a/apps/strapi/src/populateDynamicZone/forms/hubspot-form-ssr.ts +++ b/apps/strapi/src/populateDynamicZone/forms/hubspot-form-ssr.ts @@ -1,5 +1,11 @@ +import basicImagePopulate from "../utilities/basic-image" + export default { populate: { - form: true, + form: { + populate: { + thankYouImage: basicImagePopulate, + }, + }, }, } diff --git a/apps/strapi/types/generated/contentTypes.d.ts b/apps/strapi/types/generated/contentTypes.d.ts index 5916e41e..4bd96dbd 100644 --- a/apps/strapi/types/generated/contentTypes.d.ts +++ b/apps/strapi/types/generated/contentTypes.d.ts @@ -1157,6 +1157,8 @@ export interface ApiHubspotFormHubspotForm extends Struct.CollectionTypeSchema { Schema.Attribute.DefaultTo<300> portalId: Schema.Attribute.String & Schema.Attribute.Required publishedAt: Schema.Attribute.DateTime + thankYouImage: Schema.Attribute.Component<"utilities.basic-image", false> + thankYouMessage: Schema.Attribute.RichText updatedAt: Schema.Attribute.DateTime updatedBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> & Schema.Attribute.Private diff --git a/apps/ui/src/app/[locale]/[[...rest]]/page.tsx b/apps/ui/src/app/[locale]/[[...rest]]/page.tsx index fd7dd494..3a138bd2 100644 --- a/apps/ui/src/app/[locale]/[[...rest]]/page.tsx +++ b/apps/ui/src/app/[locale]/[[...rest]]/page.tsx @@ -3,7 +3,11 @@ import type { Locale } from "next-intl" import { use } from "react" import { StrapiPageView } from "@/components/layouts/StrapiPageView" -import { createFallbackPath, debugStaticParams } from "@/lib/build" +import { + createFallbackPath, + debugStaticParams, + withExportFallback, +} from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" import { getMetadataFromStrapi } from "@/lib/metadata" import { fetchAllPages } from "@/lib/strapi-api/content/server" @@ -49,7 +53,7 @@ export async function generateStaticParams({ rest: ["fallback"], }) - return params.length > 0 ? params : [fallbackPath] + return withExportFallback(params, fallbackPath) } export async function generateMetadata( diff --git a/apps/ui/src/app/[locale]/blog/[slug]/page.tsx b/apps/ui/src/app/[locale]/blog/[slug]/page.tsx index 16269d0e..2783a605 100644 --- a/apps/ui/src/app/[locale]/blog/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/blog/[slug]/page.tsx @@ -2,7 +2,11 @@ import type { Locale } from "next-intl" import { use } from "react" import { StrapiBlogPostView } from "@/components/layouts/StrapiBlogPostView" -import { createFallbackPath, debugStaticParams } from "@/lib/build" +import { + createFallbackPath, + debugStaticParams, + withExportFallback, +} from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" import { getBlogPostMetadata } from "@/lib/metadata" import { fetchAllBlogPosts } from "@/lib/strapi-api/content/server" @@ -36,7 +40,7 @@ export async function generateStaticParams({ slug: "fallback", }) - return params.length > 0 ? params : [fallbackPath] + return withExportFallback(params, fallbackPath) } export async function generateMetadata( diff --git a/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx b/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx index a2f54552..f345a963 100644 --- a/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx @@ -2,7 +2,11 @@ import type { Locale } from "next-intl" import { use } from "react" import { CmsComparisonView } from "@/components/cms-comparison/CmsComparisonView" -import { createFallbackPath, debugStaticParams } from "@/lib/build" +import { + createFallbackPath, + debugStaticParams, + withExportFallback, +} from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" import { getCmsComparisonMetadata } from "@/lib/metadata" import { fetchAllCmsComparisons } from "@/lib/strapi-api/content/server" @@ -38,7 +42,7 @@ export async function generateStaticParams({ slug: "fallback", }) - return params.length > 0 ? params : [fallbackPath] + return withExportFallback(params, fallbackPath) } export async function generateMetadata( diff --git a/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx b/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx index ee7c3085..3344420a 100644 --- a/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx @@ -1,7 +1,11 @@ import type { Locale } from "next-intl" import { CaseStudyView } from "@/components/case-study/CaseStudyView" -import { createFallbackPath, debugStaticParams } from "@/lib/build" +import { + createFallbackPath, + debugStaticParams, + withExportFallback, +} from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" import { getCaseStudyMetadata } from "@/lib/metadata" import { fetchAllCaseStudies } from "@/lib/strapi-api/content/server" @@ -37,7 +41,7 @@ export async function generateStaticParams({ slug: "fallback", }) - return params.length > 0 ? params : [fallbackPath] + return withExportFallback(params, fallbackPath) } interface RouteProps { diff --git a/apps/ui/src/app/api/exit-preview/route.ts b/apps/ui/src/app/api/exit-preview/route.ts index 82e6eee1..7145b4bc 100644 --- a/apps/ui/src/app/api/exit-preview/route.ts +++ b/apps/ui/src/app/api/exit-preview/route.ts @@ -15,11 +15,9 @@ export async function GET(request: Request) { dm.disable() /** - * `dm.disable()` emits the deletion cookie with `sameSite=Lax`, which - * browsers reject in the cross-origin Strapi admin iframe — the same - * shortcoming `/api/preview` works around. Re-set the deletion with - * `sameSite=None` so exiting works from the iframe too (later `.set()` - * calls for the same cookie name override earlier pending ones). + * Re-set the deletion with the same attributes `/api/preview` uses. + * A partitioned cookie is only cleared by a deletion that is also + * `SameSite=None`, `Secure`, and `Partitioned`. */ const cookieStore = await cookies() cookieStore.set({ @@ -30,6 +28,7 @@ export async function GET(request: Request) { path: "/", secure: true, sameSite: "none", + partitioned: true, }) return NextResponse.redirect(resolveRedirectTarget(request)) diff --git a/apps/ui/src/app/api/preview/route.ts b/apps/ui/src/app/api/preview/route.ts index f4bdad6c..ebbb2a40 100644 --- a/apps/ui/src/app/api/preview/route.ts +++ b/apps/ui/src/app/api/preview/route.ts @@ -67,6 +67,9 @@ export async function GET(request: Request) { path: "/", secure: true, sameSite: "none", // Allow cookie in cross-origin iframes + // Without this, browsers reject the cookie inside the Strapi + // admin iframe because that iframe is a different site from the frontend. + partitioned: true, }) // -------------------------------------------------------------------- // Check if the locale in the request is a correct frontend locale diff --git a/apps/ui/src/components/blog/BlogTableOfContents.tsx b/apps/ui/src/components/blog/BlogTableOfContents.tsx index 469a7123..93556b57 100644 --- a/apps/ui/src/components/blog/BlogTableOfContents.tsx +++ b/apps/ui/src/components/blog/BlogTableOfContents.tsx @@ -1,41 +1,109 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { BLOG_HEADER_OFFSET, type TocHeading } from "@/lib/blog-utils" +import type { TocHeading } from "@/lib/blog-utils" import { cn } from "@/lib/styles" +const SITE_HEADER_SELECTOR = '[data-slot="site-header"]' +/** Clearance under the sticky site header. Matches the old top-28 gap below an 80px navbar. */ +const TOC_HEADER_GAP = 32 +const TOC_OFFSET_FALLBACK = 112 +const OFFSET_CSS_VAR = "--blog-header-offset" + +function hashIdFromLocation() { + return decodeURIComponent(window.location.hash.replace(/^#/, "")) +} + +function scrollToHeading(id: string, behavior: ScrollBehavior, offset: number) { + const el = document.getElementById(id) + if (!el) return + + window.scrollTo({ + behavior, + top: el.getBoundingClientRect().top + window.scrollY - offset, + }) +} + export function BlogTableOfContents({ headings, }: { readonly headings: TocHeading[] }) { const [activeId, setActiveId] = useState(null) + const [offset, setOffset] = useState(TOC_OFFSET_FALLBACK) const rafRef = useRef(0) const activeIdRef = useRef(null) + const offsetRef = useRef(TOC_OFFSET_FALLBACK) + const correctedHashRef = useRef(false) const h2Headings = useMemo( () => headings.filter((h) => h.level === 2), [headings] ) + const headingIds = useMemo( + () => new Set(headings.map((heading) => heading.id)), + [headings] + ) + + const updateActive = useCallback(() => { + let current: string | null = null + const threshold = offsetRef.current + + for (const heading of h2Headings) { + const el = document.getElementById(heading.id) + if (el && el.getBoundingClientRect().top <= threshold) { + current = heading.id + } + } + + if (current !== activeIdRef.current) { + activeIdRef.current = current + setActiveId(current) + } + }, [h2Headings]) useEffect(() => { - const updateActive = () => { - let current: string | null = null + const header = document.querySelector(SITE_HEADER_SELECTOR) - for (const h of h2Headings) { - const el = document.getElementById(h.id) - if (el && el.getBoundingClientRect().top <= BLOG_HEADER_OFFSET) { - current = h.id - } + const applyOffset = () => { + const headerHeight = header?.getBoundingClientRect().height ?? 0 + const next = headerHeight + TOC_HEADER_GAP + const changed = offsetRef.current !== next + + offsetRef.current = next + document.documentElement.style.setProperty(OFFSET_CSS_VAR, `${next}px`) + + if (changed) { + setOffset(next) + updateActive() } - if (current !== activeIdRef.current) { - activeIdRef.current = current - setActiveId(current) + if (!correctedHashRef.current) { + correctedHashRef.current = true + const id = hashIdFromLocation() + if (headingIds.has(id)) { + scrollToHeading(id, "auto", next) + } } } + applyOffset() + + let observer: ResizeObserver | undefined + + if (header) { + observer = new ResizeObserver(applyOffset) + observer.observe(header) + } + + return () => { + observer?.disconnect() + document.documentElement.style.removeProperty(OFFSET_CSS_VAR) + } + }, [headingIds, updateActive]) + + useEffect(() => { const onScroll = () => { cancelAnimationFrame(rafRef.current) rafRef.current = requestAnimationFrame(updateActive) @@ -48,33 +116,56 @@ export function BlogTableOfContents({ window.removeEventListener("scroll", onScroll) cancelAnimationFrame(rafRef.current) } - }, [h2Headings]) + }, [updateActive]) + + useEffect(() => { + const onPopState = () => { + const id = hashIdFromLocation() + if (!headingIds.has(id)) return + + requestAnimationFrame(() => { + scrollToHeading(id, "smooth", offsetRef.current) + }) + } + + window.addEventListener("popstate", onPopState) + + return () => window.removeEventListener("popstate", onPopState) + }, [headingIds]) if (h2Headings.length === 0) { return null } return ( -