From 77bcaa2d03f811564bc87dc65036bcde36065ea0 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 23 Apr 2026 21:41:03 +0300 Subject: [PATCH 1/4] Refactor Tags page to use static pagination, older SSR pagination was incorrect --- _TODO.md | 38 --- src/components/Pages/TagPage/index.astro | 262 +++++++++++++++++++ src/lib/tags/__tests__/pagination.spec.ts | 31 +++ src/lib/tags/pagination.ts | 99 +++++++ src/pages/tags/[tag].astro | 302 +--------------------- src/pages/tags/[tag]/[page].astro | 24 ++ 6 files changed, 426 insertions(+), 330 deletions(-) create mode 100644 src/components/Pages/TagPage/index.astro create mode 100644 src/lib/tags/__tests__/pagination.spec.ts create mode 100644 src/lib/tags/pagination.ts create mode 100644 src/pages/tags/[tag]/[page].astro diff --git a/_TODO.md b/_TODO.md index 5c621955..d801f1d9 100644 --- a/_TODO.md +++ b/_TODO.md @@ -320,41 +320,3 @@ So the honest answer is: The bigger problem on this homepage is still total shipped JS and chunk fan-out, not a deep chained graph. If you opened DevTools, I would expect "mostly stacked direct requests, plus a smaller second wave" rather than a long staircase. If you want, I can next turn that into a plain-English takeaway for your _TODO.md, like: "fan-out is the main issue; dependency waterfall is present but shallow." - -### Tags page - -One caution: - -src/pages/tags/[tag].astro is prerendered but also reads ?page=. That is not a reason to keep it dynamic, but it is a sign that query-param pagination there may not be doing what you expect in a prerendered route. - -It's using page for plain old server-side pagination. - -In src/pages/tags/[tag].astro, the route sets ITEMS_PER_PAGE = 12, then reads the query param here: - -src/pages/tags/[tag].astro - -const currentPage = parseInt(Astro.url.searchParams.get('page') || '1') -It uses that value to: - -Compute the slice boundaries: -src/pages/tags/[tag].astro - -const startIndex = (currentPage - 1) * ITEMS_PER_PAGEconst endIndex = startIndex + ITEMS_PER_PAGE -Slice the sorted articles for that tag: -src/pages/tags/[tag].astro - -const paginatedContent = sortedContent.slice(startIndex, endIndex) -Render the pagination UI and link targets: -src/pages/tags/[tag].astro -That block builds: - -Previous / Next -numbered page links -ellipsis when there are many pages -links like /tags/foo?page=2, /tags/foo?page=3, etc. -So the intent is: - -/tags/some-tag means page 1 -/tags/some-tag?page=2 means articles 13-24 -/tags/some-tag?page=3 means the next 12, and so on -One important caveat: this route is also marked prerendered in src/pages/tags/[tag].astro. That means the code is written like SSR pagination, but because the route is static, the page query param may not actually produce distinct server-rendered HTML at runtime. In other words, the code is trying to use ?page= to choose which slice to render, but prerendering makes that suspicious. diff --git a/src/components/Pages/TagPage/index.astro b/src/components/Pages/TagPage/index.astro new file mode 100644 index 00000000..4de1e672 --- /dev/null +++ b/src/components/Pages/TagPage/index.astro @@ -0,0 +1,262 @@ +--- +import type { CollectionEntry } from 'astro:content' +import type { AstroComponentFactory } from 'astro/runtime/server/index.js' +import { Picture } from 'astro:assets' +import BaseLayout from '@layouts/BaseLayout.astro' +import Icon from '@components/Icon/index.astro' +import { + buildTagPagePath, + getSortedTagContent, + getTagPageSlice, + getTagTotalPages, +} from '@lib/tags/pagination' + +export interface Props { + tagEntry: CollectionEntry<'tags'> + content: Array> + currentPage: number + TagContent: AstroComponentFactory +} + +const { tagEntry, content: allTagContent, currentPage, TagContent } = Astro.props as Props +const tag = tagEntry.data.slug +const totalItems = allTagContent.length +const totalPages = getTagTotalPages(totalItems) +const sortedContent = getSortedTagContent(allTagContent) +const paginatedContent = getTagPageSlice(sortedContent, currentPage) +const path = buildTagPagePath(tag, currentPage) +--- + + +
+
+
+
+
+ +
+
+ +
+
+

