diff --git a/src/landings/components/bespoke/BespokeLanding.tsx b/src/landings/components/bespoke/BespokeLanding.tsx index a7d46ff887..7b4b1f85da 100644 --- a/src/landings/components/bespoke/BespokeLanding.tsx +++ b/src/landings/components/bespoke/BespokeLanding.tsx @@ -4,6 +4,7 @@ import { LandingHero } from '@/landings/components/shared/LandingHero' import { ArticleGrid } from '@/landings/components/shared/LandingArticleGridWithFilter' import { UtmPreserver } from '@/frame/components/UtmPreserver' import { LandingCarousel } from '@/landings/components/shared/LandingCarousel' +import { useMultiQueryParams } from '@/search/components/hooks/useMultiQueryParams' export const BespokeLanding = () => { const { @@ -16,6 +17,10 @@ export const BespokeLanding = () => { includedCategories, landingType, } = useLandingContext() + const { params, updateParams } = useMultiQueryParams({ + useHistory: true, + excludeFromHistory: ['articles-filter'], + }) return ( @@ -29,6 +34,8 @@ export const BespokeLanding = () => { tocItems={tocItems} includedCategories={includedCategories} landingType={landingType} + params={params} + updateParams={updateParams} /> diff --git a/src/landings/components/discovery/DiscoveryLanding.tsx b/src/landings/components/discovery/DiscoveryLanding.tsx index 6e090e5492..0ec403180c 100644 --- a/src/landings/components/discovery/DiscoveryLanding.tsx +++ b/src/landings/components/discovery/DiscoveryLanding.tsx @@ -4,6 +4,7 @@ import { LandingHero } from '@/landings/components/shared/LandingHero' import { ArticleGrid } from '@/landings/components/shared/LandingArticleGridWithFilter' import { LandingCarousel } from '@/landings/components/shared/LandingCarousel' import { UtmPreserver } from '@/frame/components/UtmPreserver' +import { useMultiQueryParams } from '@/search/components/hooks/useMultiQueryParams' export const DiscoveryLanding = () => { const { @@ -16,6 +17,10 @@ export const DiscoveryLanding = () => { includedCategories, landingType, } = useLandingContext() + const { params, updateParams } = useMultiQueryParams({ + useHistory: true, + excludeFromHistory: ['articles-filter'], + }) return ( @@ -28,6 +33,8 @@ export const DiscoveryLanding = () => { tocItems={tocItems} includedCategories={includedCategories} landingType={landingType} + params={params} + updateParams={updateParams} /> diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.tsx b/src/landings/components/shared/LandingArticleGridWithFilter.tsx index 631a0c8d78..d3e1fcb87e 100644 --- a/src/landings/components/shared/LandingArticleGridWithFilter.tsx +++ b/src/landings/components/shared/LandingArticleGridWithFilter.tsx @@ -7,6 +7,7 @@ import { Link } from '@/frame/components/Link' import { useTranslation } from '@/languages/components/useTranslation' import { ArticleCardItems, ChildTocItem, TocItem } from '@/landings/types' import { LandingType } from '@/landings/context/LandingContext' +import type { QueryParams } from '@/search/components/hooks/useMultiQueryParams' import styles from './LandingArticleGridWithFilter.module.scss' @@ -14,6 +15,8 @@ type ArticleGridProps = { tocItems: TocItem[] includedCategories?: string[] landingType: LandingType + params: QueryParams + updateParams: (updates: Partial, shouldPushHistory?: boolean) => void } const ALL_CATEGORIES = 'all_categories' @@ -69,17 +72,24 @@ const useResponsiveArticlesPerPage = () => { return articlesPerPage } -export const ArticleGrid = ({ tocItems, includedCategories, landingType }: ArticleGridProps) => { +export const ArticleGrid = ({ + tocItems, + includedCategories, + landingType, + params, + updateParams, +}: ArticleGridProps) => { const { t } = useTranslation('product_landing') - const [searchQuery, setSearchQuery] = useState('') - const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORIES) - const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0) - const [currentPage, setCurrentPage] = useState(1) const articlesPerPage = useResponsiveArticlesPerPage() const inputRef = useRef(null) const headingRef = useRef(null) + // Read filter state directly from query params + const searchQuery = params['articles-filter'] || '' + const selectedCategory = params['articles-category'] || ALL_CATEGORIES + const currentPage = parseInt(params['articles-page'] || '1', 10) + // Recursively flatten all articles from tocItems, including both direct children and nested articles const allArticles = useMemo(() => flattenArticles(tocItems), [tocItems]) @@ -99,25 +109,43 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic return allArticles }, [allArticles, includedCategories, landingType]) - // Reset to first page when articlesPerPage changes (screen size changes) - useEffect(() => { - setCurrentPage(1) - }, [articlesPerPage]) - // Extract unique categories for dropdown from filtered articles (so all dropdown options have matching articles) - const categories: string[] = [ - ALL_CATEGORIES, - ...Array.from( - new Set(filteredArticlesByLandingType.flatMap((item) => (item.category || []) as string[])), - ) - .filter((category: string) => { - if (!includedCategories || includedCategories.length === 0) return true - // Case-insensitive comparison for dropdown filtering - const lowerCategory = category.toLowerCase() - return includedCategories.some((included) => included.toLowerCase() === lowerCategory) - }) - .sort((a, b) => a.localeCompare(b)), - ] + const categories: string[] = useMemo( + () => [ + ALL_CATEGORIES, + ...Array.from( + new Set(filteredArticlesByLandingType.flatMap((item) => (item.category || []) as string[])), + ) + .filter((category: string) => { + if (!includedCategories || includedCategories.length === 0) return true + // Case-insensitive comparison for dropdown filtering + const lowerCategory = category.toLowerCase() + return includedCategories.some((included) => included.toLowerCase() === lowerCategory) + }) + .sort((a, b) => a.localeCompare(b)), + ], + [filteredArticlesByLandingType, includedCategories], + ) + + // Calculate the selected category index based on the current query param + const selectedCategoryIndex = useMemo(() => { + const index = categories.indexOf(selectedCategory) + return index !== -1 ? index : 0 + }, [categories, selectedCategory]) + + // Clear invalid category from query params if it doesn't exist in available categories + useEffect(() => { + if (selectedCategory !== ALL_CATEGORIES && selectedCategoryIndex === 0) { + updateParams({ 'articles-category': '' }) + } + }, [selectedCategory, selectedCategoryIndex, updateParams]) + + // Sync the input field value with query params + useEffect(() => { + if (inputRef.current) { + inputRef.current.value = searchQuery + } + }, [searchQuery]) const applyFilters = () => { let results = filteredArticlesByLandingType @@ -154,31 +182,86 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic const paginatedResults = filteredResults.slice(startIndex, startIndex + articlesPerPage) const handleSearch = (query: string) => { - setSearchQuery(query) - setCurrentPage(1) // Reset to first page when searching + // Update query params, clear if empty, and reset to first page + // Don't add to history for search filtering + updateParams({ 'articles-filter': query || '', 'articles-page': '' }, false) } - const handleFilter = (option: string, index: number) => { - setSelectedCategory(option) - setSelectedCategoryIndex(index) - setCurrentPage(1) // Reset to first page when filtering + const handleFilter = (option: string) => { + // Update query params, clear if "all categories", and reset to first page + updateParams( + { + 'articles-category': option === ALL_CATEGORIES ? '' : option, + 'articles-page': '', + }, + true, + ) } + // Track previous page to determine if we should scroll + const prevPageRef = useRef(currentPage) + const hasMountedRef = useRef(false) + const handlePageChange = (e: React.MouseEvent, pageNumber: number) => { e.preventDefault() if (pageNumber >= 1 && pageNumber <= totalPages) { - setCurrentPage(pageNumber) - if (headingRef.current) { - const elementPosition = headingRef.current.getBoundingClientRect().top + window.scrollY - const offsetPosition = elementPosition - 140 // 140px offset from top - window.scrollTo({ - top: offsetPosition, - behavior: 'smooth', - }) - } + // Update page in query params, clear if page 1 + updateParams({ 'articles-page': pageNumber === 1 ? '' : String(pageNumber) }, true) } } + // Scroll to heading on initial mount if query params are present + useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true + + // Check if any VALID article grid query params are present on initial load + // Don't scroll if category is invalid (selectedCategoryIndex === 0 means invalid or "all") + const hasValidCategory = selectedCategory !== ALL_CATEGORIES && selectedCategoryIndex !== 0 + const hasQueryParams = searchQuery || hasValidCategory || currentPage > 1 + + if (hasQueryParams && headingRef.current) { + // Use setTimeout to ensure the component is fully rendered + setTimeout(() => { + if (headingRef.current) { + const elementPosition = headingRef.current.getBoundingClientRect().top + window.scrollY + const offsetPosition = elementPosition - 140 // 140px offset from top + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth', + }) + } + }, 100) + } + } + }, []) // Only run on mount + + // Scroll to heading when page changes via pagination + useEffect(() => { + const pageChanged = currentPage !== prevPageRef.current + const isPaginationClick = pageChanged && prevPageRef.current !== 1 + + // Scroll if page changed via pagination (not from filter/category reset to page 1) + // This includes: going to page 2+, or going back to page 1 from a higher page + const shouldScroll = pageChanged && (currentPage > 1 || isPaginationClick) + + if (shouldScroll && headingRef.current) { + // Delay scroll slightly to let router finish and restore scroll position first + setTimeout(() => { + if (headingRef.current) { + const elementPosition = headingRef.current.getBoundingClientRect().top + window.scrollY + const offsetPosition = elementPosition - 140 // 140px offset from top + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth', + }) + } + }, 150) // Slightly longer than router debounce (100ms) + execution time + } + + prevPageRef.current = currentPage + }, [currentPage]) + return (
{/* Filter and Search Controls */} @@ -204,7 +287,7 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic handleFilter(category, index)} + onSelect={() => handleFilter(category)} > {category === ALL_CATEGORIES ? t('article_grid.all_categories') : category} diff --git a/src/search/components/hooks/useMultiQueryParams.ts b/src/search/components/hooks/useMultiQueryParams.ts index ee8cc9de20..34f2b7eb43 100644 --- a/src/search/components/hooks/useMultiQueryParams.ts +++ b/src/search/components/hooks/useMultiQueryParams.ts @@ -1,22 +1,37 @@ import { useRouter } from 'next/router' -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' export type QueryParams = { 'search-overlay-input': string 'search-overlay-ask-ai': string // "true" or "" debug: string + 'articles-category': string + 'articles-filter': string + 'articles-page': string } const initialKeys: (keyof QueryParams)[] = [ + // Used to persist search state 'search-overlay-input', 'search-overlay-ask-ai', + // Used to debug search result 'debug', + // Used to filter category and search results of Articles on landing pages + 'articles-category', + 'articles-filter', + 'articles-page', ] // When we need to update 2 query params simultaneously, we can use this hook to prevent race conditions -export function useMultiQueryParams() { +export function useMultiQueryParams(options?: { + useHistory?: boolean + excludeFromHistory?: (keyof QueryParams)[] +}) { const router = useRouter() const pushTimeoutRef = useRef | null>(null) + const useHistory = options?.useHistory ?? false + // When using browser history, exclude these params from being updated on back/forward navigation (like search input which causes race conditions) + const excludeFromHistory = options?.excludeFromHistory ?? [] const getInitialParams = (): QueryParams => { const searchParams = @@ -27,6 +42,9 @@ export function useMultiQueryParams() { 'search-overlay-input': searchParams.get('search-overlay-input') || '', 'search-overlay-ask-ai': searchParams.get('search-overlay-ask-ai') || '', debug: searchParams.get('debug') || '', + 'articles-category': searchParams.get('articles-category') || '', + 'articles-filter': searchParams.get('articles-filter') || '', + 'articles-page': searchParams.get('articles-page') || '', } return params } @@ -38,43 +56,87 @@ export function useMultiQueryParams() { setParams(getInitialParams()) }, [router.pathname]) - const updateParams = (updates: Partial) => { - const newParams = { ...params, ...updates } - const [asPathWithoutHash] = router.asPath.split('#') - const [asPathRoot, asPathQuery = ''] = asPathWithoutHash.split('?') - const searchParams = new URLSearchParams(asPathQuery) - for (const key of initialKeys) { - if (key === 'search-overlay-ask-ai') { - if (newParams[key] === 'true') { - searchParams.set(key, 'true') - } else { - searchParams.delete(key) + // Listen to browser back/forward button navigation (only if history is being used) + useEffect(() => { + if (!useHistory) return + + const handleRouteChange = () => { + // When the route changes (e.g., back button), update state from URL + // But preserve excluded params from current state to avoid race conditions + setParams((currentParams) => { + const newParams = getInitialParams() + // Keep excluded params from current state instead of reading from URL + for (const key of excludeFromHistory) { + newParams[key] = currentParams[key] } - } else { - if (newParams[key]) { - searchParams.set(key, newParams[key]) - } else { - searchParams.delete(key) - } - } - } - const paramsString = searchParams.toString() ? `?${searchParams.toString()}` : '' - let newUrl = `${asPathRoot}${paramsString}` - if (asPathRoot !== '/' && router.locale) { - newUrl = `${router.locale}${asPathRoot}${paramsString}` - } - if (!newUrl.startsWith('/')) { - newUrl = `/${newUrl}` + return newParams + }) } - // Debounce the router push so we don't push a new URL for every keystroke - if (pushTimeoutRef.current) clearTimeout(pushTimeoutRef.current) - pushTimeoutRef.current = setTimeout(() => { - router.replace(newUrl, undefined, { shallow: true, locale: router.locale, scroll: false }) - }, 100) + router.events.on('routeChangeComplete', handleRouteChange) + return () => { + router.events.off('routeChangeComplete', handleRouteChange) + } + }, [router.events, useHistory, excludeFromHistory]) - setParams(newParams) - } + const updateParams = useCallback( + (updates: Partial, shouldPushHistory = false) => { + // Use functional state update to avoid depending on params in the closure + setParams((currentParams) => { + const newParams = { ...currentParams, ...updates } + const [asPathWithoutHash] = router.asPath.split('#') + const [asPathRoot, asPathQuery = ''] = asPathWithoutHash.split('?') + const searchParams = new URLSearchParams(asPathQuery) + for (const key of initialKeys) { + if (key === 'search-overlay-ask-ai') { + if (newParams[key] === 'true') { + searchParams.set(key, 'true') + } else { + searchParams.delete(key) + } + } else { + if (newParams[key]) { + searchParams.set(key, newParams[key]) + } else { + searchParams.delete(key) + } + } + } + const paramsString = searchParams.toString() ? `?${searchParams.toString()}` : '' + let newUrl = `${asPathRoot}${paramsString}` + if (asPathRoot !== '/' && router.locale) { + newUrl = `${router.locale}${asPathRoot}${paramsString}` + } + if (!newUrl.startsWith('/')) { + newUrl = `/${newUrl}` + } + + // Debounce the router push so we don't push a new URL for every keystroke + if (pushTimeoutRef.current) clearTimeout(pushTimeoutRef.current) + pushTimeoutRef.current = setTimeout(async () => { + // Always preserve scroll position during router update to prevent jumps + // Component-level scroll logic (like pagination scroll) will handle intentional scrolling + const scrollY = window.scrollY + const scrollX = window.scrollX + + // Use router.push for history entries (category/page changes), router.replace for others (search) + const routerMethod = shouldPushHistory ? router.push : router.replace + await routerMethod(newUrl, undefined, { + shallow: true, + locale: router.locale, + scroll: false, + }) + + // Restore scroll position after router update + // This prevents unintended scrolling; intentional scrolling is handled by components + window.scrollTo(scrollX, scrollY) + }, 100) + + return newParams + }) + }, + [router], + ) return { params, updateParams } } diff --git a/src/shielding/middleware/handle-invalid-query-strings.ts b/src/shielding/middleware/handle-invalid-query-strings.ts index c6d4073ed7..94a8a0d191 100644 --- a/src/shielding/middleware/handle-invalid-query-strings.ts +++ b/src/shielding/middleware/handle-invalid-query-strings.ts @@ -37,6 +37,10 @@ const RECOGNIZED_KEYS_BY_ANY = new Set([ 'search-overlay-ask-ai', // The drop-downs on "Webhook events and payloads" 'actionType', + // Landing page article grid filters + 'articles-category', + 'articles-filter', + 'articles-page', // Legacy domain tracking parameter (no longer processed but still recognized) 'ghdomain', // UTM campaign tracking