From 4a303f601aeb5339dcde16d113bd5417a227c1f5 Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 17:17:01 -0700 Subject: [PATCH 01/10] feat(cow-fi): add Resource CMS service functions Add typed fetch helpers for resources, campaign summaries, and slug lookup without touching the existing Article/Learn pipeline. --- apps/cow-fi/const/resources.ts | 18 ++++ apps/cow-fi/services/cms/helpers.ts | 11 ++- apps/cow-fi/services/cms/index.ts | 137 +++++++++++++++++++++++++++- 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 apps/cow-fi/const/resources.ts diff --git a/apps/cow-fi/const/resources.ts b/apps/cow-fi/const/resources.ts new file mode 100644 index 00000000000..f74e99ffeb9 --- /dev/null +++ b/apps/cow-fi/const/resources.ts @@ -0,0 +1,18 @@ +const CAMPAIGN_LABELS: Record = { + tokens: 'Tokens', +} + +export function getCampaignLabel(campaign: string): string { + if (CAMPAIGN_LABELS[campaign]) { + return CAMPAIGN_LABELS[campaign] + } + + return campaign + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +export function getResourcePath(campaign: string, slug: string): string { + return `/resources/${campaign}/${slug}` +} diff --git a/apps/cow-fi/services/cms/helpers.ts b/apps/cow-fi/services/cms/helpers.ts index 9ed4eb35e64..72eaad94753 100644 --- a/apps/cow-fi/services/cms/helpers.ts +++ b/apps/cow-fi/services/cms/helpers.ts @@ -10,7 +10,7 @@ export const querySerializer = (params: unknown): string => { } // Helper function to get populate configuration for different endpoints -export function getPopulateConfig(endpoint: '/categories' | '/articles' | '/pages'): PopulateConfig { +export function getPopulateConfig(endpoint: '/categories' | '/articles' | '/pages' | '/resources'): PopulateConfig { switch (endpoint) { case '/categories': return { @@ -34,5 +34,14 @@ export function getPopulateConfig(endpoint: '/categories' | '/articles' | '/page }, authorsBio: { fields: ['name'] }, } + case '/resources': + return { + cover: { fields: ['url', 'width', 'height', 'alternativeText'] }, + blocks: '*', + seo: { + fields: ['metaTitle', 'metaDescription'], + populate: { shareImage: { fields: ['url'] } }, + }, + } } } diff --git a/apps/cow-fi/services/cms/index.ts b/apps/cow-fi/services/cms/index.ts index 51652bc4c1f..a98a0128963 100644 --- a/apps/cow-fi/services/cms/index.ts +++ b/apps/cow-fi/services/cms/index.ts @@ -26,6 +26,29 @@ export type ArticleListResponse = { export type SharedRichTextComponent = Schemas['SharedRichTextComponent'] export type Category = Schemas['CategoryListResponseDataItem'] +export type Resource = Schemas['ResourceListResponseDataItem'] + +export type ResourceListResponse = { + data: Resource[] + meta: { + pagination: { + page: number + pageSize: number + pageCount: number + total: number + } + } +} + +export type ResourceSlugParam = { + campaign: string + slug: string +} + +export type CampaignSummary = { + campaign: string + count: number +} const SKIP_CMS_FETCH_DURING_BUILD = process.env.NEXT_PHASE === 'phase-production-build' || process.env.SKIP_COW_FI_CMS_FETCH === 'true' @@ -285,11 +308,123 @@ export async function getPageBySlug(slug: string): Promise { return getBySlugAux(slug, '/pages') } +/** + * Get resources sorted by descending published date. + */ +export async function getResources({ + page = 0, + pageSize = DEFAULT_PAGE_SIZE, + filters = {}, +}: PaginationParam & { filters?: Record } = {}): Promise { + try { + const { data, error, response } = await client.GET('/resources', { + params: { + query: { + 'populate[0]': 'cover', + 'populate[1]': 'blocks', + 'populate[2]': 'seo', + 'pagination[page]': page, + 'pagination[pageSize]': pageSize, + sort: 'publishDate:desc,publishedAt:desc', + filters, + }, + }, + querySerializer, + ...clientAddons, + }) + + if (error) { + console.error(`Error ${response.status} getting resources: ${response.url}. Page ${page}`, error) + throw error + } + + return { data: data.data, meta: data.meta } + } catch (error) { + return handleCmsBuildFailure('getResources', error, { + data: [], + meta: { + pagination: { + page, + pageSize, + pageCount: 0, + total: 0, + }, + }, + }) + } +} + +/** + * Returns all resource slugs grouped by campaign. + */ +export async function getAllResourceSlugs(): Promise { + try { + const { data, error, response } = await client.GET('/resources', { + params: { + query: { + fields: ['slug', 'campaign'], + 'pagination[pageSize]': DEFAULT_PAGE_SIZE, + }, + }, + querySerializer, + ...clientAddons, + }) + + if (error) { + console.error(`Error ${response.status} getting resource slugs: ${response.url}`, error) + throw error + } + + return data.data + .filter((resource: Resource) => resource.attributes?.slug && resource.attributes?.campaign) + .map((resource: Resource) => ({ + campaign: resource.attributes!.campaign!, + slug: resource.attributes!.slug!, + })) + } catch (error) { + return handleCmsBuildFailure('getAllResourceSlugs', error, []) + } +} + +/** + * Returns campaign summaries derived from published resources. + */ +export async function getCampaignSummaries(): Promise { + const slugs = await getAllResourceSlugs() + const counts = new Map() + + for (const { campaign } of slugs) { + counts.set(campaign, (counts.get(campaign) ?? 0) + 1) + } + + return Array.from(counts.entries()) + .map(([campaign, count]) => ({ campaign, count })) + .sort((a, b) => a.campaign.localeCompare(b.campaign)) +} + +/** + * Get resource by slug. + */ +export async function getResourceBySlug(slug: string): Promise { + if (!slug) throw new Error('Resource slug is required') + + try { + return await getBySlugAux(slug, '/resources') + } catch (error) { + console.error(`Error getting resource by slug ${slug}:`, error) + throw error + } +} + async function getBySlugAux(slug: string, endpoint: '/articles'): Promise
async function getBySlugAux(slug: string, endpoint: '/categories'): Promise async function getBySlugAux(slug: string, endpoint: '/pages'): Promise +async function getBySlugAux(slug: string, endpoint: '/resources'): Promise -async function getBySlugAux(slug: string, endpoint: '/categories' | '/articles' | '/pages'): Promise { +async function getBySlugAux( + slug: string, + endpoint: '/categories' | '/articles' | '/pages' | '/resources', +): Promise { if (!slug) throw new Error('Slug is required') // Fail fast - no silent failures per CMS architecture if (!isValidCmsSlug(slug)) return null From af3d295696bb6b707cdb2ed3ba91a82ee3eaaf0d Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 17:20:18 -0700 Subject: [PATCH 02/10] feat(cow-fi): add Resources pages and components Introduce /resources hub, campaign listings, and detail pages for programmatic CMS content. --- .../resources/[campaign]/[slug]/page.tsx | 98 +++++++++++++ .../app/(main)/resources/[campaign]/page.tsx | 49 +++++++ apps/cow-fi/app/(main)/resources/page.tsx | 23 +++ .../components/ResourcePageComponent.tsx | 135 ++++++++++++++++++ .../components/ResourcesCampaignComponent.tsx | 97 +++++++++++++ .../components/ResourcesHubComponent.tsx | 97 +++++++++++++ 6 files changed, 499 insertions(+) create mode 100644 apps/cow-fi/app/(main)/resources/[campaign]/[slug]/page.tsx create mode 100644 apps/cow-fi/app/(main)/resources/[campaign]/page.tsx create mode 100644 apps/cow-fi/app/(main)/resources/page.tsx create mode 100644 apps/cow-fi/components/ResourcePageComponent.tsx create mode 100644 apps/cow-fi/components/ResourcesCampaignComponent.tsx create mode 100644 apps/cow-fi/components/ResourcesHubComponent.tsx diff --git a/apps/cow-fi/app/(main)/resources/[campaign]/[slug]/page.tsx b/apps/cow-fi/app/(main)/resources/[campaign]/[slug]/page.tsx new file mode 100644 index 00000000000..312a0fa27bb --- /dev/null +++ b/apps/cow-fi/app/(main)/resources/[campaign]/[slug]/page.tsx @@ -0,0 +1,98 @@ +import type { ReactNode } from 'react' + +import { notFound, permanentRedirect } from 'next/navigation' + +import { getAllResourceSlugs, getResourceBySlug, SharedRichTextComponent } from '../../../../../services/cms' + +import type { Metadata } from 'next' + +import { ResourcePageComponent } from '@/components/ResourcePageComponent' +import { getPageMetadata } from '@/util/getPageMetadata' +import { stripHtmlTags } from '@/util/stripHTMLTags' + +export const revalidate = 43200 + +const METADATA_DESCRIPTION_MAX_LENGTH = 150 +const METADATA_DESCRIPTION_TRUNCATE_LENGTH = METADATA_DESCRIPTION_MAX_LENGTH - 3 + +function isRichTextComponent(block: unknown): block is SharedRichTextComponent { + return ( + typeof block === 'object' && + block !== null && + 'body' in block && + typeof (block as { body?: unknown }).body === 'string' + ) +} + +type Props = { + params: Promise<{ campaign: string; slug: string }> +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params + + if (!slug) return {} + + try { + const resource = await getResourceBySlug(slug) + if (!resource?.attributes) { + return getPageMetadata({ + title: 'Resource Not Found', + description: 'The requested resource could not be found.', + }) + } + + const attributes = resource.attributes + const { title, blocks, description, cover } = attributes + const coverImageUrl = cover?.data?.attributes?.url + const content = + blocks?.map((block: SharedRichTextComponent) => (isRichTextComponent(block) ? block.body : '')).join(' ') || '' + const plainContent = stripHtmlTags(content) + + return getPageMetadata({ + absoluteTitle: `${title} - CoW DAO`, + description: description + ? stripHtmlTags(description) + : plainContent.length > METADATA_DESCRIPTION_MAX_LENGTH + ? stripHtmlTags(plainContent.substring(0, METADATA_DESCRIPTION_TRUNCATE_LENGTH)) + '...' + : stripHtmlTags(plainContent), + image: coverImageUrl, + }) + } catch (error) { + console.error(`Error generating metadata for resource ${slug}:`, error) + return getPageMetadata({ + title: 'Resource', + description: 'Loading resource...', + }) + } +} + +export async function generateStaticParams(): Promise<{ campaign: string; slug: string }[]> { + try { + return await getAllResourceSlugs() + } catch (error) { + console.error('Error generating resource static params:', error) + return [] + } +} + +export default async function ResourcePage({ params }: Props): Promise { + const { campaign, slug } = await params + + try { + const resource = await getResourceBySlug(slug) + + if (!resource?.attributes) { + return notFound() + } + + if (resource.attributes.campaign !== campaign) { + permanentRedirect(`/resources/${resource.attributes.campaign}/${slug}`) + } + + return + } catch (error) { + console.error(`Error fetching resource ${slug}:`, error) + return notFound() + } +} diff --git a/apps/cow-fi/app/(main)/resources/[campaign]/page.tsx b/apps/cow-fi/app/(main)/resources/[campaign]/page.tsx new file mode 100644 index 00000000000..046553a002f --- /dev/null +++ b/apps/cow-fi/app/(main)/resources/[campaign]/page.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react' + +import { notFound } from 'next/navigation' + +import { getCampaignSummaries, getResources } from '../../../../services/cms' + +import type { Metadata } from 'next' + +import { ResourcesCampaignComponent } from '@/components/ResourcesCampaignComponent' +import { getCampaignLabel } from '@/const/resources' +import { getPageMetadata } from '@/util/getPageMetadata' + +export const revalidate = 43200 + +type Props = { + params: Promise<{ campaign: string }> +} + +export async function generateStaticParams(): Promise<{ campaign: string }[]> { + const campaigns = await getCampaignSummaries() + return campaigns.map(({ campaign }) => ({ campaign })) +} + +export async function generateMetadata({ params }: Props): Promise { + const campaign = (await params).campaign + const label = getCampaignLabel(campaign) + + return getPageMetadata({ + title: label, + description: `Programmatic ${label.toLowerCase()} resources from CoW DAO.`, + }) +} + +export default async function ResourcesCampaignPage({ params }: Props): Promise { + const campaign = (await params).campaign + const resourcesResponse = await getResources({ + filters: { + campaign: { + $eq: campaign, + }, + }, + }) + + if (resourcesResponse.data.length === 0) { + return notFound() + } + + return +} diff --git a/apps/cow-fi/app/(main)/resources/page.tsx b/apps/cow-fi/app/(main)/resources/page.tsx new file mode 100644 index 00000000000..78d75f09985 --- /dev/null +++ b/apps/cow-fi/app/(main)/resources/page.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' + +import { getCampaignSummaries } from '../../../services/cms' + +import type { Metadata } from 'next' + +import { ResourcesHubComponent } from '@/components/ResourcesHubComponent' +import { getPageMetadata } from '@/util/getPageMetadata' + +export const revalidate = 43200 + +export async function generateMetadata(): Promise { + return getPageMetadata({ + title: 'Resources', + description: 'Programmatic reference content published by CoW DAO.', + }) +} + +export default async function ResourcesPage(): Promise { + const campaigns = await getCampaignSummaries() + + return +} diff --git a/apps/cow-fi/components/ResourcePageComponent.tsx b/apps/cow-fi/components/ResourcePageComponent.tsx new file mode 100644 index 00000000000..8c513828a0f --- /dev/null +++ b/apps/cow-fi/components/ResourcePageComponent.tsx @@ -0,0 +1,135 @@ +'use client' + +import type { ImgHTMLAttributes, ReactNode } from 'react' + +import { Media } from '@cowprotocol/ui' + +import { usePathname } from 'next/navigation' +import ReactMarkdown from 'react-markdown' +import styled from 'styled-components/macro' + +import { Resource, SharedRichTextComponent } from '../services/cms' + +import { LazyImage } from '@/components/LazyImage' +import { Link } from '@/components/Link' +import { ShareBlock } from '@/components/ShareBlock' +import { getCampaignLabel } from '@/const/resources' +import { ArticleContent, ArticleMainTitle, ArticleSubtitleWrapper, BodyContent, Breadcrumbs } from '@/styles/styled' +import { formatDate } from '@/util/formatDate' +import { remarkAllowedHtmlImages, sanitizeCmsMarkdown } from '@/util/markdownHtmlImages' + +const SITE_ORIGIN = process.env.NEXT_PUBLIC_SITE_URL || '' + +const Wrapper = styled.div` + display: flex; + flex-flow: column wrap; + justify-content: center; + width: 100%; + margin: 24px auto 0; + gap: 34px; + max-width: 1760px; + + ${Media.upToMedium()} { + margin: 0 auto; + gap: 24px; + } +` + +interface ResourcePageComponentProps { + resource: Resource +} + +export function ResourcePageComponent({ resource }: ResourcePageComponentProps): ReactNode { + const attributes = resource.attributes + const title = attributes?.title + const campaign = attributes?.campaign + const blocks = attributes?.blocks + const publishedAt = attributes?.publishedAt + const publishDate = attributes?.publishDate || null + const publishDateVisible = attributes?.publishDateVisible ?? true + const description = attributes?.description || '' + const content = + blocks?.map((block: SharedRichTextComponent) => (isRichTextComponent(block) ? block.body : '')).join(' ') || '' + const pathname = usePathname() + const fallbackUrl = buildFallbackUrl(pathname) + const shareTitle = title || 'CoW DAO Resource' + + if (!campaign || !attributes?.slug) { + return null + } + + const campaignLabel = getCampaignLabel(campaign) + const dateIso = publishDate || publishedAt || '' + const date = dateIso ? new Date(dateIso) : null + const showDate = Boolean(publishDateVisible && date && !Number.isNaN(date.getTime())) + const formattedDate = showDate && date ? formatDate(date) : null + + return ( + + + + Home + Resources + {campaignLabel} + {title} + + + {title} + + + {description &&
{description}
} + {formattedDate && ( + <> + {description &&
·
} +
+ Published {formattedDate} +
+ + )} +
+ + + {blocks?.map((block) => + isRichTextComponent(block) ? : null, + )} + undefined} /> + +
+
+ ) +} + +function buildFallbackUrl(pathname: string): string { + if (!SITE_ORIGIN) return '' + try { + return new URL(pathname, SITE_ORIGIN).toString() + } catch { + return '' + } +} + +function isRichTextComponent(block: unknown): block is SharedRichTextComponent { + return ( + typeof block === 'object' && + block !== null && + 'body' in block && + typeof (block as { body?: unknown }).body === 'string' + ) +} + +function MarkdownImage({ src, alt, ...props }: ImgHTMLAttributes): ReactNode { + const dataSrc = (props as Record)['data-src'] + const resolvedSrc = typeof dataSrc === 'string' ? dataSrc : src + if (!resolvedSrc) return null + return +} + +function ResourceRichText({ sharedRichText }: { sharedRichText: SharedRichTextComponent }): ReactNode { + const content = sanitizeCmsMarkdown(sharedRichText.body || '') + + return ( + + {content} + + ) +} diff --git a/apps/cow-fi/components/ResourcesCampaignComponent.tsx b/apps/cow-fi/components/ResourcesCampaignComponent.tsx new file mode 100644 index 00000000000..a0cfc0718e3 --- /dev/null +++ b/apps/cow-fi/components/ResourcesCampaignComponent.tsx @@ -0,0 +1,97 @@ +'use client' + +import type { ReactNode } from 'react' + +import { Font, Media, UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +import { Resource } from '../services/cms' + +import { Link } from '@/components/Link' +import { getCampaignLabel, getResourcePath } from '@/const/resources' +import { + Breadcrumbs, + ContainerCard, + ContainerCardInner, + ContainerCardSection, + ContainerCardSectionTop, + ContainerCardSectionTopTitle, + LinkColumn, + LinkItem, +} from '@/styles/styled' + +const Wrapper = styled.div` + display: flex; + flex-flow: column wrap; + justify-content: center; + align-items: center; + width: 100%; + margin: 24px auto 0; + gap: 34px; + max-width: 1760px; + + > h1 { + font-size: 67px; + text-align: center; + + ${Media.upToMedium()} { + font-size: 38px; + } + } +` + +const ResourceDescription = styled.span` + display: block; + margin-top: 8px; + color: var(${UI.COLOR_NEUTRAL_50}); + font-size: ${Font.size.small}; + line-height: 1.4; +` + +interface ResourcesCampaignComponentProps { + campaign: string + resources: Resource[] +} + +export function ResourcesCampaignComponent({ campaign, resources }: ResourcesCampaignComponentProps): ReactNode { + const campaignLabel = getCampaignLabel(campaign) + + return ( + + + Home + Resources + {campaignLabel} + + +

{campaignLabel}

+ + + + + Pages + + + + {resources.map((resource) => { + const attributes = resource.attributes + if (!attributes?.slug) return null + + return ( + + {attributes.title} + {attributes.description ? ( + {attributes.description} + ) : null} + + + ) + })} + + + + +
+ ) +} diff --git a/apps/cow-fi/components/ResourcesHubComponent.tsx b/apps/cow-fi/components/ResourcesHubComponent.tsx new file mode 100644 index 00000000000..6097efb0421 --- /dev/null +++ b/apps/cow-fi/components/ResourcesHubComponent.tsx @@ -0,0 +1,97 @@ +'use client' + +import type { ReactNode } from 'react' + +import { Font, Media, UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +import { CampaignSummary } from '../services/cms' + +import { Link } from '@/components/Link' +import { getCampaignLabel } from '@/const/resources' +import { + Breadcrumbs, + ContainerCard, + ContainerCardInner, + ContainerCardSection, + ContainerCardSectionTop, + ContainerCardSectionTopTitle, + LinkColumn, + LinkItem, +} from '@/styles/styled' + +const Wrapper = styled.div` + display: flex; + flex-flow: column wrap; + justify-content: center; + align-items: center; + width: 100%; + margin: 24px auto 0; + gap: 34px; + max-width: 1760px; + + > h1 { + font-size: 67px; + text-align: center; + + ${Media.upToMedium()} { + font-size: 38px; + } + } + + > p { + max-width: 720px; + text-align: center; + color: var(${UI.COLOR_NEUTRAL_50}); + font-size: 18px; + line-height: 1.5; + } +` + +const CampaignCount = styled.span` + color: var(${UI.COLOR_NEUTRAL_50}); + font-size: ${Font.size.small}; +` + +interface ResourcesHubComponentProps { + campaigns: CampaignSummary[] +} + +export function ResourcesHubComponent({ campaigns }: ResourcesHubComponentProps): ReactNode { + return ( + + + Home + Resources + + +

Resources

+

Programmatic reference content published by CoW DAO, grouped by campaign.

+ + + + + Campaigns + + + {campaigns.length === 0 ? ( +

No resources have been published yet.

+ ) : ( + + {campaigns.map(({ campaign, count }) => ( + + {getCampaignLabel(campaign)} + + {count} {count === 1 ? 'page' : 'pages'} → + + + ))} + + )} +
+
+
+
+ ) +} From 0de3be9ed14c3ef13a3d2f0ad311ccea791baf08 Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 17:21:36 -0700 Subject: [PATCH 03/10] feat(ui): add Resources link to global footer Expose the new /resources hub from the Help section without adding main nav. --- libs/ui/src/containers/Footer/footer.constants.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libs/ui/src/containers/Footer/footer.constants.ts b/libs/ui/src/containers/Footer/footer.constants.ts index 4b62d0c09a4..b99efa61c68 100644 --- a/libs/ui/src/containers/Footer/footer.constants.ts +++ b/libs/ui/src/containers/Footer/footer.constants.ts @@ -129,6 +129,12 @@ const FOOTER_NAV_GROUP_HELP = { external: true, utmContent: 'footer-help-knowledge-base', }, + { + label: 'Resources', + href: 'https://cow.fi/resources', + external: true, + utmContent: 'footer-help-resources', + }, { label: 'Report Scams', href: 'https://cow.fi/report-scam', From 25afcb5d93ad548ac7b595999a21a80318c0c7e7 Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 17:23:02 -0700 Subject: [PATCH 04/10] feat(cow-fi): extend cache, revalidation, and sitemap for Resources Keep programmatic pages fresh and discoverable alongside existing Learn routes. --- apps/cow-fi/app/api/revalidate/route.ts | 5 ++ apps/cow-fi/middleware.ts | 4 +- apps/cow-fi/next-sitemap.config.js | 70 +++++++++++++++++++++++++ apps/cow-fi/next.config.ts | 4 ++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/apps/cow-fi/app/api/revalidate/route.ts b/apps/cow-fi/app/api/revalidate/route.ts index 7796ab75987..089ac3d1b35 100644 --- a/apps/cow-fi/app/api/revalidate/route.ts +++ b/apps/cow-fi/app/api/revalidate/route.ts @@ -51,6 +51,11 @@ export async function POST(request: NextRequest): Promise { // Revalidate the dynamic article route (this updates the manifest) revalidatePath('/learn/[article]') + // Revalidate resources pages + revalidatePath('/resources') + revalidatePath('/resources/[campaign]') + revalidatePath('/resources/[campaign]/[slug]') + // If a specific path was provided, revalidate it to update the route manifest if (path) revalidatePath(path) diff --git a/apps/cow-fi/middleware.ts b/apps/cow-fi/middleware.ts index 4dfdecf6d0e..201e447b94d 100644 --- a/apps/cow-fi/middleware.ts +++ b/apps/cow-fi/middleware.ts @@ -39,8 +39,8 @@ export function middleware(request: NextRequest): NextResponse { return response } - // Only process /learn routes for tracking param removal - if (!pathname.startsWith('/learn')) { + // Process /learn and /resources routes for tracking param removal + if (!pathname.startsWith('/learn') && !pathname.startsWith('/resources')) { return response } diff --git a/apps/cow-fi/next-sitemap.config.js b/apps/cow-fi/next-sitemap.config.js index c6cf892da88..6000b234f9a 100644 --- a/apps/cow-fi/next-sitemap.config.js +++ b/apps/cow-fi/next-sitemap.config.js @@ -33,6 +33,29 @@ module.exports = { } } + // Handle /resources/* pages with lastmod from CMS + if (url.startsWith('/resources/') && url.split('/').length >= 4) { + try { + console.log(`Transforming resource page: ${url}`) + const resources = await getAllResourceSlugsWithDatesCached() + const resource = resources.find(({ path }) => path === url) + + if (resource) { + console.log(`Found matching resource for ${url}`) + return { + loc: url, + changefreq: config.changefreq, + priority: config.priority, + lastmod: resource.updatedAt, + } + } else { + console.log(`No matching resource found for ${url}`) + } + } catch (error) { + console.error(`Error processing ${url}:`, error) + } + } + console.log(`Applying default transformation for: ${url}`) return { loc: url, @@ -62,6 +85,9 @@ function cacheAsyncFunction(fn) { /** @type {typeof getAllArticleSlugsWithDates} */ const getAllArticleSlugsWithDatesCached = cacheAsyncFunction(getAllArticleSlugsWithDates) +/** @type {typeof getAllResourceSlugsWithDates} */ +const getAllResourceSlugsWithDatesCached = cacheAsyncFunction(getAllResourceSlugsWithDates) + /** * Function to fetch all article slugs with lastModified dates from the CMS API * Implements pagination to fetch all pages of articles @@ -105,3 +131,47 @@ async function getAllArticleSlugsWithDates() { updatedAt: article.attributes.updatedAt, })) } + +/** + * Function to fetch all resource slugs with lastModified dates from the CMS API + */ +async function getAllResourceSlugsWithDates() { + const cmsBaseUrl = process.env.NEXT_PUBLIC_CMS_BASE_URL || 'https://cms.cow.fi/api' + const cmsApiUrl = `${cmsBaseUrl}/resources` + let allResources = [] + let page = 1 + let hasMorePages = true + + while (hasMorePages) { + try { + const url = `${cmsApiUrl}?pagination[page]=${page}&pagination[pageSize]=100&fields[0]=slug&fields[1]=campaign&fields[2]=updatedAt` + console.log(`Fetching resources from: ${url}`) + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + const resources = data.data + allResources = allResources.concat(resources) + + console.log(`Fetched ${resources.length} resources from page ${page}`) + + hasMorePages = data.meta.pagination.page < data.meta.pagination.pageCount + page++ + } catch (error) { + console.error('Error fetching resources for sitemap:', error) + hasMorePages = false + } + } + + console.log(`Total resources fetched: ${allResources.length}`) + + return allResources + .filter((resource) => resource.attributes?.slug && resource.attributes?.campaign) + .map((resource) => ({ + path: `/resources/${resource.attributes.campaign}/${resource.attributes.slug}`, + updatedAt: resource.attributes.updatedAt, + })) +} diff --git a/apps/cow-fi/next.config.ts b/apps/cow-fi/next.config.ts index cf952da2389..a3fce40c2f6 100644 --- a/apps/cow-fi/next.config.ts +++ b/apps/cow-fi/next.config.ts @@ -148,6 +148,10 @@ const nextConfig: WithNxOptions & NextConfig = { source: '/learn/:path*', headers: [DEFAULT_CACHE_CONTROL_HEADER], }, + { + source: '/resources/:path*', + headers: [DEFAULT_CACHE_CONTROL_HEADER], + }, // Cache all other pages for 1 hour { source: '/:path*', From 23c98259dd3cd8a199fc378e5bccb84c16a50a7c Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 17:23:36 -0700 Subject: [PATCH 05/10] chore(cow-fi): link local @cowprotocol/cms for Resource endpoints Point cow-fi at the locally built CMS client package until the published @cowprotocol/cms release with /resources is available. --- apps/cow-fi/package.json | 2 +- libs/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cow-fi/package.json b/apps/cow-fi/package.json index b3246c82e49..08c850474ac 100644 --- a/apps/cow-fi/package.json +++ b/apps/cow-fi/package.json @@ -26,7 +26,7 @@ "@apollo/client": "3.8.8", "@cowprotocol/analytics": "workspace:*", "@cowprotocol/assets": "workspace:*", - "@cowprotocol/cms": "https://registry.npmjs.org/@cowprotocol/cms/-/cms-0.11.0.tgz", + "@cowprotocol/cms": "file:../../../cowswap-cms/lib", "@cowprotocol/common-const": "workspace:*", "@cowprotocol/common-hooks": "workspace:*", "@cowprotocol/common-utils": "workspace:*", diff --git a/libs/core/package.json b/libs/core/package.json index 57f613c92f8..ad55e822449 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@cowprotocol/cow-sdk": "9.2.2", - "@cowprotocol/cms": "https://registry.npmjs.org/@cowprotocol/cms/-/cms-0.11.0.tgz", + "@cowprotocol/cms": "file:../../../cowswap-cms/lib", "@cowprotocol/common-const": "workspace:*", "@cowprotocol/common-utils": "workspace:*", "@safe-global/api-kit": "4.2.0", From 3ff57729b8a8c8e434b9be79fefc0d5078f3c914 Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 17:54:39 -0700 Subject: [PATCH 06/10] fix(cow-fi): avoid invalid Font.size usage on Resources pages Use a concrete font-size so campaign hub and listing pages render. --- apps/cow-fi/components/ResourcesCampaignComponent.tsx | 4 ++-- apps/cow-fi/components/ResourcesHubComponent.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cow-fi/components/ResourcesCampaignComponent.tsx b/apps/cow-fi/components/ResourcesCampaignComponent.tsx index a0cfc0718e3..63e56e735c0 100644 --- a/apps/cow-fi/components/ResourcesCampaignComponent.tsx +++ b/apps/cow-fi/components/ResourcesCampaignComponent.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' -import { Font, Media, UI } from '@cowprotocol/ui' +import { Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' @@ -45,7 +45,7 @@ const ResourceDescription = styled.span` display: block; margin-top: 8px; color: var(${UI.COLOR_NEUTRAL_50}); - font-size: ${Font.size.small}; + font-size: 14px; line-height: 1.4; ` diff --git a/apps/cow-fi/components/ResourcesHubComponent.tsx b/apps/cow-fi/components/ResourcesHubComponent.tsx index 6097efb0421..13ca2dd3f35 100644 --- a/apps/cow-fi/components/ResourcesHubComponent.tsx +++ b/apps/cow-fi/components/ResourcesHubComponent.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' -import { Font, Media, UI } from '@cowprotocol/ui' +import { Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' @@ -51,7 +51,7 @@ const Wrapper = styled.div` const CampaignCount = styled.span` color: var(${UI.COLOR_NEUTRAL_50}); - font-size: ${Font.size.small}; + font-size: 14px; ` interface ResourcesHubComponentProps { From 81754ee6c705e83c010b3d55fb5fd5ab0db1beeb Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 18:34:41 -0700 Subject: [PATCH 07/10] fix(cow-fi): stop BigInt crash on Resources pages Avoid BigInt exponentiation that Webpack turns into Math.pow, and instantiate the CMS client locally so content pages do not import the trading/wallet core barrel. --- apps/cow-fi/components/CmsImage/index.tsx | 4 ++-- apps/cow-fi/services/cms/config.ts | 3 +++ apps/cow-fi/services/cms/index.ts | 10 ++++++---- libs/common-utils/src/maxAmountSpend.ts | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/cow-fi/components/CmsImage/index.tsx b/apps/cow-fi/components/CmsImage/index.tsx index 53452f49243..fbfe16b93c6 100644 --- a/apps/cow-fi/components/CmsImage/index.tsx +++ b/apps/cow-fi/components/CmsImage/index.tsx @@ -1,7 +1,7 @@ -import { CMS_BASE_URL } from '@cowprotocol/core' - import Image, { ImageProps } from 'next/image' +import { CMS_BASE_URL } from '../../services/cms/config' + const CMS_BASE_URL_ROOT = CMS_BASE_URL.replace('/api', '') // TODO: fix this, base url should not have /api // TODO: Add proper return type annotation diff --git a/apps/cow-fi/services/cms/config.ts b/apps/cow-fi/services/cms/config.ts index 0b79ffef3e4..98f104b7e6f 100644 --- a/apps/cow-fi/services/cms/config.ts +++ b/apps/cow-fi/services/cms/config.ts @@ -1,6 +1,9 @@ export const DEFAULT_PAGE_SIZE = 100 export const CMS_CACHE_TIME = 60 * 60 // 60 minutes +export const CMS_BASE_URL = + process.env.NEXT_PUBLIC_CMS_BASE_URL || process.env.REACT_APP_CMS_BASE_URL || 'https://cms.cow.fi/api' + export const clientAddons = { fetch: (request: unknown) => fetch(request as Request, { diff --git a/apps/cow-fi/services/cms/index.ts b/apps/cow-fi/services/cms/index.ts index a98a0128963..8a24727ad3c 100644 --- a/apps/cow-fi/services/cms/index.ts +++ b/apps/cow-fi/services/cms/index.ts @@ -1,12 +1,11 @@ -import { components } from '@cowprotocol/cms' -import { getCmsClient } from '@cowprotocol/core' +import { CmsClient, components } from '@cowprotocol/cms' import { PaginationParam } from 'types' import { isValidCmsSlug, normalizeSearchArticlesInput } from 'util/cmsValidation' import { toQueryParams } from 'util/queryParams' -import { DEFAULT_PAGE_SIZE, clientAddons } from './config' +import { CMS_BASE_URL, DEFAULT_PAGE_SIZE, clientAddons } from './config' import { querySerializer, getPopulateConfig } from './helpers' type Schemas = components['schemas'] @@ -64,8 +63,11 @@ function handleCmsBuildFailure(operation: string, error: unknown, fallback: T /** * Open API Fetch client. See docs for usage https://openapi-ts.pages.dev/openapi-fetch/ + * Instantiated here (not via @cowprotocol/core) so content pages don't pull trading/wallet code. */ -export const client = getCmsClient() +export const client = CmsClient({ + url: CMS_BASE_URL, +}) /** * Returns all article slugs. diff --git a/libs/common-utils/src/maxAmountSpend.ts b/libs/common-utils/src/maxAmountSpend.ts index fcc93bebf7a..7cdff6ba6fe 100644 --- a/libs/common-utils/src/maxAmountSpend.ts +++ b/libs/common-utils/src/maxAmountSpend.ts @@ -2,7 +2,8 @@ import { Currency, CurrencyAmount } from '@cowprotocol/currency' import { getIsNativeToken } from './getIsNativeToken' -const MIN_NATIVE_CURRENCY_FOR_GAS: bigint = 10n ** 16n // .01 ETH +// Use a literal — `10n ** 16n` is sometimes transpiled to Math.pow, which rejects BigInt +const MIN_NATIVE_CURRENCY_FOR_GAS = 10_000_000_000_000_000n // .01 ETH /** * Given some token amount, return the max that can be spent of it From 2813596b989106d767729b0b021d06eaff8f8716 Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 18:38:37 -0700 Subject: [PATCH 08/10] fix(cow-fi): let campaign mismatch redirects escape try/catch permanentRedirect throws a Next control-flow error; catching it turned wrong-campaign URLs into soft failures instead of redirects. --- .../resources/[campaign]/[slug]/page.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/cow-fi/app/(main)/resources/[campaign]/[slug]/page.tsx b/apps/cow-fi/app/(main)/resources/[campaign]/[slug]/page.tsx index 312a0fa27bb..32ffecc8a48 100644 --- a/apps/cow-fi/app/(main)/resources/[campaign]/[slug]/page.tsx +++ b/apps/cow-fi/app/(main)/resources/[campaign]/[slug]/page.tsx @@ -79,20 +79,22 @@ export async function generateStaticParams(): Promise<{ campaign: string; slug: export default async function ResourcePage({ params }: Props): Promise { const { campaign, slug } = await params + let resource try { - const resource = await getResourceBySlug(slug) - - if (!resource?.attributes) { - return notFound() - } - - if (resource.attributes.campaign !== campaign) { - permanentRedirect(`/resources/${resource.attributes.campaign}/${slug}`) - } - - return + resource = await getResourceBySlug(slug) } catch (error) { console.error(`Error fetching resource ${slug}:`, error) return notFound() } + + if (!resource?.attributes) { + return notFound() + } + + // Keep outside try/catch — permanentRedirect throws a control-flow error Next must handle + if (resource.attributes.campaign !== campaign) { + permanentRedirect(`/resources/${resource.attributes.campaign}/${slug}`) + } + + return } From 4984fa59b2d1ee9658861fc68ef187fe59e85421 Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 18:45:41 -0700 Subject: [PATCH 09/10] fix(cow-fi): harden BigInt gas constant against Webpack transpile Use BigInt('...') instead of a BigInt literal, and require browsers that support bigint so maxAmountSpend no longer crashes Resources pages. --- apps/cow-fi/package.json | 2 +- libs/common-utils/src/maxAmountSpend.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cow-fi/package.json b/apps/cow-fi/package.json index 08c850474ac..44685aea7dd 100644 --- a/apps/cow-fi/package.json +++ b/apps/cow-fi/package.json @@ -12,7 +12,7 @@ }, "browserslist": { "production": [ - ">0.2%", + "supports bigint", "not dead", "not op_mini all" ], diff --git a/libs/common-utils/src/maxAmountSpend.ts b/libs/common-utils/src/maxAmountSpend.ts index 7cdff6ba6fe..15011cb6cec 100644 --- a/libs/common-utils/src/maxAmountSpend.ts +++ b/libs/common-utils/src/maxAmountSpend.ts @@ -2,8 +2,8 @@ import { Currency, CurrencyAmount } from '@cowprotocol/currency' import { getIsNativeToken } from './getIsNativeToken' -// Use a literal — `10n ** 16n` is sometimes transpiled to Math.pow, which rejects BigInt -const MIN_NATIVE_CURRENCY_FOR_GAS = 10_000_000_000_000_000n // .01 ETH +// Avoid BigInt literals / ** — Webpack/Babel can rewrite them to Math.pow/Number and crash +const MIN_NATIVE_CURRENCY_FOR_GAS = BigInt('10000000000000000') // 0.01 ETH /** * Given some token amount, return the max that can be spent of it @@ -22,7 +22,7 @@ export function maxAmountSpend( currencyAmount.quotient - MIN_NATIVE_CURRENCY_FOR_GAS, ) } else { - return CurrencyAmount.fromRawAmount(currencyAmount.currency, 0n) + return CurrencyAmount.fromRawAmount(currencyAmount.currency, BigInt(0)) } } return currencyAmount From 4f15eb35ec73853bc331775f51b387e144e6077c Mon Sep 17 00:00:00 2001 From: Justin Dunham Date: Sun, 12 Jul 2026 18:50:30 -0700 Subject: [PATCH 10/10] chore(cow-fi): keep @cowprotocol/cms on published 0.11.0 until CMS ships Local file: linking was only for development. Bump after cowprotocol/cms Resource collection is published. --- apps/cow-fi/package.json | 2 +- libs/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cow-fi/package.json b/apps/cow-fi/package.json index 44685aea7dd..67f18e394af 100644 --- a/apps/cow-fi/package.json +++ b/apps/cow-fi/package.json @@ -26,7 +26,7 @@ "@apollo/client": "3.8.8", "@cowprotocol/analytics": "workspace:*", "@cowprotocol/assets": "workspace:*", - "@cowprotocol/cms": "file:../../../cowswap-cms/lib", + "@cowprotocol/cms": "https://registry.npmjs.org/@cowprotocol/cms/-/cms-0.11.0.tgz", "@cowprotocol/common-const": "workspace:*", "@cowprotocol/common-hooks": "workspace:*", "@cowprotocol/common-utils": "workspace:*", diff --git a/libs/core/package.json b/libs/core/package.json index ad55e822449..57f613c92f8 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@cowprotocol/cow-sdk": "9.2.2", - "@cowprotocol/cms": "file:../../../cowswap-cms/lib", + "@cowprotocol/cms": "https://registry.npmjs.org/@cowprotocol/cms/-/cms-0.11.0.tgz", "@cowprotocol/common-const": "workspace:*", "@cowprotocol/common-utils": "workspace:*", "@safe-global/api-kit": "4.2.0",