+ {tagEntry.data.displayName} +

+ + {totalItems} + {totalItems === 1 ? 'article' : 'articles'} + +
+ + { + tagEntry.data.intro && ( + + ) + } + +
+ { + sortedContent.length > 0 && ( + + Latest:{' '} + + + ) + } +
+
+
+
+ +
+ +
+ + { + paginatedContent.length > 0 ? ( + <> +
+

+ Tagged content +

+ {paginatedContent.map(item => { + const href = `/articles/${item.id}` + + return ( + + ) + })} +
+ + {totalPages > 1 && ( + + )} + + ) : ( +
+

No content found for this tag.

+ + Browse other tags + +
+ ) + } +
+
\ No newline at end of file diff --git a/src/lib/tags/__tests__/pagination.spec.ts b/src/lib/tags/__tests__/pagination.spec.ts new file mode 100644 index 00000000..04fc34fb --- /dev/null +++ b/src/lib/tags/__tests__/pagination.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { + ITEMS_PER_PAGE, + buildTagPagePath, + getTagPageSlice, + getTagTotalPages, +} from '../pagination' + +describe('tag pagination helpers', () => { + it('builds static tag page paths without query params', () => { + expect(buildTagPagePath('aws', 1)).toBe('/tags/aws') + expect(buildTagPagePath('aws', 2)).toBe('/tags/aws/2') + expect(buildTagPagePath('aws', 3)).toBe('/tags/aws/3') + }) + + it('slices items by page number', () => { + const items = Array.from({ length: ITEMS_PER_PAGE * 2 + 3 }, (_, index) => index + 1) + + expect(getTagPageSlice(items, 1)).toEqual(items.slice(0, ITEMS_PER_PAGE)) + expect(getTagPageSlice(items, 2)).toEqual(items.slice(ITEMS_PER_PAGE, ITEMS_PER_PAGE * 2)) + expect(getTagPageSlice(items, 3)).toEqual(items.slice(ITEMS_PER_PAGE * 2)) + }) + + it('computes total pages from item count', () => { + expect(getTagTotalPages(0)).toBe(0) + expect(getTagTotalPages(1)).toBe(1) + expect(getTagTotalPages(ITEMS_PER_PAGE)).toBe(1) + expect(getTagTotalPages(ITEMS_PER_PAGE + 1)).toBe(2) + }) +}) \ No newline at end of file diff --git a/src/lib/tags/pagination.ts b/src/lib/tags/pagination.ts new file mode 100644 index 00000000..b34962c7 --- /dev/null +++ b/src/lib/tags/pagination.ts @@ -0,0 +1,99 @@ +import { type CollectionEntry, getCollection, render } from 'astro:content' +import { isDev } from '@lib/config/environmentServer' + +export const ITEMS_PER_PAGE = 12 + +export interface TagPageProps { + tagEntry: CollectionEntry<'tags'> + content: Array> + currentPage: number +} + +const articleHasTag = ( + article: CollectionEntry<'articles'>, + tagEntry: CollectionEntry<'tags'> +): boolean => { + return article.data.tags.some((tagRef: { id: string }) => tagRef.id === tagEntry.id) +} + +export const buildTagPagePath = (tag: string, page: number): string => { + return page <= 1 ? `/tags/${tag}` : `/tags/${tag}/${page}` +} + +export const getSortedTagContent = ( + content: Array> +): Array> => { + return [...content].sort( + (a, b) => new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime() + ) +} + +export const getTagPageSlice = (content: T[], currentPage: number, itemsPerPage = ITEMS_PER_PAGE): T[] => { + const startIndex = (currentPage - 1) * itemsPerPage + return content.slice(startIndex, startIndex + itemsPerPage) +} + +export const getTagTotalPages = (totalItems: number, itemsPerPage = ITEMS_PER_PAGE): number => { + return Math.ceil(totalItems / itemsPerPage) +} + +const getTagPageCollections = async (): Promise; content: Array> }>> => { + const allTags = await getCollection('tags') + const allArticles = await getCollection( + 'articles', + ({ data }) => isDev() || data.isDraft !== true + ) + + return allTags.map(tagEntry => ({ + tagEntry, + content: allArticles.filter(article => articleHasTag(article, tagEntry)), + })) +} + +export const getFirstTagPageStaticPaths = async (): Promise< + Array<{ params: { tag: string }; props: TagPageProps }> +> => { + const collections = await getTagPageCollections() + + return collections.map(({ tagEntry, content }) => ({ + params: { tag: tagEntry.data.slug }, + props: { + tagEntry, + content, + currentPage: 1, + }, + })) +} + +export const getAdditionalTagPageStaticPaths = async (): Promise< + Array<{ params: { tag: string; page: string }; props: TagPageProps }> +> => { + const collections = await getTagPageCollections() + + return collections.flatMap(({ tagEntry, content }) => { + const totalPages = getTagTotalPages(content.length) + if (totalPages <= 1) { + return [] + } + + return Array.from({ length: totalPages - 1 }, (_, index) => { + const currentPage = index + 2 + + return { + params: { + tag: tagEntry.data.slug, + page: String(currentPage), + }, + props: { + tagEntry, + content, + currentPage, + }, + } + }) + }) +} + +export const renderTagContent = async (tagEntry: CollectionEntry<'tags'>) => { + return render(tagEntry) +} \ No newline at end of file diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro index 7d974c12..1d22cda3 100644 --- a/src/pages/tags/[tag].astro +++ b/src/pages/tags/[tag].astro @@ -1,305 +1,23 @@ --- export const prerender = true -import { type CollectionEntry, getCollection, render } from 'astro:content' -import { Picture } from 'astro:assets' -import { isDev } from '@lib/config/environmentServer' -import BaseLayout from '@layouts/BaseLayout.astro' -import Icon from '@components/Icon/index.astro' +import TagPage from '@components/Pages/TagPage/index.astro' +import { + getFirstTagPageStaticPaths, + renderTagContent, + type TagPageProps, +} from '@lib/tags/pagination' export interface Params { tag: string } -export interface Props { - content: Array> -} - export async function getStaticPaths() { - const allTags = await getCollection('tags') - const allArticles = await getCollection( - 'articles', - ({ data }) => isDev() || data.isDraft !== true - ) - - const articleHasTag = ( - article: CollectionEntry<'articles'>, - tagEntry: CollectionEntry<'tags'> - ) => { - return article.data.tags.some((tagRef: { id: string }) => tagRef.id === tagEntry.id) - } - - return allTags.map(tagEntry => { - const tag = tagEntry.data.slug - const filteredContent = allArticles.filter(article => articleHasTag(article, tagEntry)) - - return { - params: { tag }, - props: { content: filteredContent }, - } - }) + return getFirstTagPageStaticPaths() } -const { tag } = Astro.params -const { content: allTagContent } = Astro.props as Props - -const [tagEntry] = await getCollection('tags', ({ data }) => data.slug === tag) -if (!tagEntry) { - throw new Error(`Unknown tag: ${tag}`) -} - -const { Content: TagContent } = await render(tagEntry) - -// Pagination setup -const ITEMS_PER_PAGE = 12 -const currentPage = parseInt(Astro.url.searchParams.get('page') || '1') -const totalItems = allTagContent.length -const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) -const startIndex = (currentPage - 1) * ITEMS_PER_PAGE -const endIndex = startIndex + ITEMS_PER_PAGE - -// Sort by publish date (newest first) and paginate -const sortedContent = allTagContent.sort( - (a, b) => new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime() -) - -const paginatedContent = sortedContent.slice(startIndex, endIndex) +const { tagEntry, content, currentPage } = Astro.props as TagPageProps +const { Content: TagContent } = await renderTagContent(tagEntry) --- - -
-
-
-
-
- -
-
- -
-
-

