@@ -32,10 +32,10 @@
|
||||
- name: Claude Opus 4.5
|
||||
dotcom: true
|
||||
vscode: true
|
||||
vs: false
|
||||
eclipse: false
|
||||
xcode: false
|
||||
jetbrains: false
|
||||
vs: true
|
||||
eclipse: true
|
||||
xcode: true
|
||||
jetbrains: true
|
||||
|
||||
- name: Claude Sonnet 4
|
||||
dotcom: true
|
||||
|
||||
80
package-lock.json
generated
80
package-lock.json
generated
@@ -68,7 +68,7 @@
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"mdast-util-to-markdown": "2.1.2",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"next": "^16.0.1",
|
||||
"next": "^16.0.7",
|
||||
"ora": "^9.0.0",
|
||||
"parse5": "7.1.2",
|
||||
"quick-lru": "7.0.1",
|
||||
@@ -2518,15 +2518,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.1.tgz",
|
||||
"integrity": "sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
|
||||
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.1.tgz",
|
||||
"integrity": "sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
|
||||
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2540,9 +2540,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.1.tgz",
|
||||
"integrity": "sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
|
||||
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2556,9 +2556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.1.tgz",
|
||||
"integrity": "sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2572,9 +2572,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.1.tgz",
|
||||
"integrity": "sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2588,9 +2588,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.1.tgz",
|
||||
"integrity": "sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2604,9 +2604,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.1.tgz",
|
||||
"integrity": "sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2620,9 +2620,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.1.tgz",
|
||||
"integrity": "sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2636,9 +2636,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.1.tgz",
|
||||
"integrity": "sha512-XeEUJsE4JYtfrXe/LaJn3z1pD19fK0Q6Er8Qoufi+HqvdO4LEPyCxLUt4rxA+4RfYo6S9gMlmzCMU2F+AatFqQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12111,12 +12111,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.1.tgz",
|
||||
"integrity": "sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
|
||||
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.1",
|
||||
"@next/env": "16.0.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -12129,14 +12129,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.0.1",
|
||||
"@next/swc-darwin-x64": "16.0.1",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.1",
|
||||
"@next/swc-linux-arm64-musl": "16.0.1",
|
||||
"@next/swc-linux-x64-gnu": "16.0.1",
|
||||
"@next/swc-linux-x64-musl": "16.0.1",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.1",
|
||||
"@next/swc-win32-x64-msvc": "16.0.1",
|
||||
"@next/swc-darwin-arm64": "16.0.7",
|
||||
"@next/swc-darwin-x64": "16.0.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.7",
|
||||
"@next/swc-linux-arm64-musl": "16.0.7",
|
||||
"@next/swc-linux-x64-gnu": "16.0.7",
|
||||
"@next/swc-linux-x64-musl": "16.0.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.7",
|
||||
"@next/swc-win32-x64-msvc": "16.0.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"mdast-util-to-markdown": "2.1.2",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"next": "^16.0.1",
|
||||
"next": "^16.0.7",
|
||||
"ora": "^9.0.0",
|
||||
"parse5": "7.1.2",
|
||||
"quick-lru": "7.0.1",
|
||||
|
||||
@@ -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 (
|
||||
<DefaultLayout>
|
||||
@@ -29,6 +34,8 @@ export const BespokeLanding = () => {
|
||||
tocItems={tocItems}
|
||||
includedCategories={includedCategories}
|
||||
landingType={landingType}
|
||||
params={params}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<DefaultLayout>
|
||||
@@ -28,6 +33,8 @@ export const DiscoveryLanding = () => {
|
||||
tocItems={tocItems}
|
||||
includedCategories={includedCategories}
|
||||
landingType={landingType}
|
||||
params={params}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<QueryParams>, 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<HTMLInputElement>(null)
|
||||
const headingRef = useRef<HTMLHeadingElement>(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,13 +109,9 @@ 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[] = [
|
||||
const categories: string[] = useMemo(
|
||||
() => [
|
||||
ALL_CATEGORIES,
|
||||
...Array.from(
|
||||
new Set(filteredArticlesByLandingType.flatMap((item) => (item.category || []) as string[])),
|
||||
@@ -117,7 +123,29 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic
|
||||
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,20 +182,47 @@ 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)
|
||||
// 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
|
||||
@@ -176,8 +231,36 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic
|
||||
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 (
|
||||
<div data-testid="article-grid-container">
|
||||
@@ -204,7 +287,7 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic
|
||||
<ActionList.Item
|
||||
key={index}
|
||||
selected={index === selectedCategoryIndex}
|
||||
onSelect={() => handleFilter(category, index)}
|
||||
onSelect={() => handleFilter(category)}
|
||||
>
|
||||
{category === ALL_CATEGORIES ? t('article_grid.all_categories') : category}
|
||||
</ActionList.Item>
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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,8 +56,34 @@ export function useMultiQueryParams() {
|
||||
setParams(getInitialParams())
|
||||
}, [router.pathname])
|
||||
|
||||
const updateParams = (updates: Partial<QueryParams>) => {
|
||||
const newParams = { ...params, ...updates }
|
||||
// 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]
|
||||
}
|
||||
return newParams
|
||||
})
|
||||
}
|
||||
|
||||
router.events.on('routeChangeComplete', handleRouteChange)
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange)
|
||||
}
|
||||
}, [router.events, useHistory, excludeFromHistory])
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Partial<QueryParams>, 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)
|
||||
@@ -69,12 +113,30 @@ export function useMultiQueryParams() {
|
||||
|
||||
// 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 })
|
||||
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)
|
||||
|
||||
setParams(newParams)
|
||||
}
|
||||
return newParams
|
||||
})
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
return { params, updateParams }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user