- {tagEntry.data.displayName} -

- - {totalItems} - {totalItems === 1 ? 'article' : 'articles'} - -
- - { - tagEntry.data.intro && ( - - ) - } - -
- { - sortedContent.length > 0 && ( - - Latest:{' '} - - - ) - } -
-
-
-
- -
- -
- - { - paginatedContent.length > 0 ? ( - <> -
-

- Tagged content -

- {paginatedContent.map(item => { - const href = `/articles/${item.id}` - - return ( - - ) - })} -
- - {/* Pagination */} - {totalPages > 1 && ( - - )} - - ) : ( -
-

No content found for this tag.

- - Browse other tags - -
- ) - } -
-
+ diff --git a/src/pages/tags/[tag]/[page].astro b/src/pages/tags/[tag]/[page].astro new file mode 100644 index 00000000..45d57980 --- /dev/null +++ b/src/pages/tags/[tag]/[page].astro @@ -0,0 +1,24 @@ +--- +export const prerender = true + +import TagPage from '@components/Pages/TagPage/index.astro' +import { + getAdditionalTagPageStaticPaths, + renderTagContent, + type TagPageProps, +} from '@lib/tags/pagination' + +export interface Params { + tag: string + page: string +} + +export async function getStaticPaths() { + return getAdditionalTagPageStaticPaths() +} + +const { tagEntry, content, currentPage } = Astro.props as TagPageProps +const { Content: TagContent } = await renderTagContent(tagEntry) +--- + + \ No newline at end of file From 950f643a8cf34df13392e3a5fa76d5c553b4622b Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 23 Apr 2026 21:48:18 +0300 Subject: [PATCH 2/4] Fix GDPR consent API endpoint URL --- .cache/pages.json | 10 ++- .../scripts/sentry/__tests__/helpers.spec.ts | 2 +- src/components/scripts/sentry/helpers.ts | 9 +- .../scripts/store/__tests__/consent.spec.ts | 85 +++++++++---------- src/components/scripts/store/consent.ts | 78 ++++++++--------- 5 files changed, 100 insertions(+), 84 deletions(-) diff --git a/.cache/pages.json b/.cache/pages.json index 2ab6a539..1be2d7e3 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -103,10 +103,13 @@ "apis-and-gateways", "argo-cd", "aws", + "aws/2", + "aws/3", "azure", "backstage-idp", "build-and-deploy", "cloud-platforms", + "cloud-platforms/2", "crossplane", "docker", "dotnet", @@ -114,17 +117,22 @@ "grafana", "helm", "kubernetes", + "kubernetes/2", + "kubernetes/3", "observability-and-telemetry", "openstack", "platform-engineering", "prometheus", + "prometheus/2", "python", + "python/2", "reliability-and-testing", "ruby", "system-modernization", "systems-and-development", "terraform", - "typescript" + "typescript", + "typescript/2" ] }, "terms" diff --git a/src/components/scripts/sentry/__tests__/helpers.spec.ts b/src/components/scripts/sentry/__tests__/helpers.spec.ts index 179bea70..59d629b6 100644 --- a/src/components/scripts/sentry/__tests__/helpers.spec.ts +++ b/src/components/scripts/sentry/__tests__/helpers.spec.ts @@ -57,7 +57,7 @@ const createContactSubmitHttpErrorEvent = (): Parameters[0] => ({ type: 'error', - request: { url: 'https://www.webstackbuilders.com/_actions/gdpr.consentCreate' }, + request: { url: 'https://www.webstackbuilders.com/_actions/gdpr/consentCreate' }, exception: { values: [ { diff --git a/src/components/scripts/sentry/helpers.ts b/src/components/scripts/sentry/helpers.ts index 72366b08..d38a0c0f 100644 --- a/src/components/scripts/sentry/helpers.ts +++ b/src/components/scripts/sentry/helpers.ts @@ -6,6 +6,13 @@ type BeforeSendHandler = NonNullable const SAFE_BREADCRUMB_CATEGORIES = new Set(['script', 'sentry.event']) +const isConsentActionRequest = (requestUrl: string): boolean => { + return ( + requestUrl.includes('/_actions/gdpr.consentCreate') || + requestUrl.includes('/_actions/gdpr/consentCreate') + ) +} + const isHandledContactSubmitHttpError = (event: Parameters[0]): boolean => { const requestUrl = event.request?.url const exception = event.exception?.values?.[0] @@ -29,7 +36,7 @@ const isHandledConsentRateLimitHttpError = (event: Parameters return ( typeof requestUrl === 'string' && - requestUrl.includes('/_actions/gdpr.consentCreate') && + isConsentActionRequest(requestUrl) && mechanismType === 'auto.http.client.fetch' && typeof errorMessage === 'string' && errorMessage.includes('HTTP Client Error with status code: 429') diff --git a/src/components/scripts/store/__tests__/consent.spec.ts b/src/components/scripts/store/__tests__/consent.spec.ts index 5268e92d..a3ca71bc 100644 --- a/src/components/scripts/store/__tests__/consent.spec.ts +++ b/src/components/scripts/store/__tests__/consent.spec.ts @@ -27,6 +27,16 @@ import { import { $isConsentBannerVisible } from '@components/scripts/store/consentBanner' import * as errorHandlerModule from '@components/scripts/errors/handler' +const consentCreateMock = vi.hoisted(() => vi.fn()) + +vi.mock('astro:actions', () => ({ + actions: { + gdpr: { + consentCreate: consentCreateMock, + }, + }, +})) + // Mock js-cookie vi.mock('js-cookie', () => ({ default: { @@ -81,6 +91,7 @@ describe('Cookie Consent Management', () => { // Clear mocks vi.clearAllMocks() + consentCreateMock.mockReset() // Clear localStorage localStorage.clear() @@ -320,8 +331,7 @@ describe('Consent side effects', () => { }) it('logs consent updates via the GDPR API when preferences change', async () => { - const fetchSpy = vi.fn().mockResolvedValue({ ok: true }) - vi.stubGlobal('fetch', fetchSpy) + consentCreateMock.mockResolvedValue({ data: { success: true, record: { id: 'consent-1' } } }) let consentListener: | ((_state: ConsentState, _oldState?: ConsentState) => Promise | void) @@ -353,16 +363,13 @@ describe('Consent side effects', () => { await consentListener?.(newState, oldState) await vi.waitFor(() => { - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(consentCreateMock).toHaveBeenCalledTimes(1) }) - const firstFetchCall = fetchSpy.mock.calls.at(0) - if (!firstFetchCall) { - throw new TestError('Expected consent logging fetch to be called once') + const firstConsentCall = consentCreateMock.mock.calls.at(0) + if (!firstConsentCall) { + throw new TestError('Expected consent logging action to be called once') } - const [url, options] = firstFetchCall - expect(url).toBe('/_actions/gdpr.consentCreate') - expect(options?.method).toBe('POST') - const payload = JSON.parse(options?.body as string) + const [payload] = firstConsentCall expect(payload).toMatchObject({ DataSubjectId: validDataSubjectId, purposes: ['analytics'], @@ -372,13 +379,12 @@ describe('Consent side effects', () => { await consentListener?.(newState, undefined) await vi.waitFor(() => { - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(consentCreateMock).toHaveBeenCalledTimes(1) }) }) it('regenerates a DataSubjectId before logging when state value is missing or invalid', async () => { - const fetchSpy = vi.fn().mockResolvedValue({ ok: true }) - vi.stubGlobal('fetch', fetchSpy) + consentCreateMock.mockResolvedValue({ data: { success: true, record: { id: 'consent-1' } } }) const regeneratedId = 'regenerated-data-subject-id' vi.mocked(getOrCreateDataSubjectId).mockReturnValue(regeneratedId) @@ -412,25 +418,23 @@ describe('Consent side effects', () => { await consentListener?.(newState, oldState) await vi.waitFor(() => { - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(consentCreateMock).toHaveBeenCalledTimes(1) }) expect(getOrCreateDataSubjectId).toHaveBeenCalledTimes(1) - const firstFetchCall = fetchSpy.mock.calls.at(0) - if (!firstFetchCall) { - throw new TestError('Expected consent logging fetch to be called once') + const firstConsentCall = consentCreateMock.mock.calls.at(0) + if (!firstConsentCall) { + throw new TestError('Expected consent logging action to be called once') } - const [, options] = firstFetchCall - const payload = JSON.parse(options?.body as string) + const [payload] = firstConsentCall expect(payload.DataSubjectId).toBe(regeneratedId) expect($consent.get().DataSubjectId).toBe(regeneratedId) }) it('queues consent logging when offline and retries after reconnecting', async () => { - const fetchSpy = vi.fn().mockResolvedValue({ ok: true }) - vi.stubGlobal('fetch', fetchSpy) + consentCreateMock.mockResolvedValue({ data: { success: true, record: { id: 'consent-1' } } }) const onlineGetter = vi.spyOn(window.navigator, 'onLine', 'get') onlineGetter.mockReturnValue(false) @@ -463,13 +467,13 @@ describe('Consent side effects', () => { await consentListener?.(newState, oldState) - expect(fetchSpy).not.toHaveBeenCalled() + expect(consentCreateMock).not.toHaveBeenCalled() onlineGetter.mockReturnValue(true) window.dispatchEvent(new Event('online')) await vi.waitFor(() => { - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(consentCreateMock).toHaveBeenCalledTimes(1) }) onlineGetter.mockRestore() @@ -478,8 +482,7 @@ describe('Consent side effects', () => { it('coalesces burst consent updates into the latest payload before sending', async () => { vi.useFakeTimers() - const fetchSpy = vi.fn().mockResolvedValue({ ok: true }) - vi.stubGlobal('fetch', fetchSpy) + consentCreateMock.mockResolvedValue({ data: { success: true, record: { id: 'consent-1' } } }) let consentListener: | ((_state: ConsentState, _oldState?: ConsentState) => Promise | void) @@ -524,38 +527,34 @@ describe('Consent side effects', () => { await consentListener?.(state2, state1) await consentListener?.(state3, state2) - expect(fetchSpy).not.toHaveBeenCalled() + expect(consentCreateMock).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(250) await vi.waitFor(() => { - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(consentCreateMock).toHaveBeenCalledTimes(1) }) - const firstFetchCall = fetchSpy.mock.calls.at(0) - if (!firstFetchCall) { - throw new TestError('Expected coalesced consent logging fetch to be called once') + const firstConsentCall = consentCreateMock.mock.calls.at(0) + if (!firstConsentCall) { + throw new TestError('Expected coalesced consent logging action to be called once') } - const [, options] = firstFetchCall - const payload = JSON.parse(options?.body as string) + const [payload] = firstConsentCall expect(payload.purposes).toEqual(['analytics', 'marketing', 'functional']) }) it('retries consent logging after a 429 without reporting a script error', async () => { vi.useFakeTimers() - const fetchSpy = vi - .fn() + consentCreateMock .mockResolvedValueOnce({ - ok: false, - status: 429, - statusText: 'Too Many Requests', - headers: { get: vi.fn(() => '1') }, - json: vi.fn().mockResolvedValue({ error: { message: 'Try again in 1s' } }), + error: { + code: 'TOO_MANY_REQUESTS', + message: 'Try again in 1s', + }, }) - .mockResolvedValueOnce({ ok: true }) - vi.stubGlobal('fetch', fetchSpy) + .mockResolvedValueOnce({ data: { success: true, record: { id: 'consent-1' } } }) const handleScriptErrorSpy = vi.spyOn(errorHandlerModule, 'handleScriptError') @@ -590,7 +589,7 @@ describe('Consent side effects', () => { await vi.advanceTimersByTimeAsync(250) await vi.waitFor(() => { - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(consentCreateMock).toHaveBeenCalledTimes(1) }) expect(handleScriptErrorSpy).not.toHaveBeenCalled() @@ -598,7 +597,7 @@ describe('Consent side effects', () => { await vi.advanceTimersByTimeAsync(1_000) await vi.waitFor(() => { - expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(consentCreateMock).toHaveBeenCalledTimes(2) }) expect(handleScriptErrorSpy).not.toHaveBeenCalled() diff --git a/src/components/scripts/store/consent.ts b/src/components/scripts/store/consent.ts index e502323e..a965d698 100644 --- a/src/components/scripts/store/consent.ts +++ b/src/components/scripts/store/consent.ts @@ -1,6 +1,7 @@ /** * Cookie Consent State Management */ +import { actions } from 'astro:actions' import { computed, onMount } from 'nanostores' import { persistentAtom } from '@nanostores/persistent' import { StoreController } from '@nanostores/lit' @@ -413,8 +414,8 @@ export function initConsentSideEffects(): void { }, delayMs) } - const parseRetryAfterMs = (response: Response, serverMessage?: string): number => { - const retryAfterHeader = response.headers.get('Retry-After') + const parseRetryAfterMs = (response?: Response, serverMessage?: string): number => { + const retryAfterHeader = response?.headers.get('Retry-After') if (retryAfterHeader) { const retryAfterSeconds = Number(retryAfterHeader) if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { @@ -507,45 +508,46 @@ export function initConsentSideEffects(): void { } const sendConsentPayload = async (payload: ConsentLogPayload) => { - const response = await fetch('/_actions/gdpr.consentCreate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) + const { data, error } = await actions.gdpr.consentCreate(payload) - if (!response.ok) { - const responseBody = await response.json().catch(() => null) - const serverError = - responseBody && typeof responseBody === 'object' && 'error' in responseBody - ? (responseBody as { error: { message?: string } }).error - : null - const serverMessage = - serverError?.message ?? - (responseBody && typeof (responseBody as { message?: string }).message === 'string' - ? (responseBody as { message: string }).message - : undefined) - - if (response.status === 429) { - throw new ConsentLogRetryableError( - serverMessage ?? 'Consent logging is temporarily rate limited', - parseRetryAfterMs(response, serverMessage), - { - status: response.status, - statusText: response.statusText, - body: responseBody, - } - ) - } + if (!error && data?.success) { + return + } - throw new ClientScriptError({ - message: serverMessage ?? `Failed to record consent (status ${response.status})`, - cause: { - status: response.status, - statusText: response.statusText, - body: responseBody, - }, - }) + const actionError = error as + | { + code?: string + message?: string + status?: number + statusText?: string + } + | undefined + + const serverMessage = + typeof actionError?.message === 'string' && actionError.message.trim().length > 0 + ? actionError.message + : undefined + + if (actionError?.code === 'TOO_MANY_REQUESTS') { + throw new ConsentLogRetryableError( + serverMessage ?? 'Consent logging is temporarily rate limited', + parseRetryAfterMs(undefined, serverMessage), + { + code: actionError.code, + status: actionError.status, + statusText: actionError.statusText, + } + ) } + + throw new ClientScriptError({ + message: serverMessage ?? 'Failed to record consent', + cause: { + code: actionError?.code, + status: actionError?.status, + statusText: actionError?.statusText, + }, + }) } $consent.subscribe((consentState, oldConsentState) => { From 48a8f39a32ced9cfa06c1430cda89da8dac0b519 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 23 Apr 2026 21:53:38 +0300 Subject: [PATCH 3/4] Fix type error --- src/components/scripts/store/consent.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/components/scripts/store/consent.ts b/src/components/scripts/store/consent.ts index a965d698..3243b51f 100644 --- a/src/components/scripts/store/consent.ts +++ b/src/components/scripts/store/consent.ts @@ -7,6 +7,7 @@ import { persistentAtom } from '@nanostores/persistent' import { StoreController } from '@nanostores/lit' import type { ReactiveControllerHost } from 'lit' import { validate as uuidValidate } from 'uuid' +import type { ConsentPurpose, ConsentRequest, ConsentSource } from '@actions/gdpr/@types' import { getCookie, removeCookie, setCookie } from '@components/scripts/utils/cookies' import { ClientScriptError } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' @@ -377,13 +378,7 @@ export function initConsentSideEffects(): void { scriptName: 'cookieConsent', operation: 'logConsentToAPI', } as const - type ConsentLogPayload = { - DataSubjectId: string - purposes: string[] - source: string - userAgent: string - verified: boolean - } + type ConsentLogPayload = Pick let queuedConsentLogPayload: ConsentLogPayload | null = null let hasConsentLoggingFailure = false let isConsentLogProcessing = false @@ -564,7 +559,7 @@ export function initConsentSideEffects(): void { return } - const purposes: string[] = [] + const purposes: ConsentPurpose[] = [] if (consentState.analytics) purposes.push('analytics') if (consentState.marketing) purposes.push('marketing') if (consentState.functional) purposes.push('functional') @@ -579,7 +574,7 @@ export function initConsentSideEffects(): void { enqueueConsentPayload({ DataSubjectId: dataSubjectId, purposes, - source: 'cookies_modal', + source: 'cookies_modal' as ConsentSource, userAgent, verified: false, }) From c8970b5d1430382effa3ddc40702a2e76580161b Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 23 Apr 2026 21:58:43 +0300 Subject: [PATCH 4/4] Fix another type error --- src/actions/gdpr/@types/index.d.ts | 2 +- src/actions/gdpr/__tests__/constants.spec.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/actions/gdpr/@types/index.d.ts b/src/actions/gdpr/@types/index.d.ts index 01ed6515..bbf79306 100644 --- a/src/actions/gdpr/@types/index.d.ts +++ b/src/actions/gdpr/@types/index.d.ts @@ -58,7 +58,7 @@ export interface ConsentResponse { record: ConsentRecord } -export const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'downloads'] as const +export const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'functional', 'downloads'] as const export type ConsentPurpose = (typeof CONSENT_PURPOSES)[number] diff --git a/src/actions/gdpr/__tests__/constants.spec.ts b/src/actions/gdpr/__tests__/constants.spec.ts index 3d526b9f..02d942e5 100644 --- a/src/actions/gdpr/__tests__/constants.spec.ts +++ b/src/actions/gdpr/__tests__/constants.spec.ts @@ -5,6 +5,7 @@ import { CONSENT_PURPOSES, CONSENT_SOURCES } from '../constants' describe('gdpr constants', () => { it('exposes expected consent purposes', () => { expect(CONSENT_PURPOSES).toContain('contact') + expect(CONSENT_PURPOSES).toContain('functional') expect(CONSENT_PURPOSES).toContain('downloads') })