diff --git a/.envrc.json b/.envrc.json index b992f4b6c..dd64df4e5 100644 --- a/.envrc.json +++ b/.envrc.json @@ -24,6 +24,14 @@ "example": "http://localhost:8000/static/sdk/open-forms-sdk.js", "developmentDefault": "http://localhost:8000/static/sdk/open-forms-sdk.js" }, + { + "name": "OPEN_FORMS_MOCK", + "description": "Whether to use a mock implementation of the Open Forms API. This can be used for development and testing without needing to set up an actual Open Forms instance.", + "required": false, + "valueType": "string", + "examples": ["true", "false"], + "developmentDefault": "false" + }, { "name": "OVERIGE_OBJECTEN_API_PORT", "description": "Port number for the Overige Objecten API. This is used to access the Overige Objecten API from the frontend.", diff --git a/ENVIRONMENT_VARIABLES.md b/ENVIRONMENT_VARIABLES.md index b4db0e54f..eaafa677d 100644 --- a/ENVIRONMENT_VARIABLES.md +++ b/ENVIRONMENT_VARIABLES.md @@ -39,6 +39,7 @@ | `OPEN_FORMS_API_TOKEN` | base64 | | ✅ | Open Forms API token, used to access access forms from the website and the Strapi Dashboard. See: https://open-forms.readthedocs.io/en/stable/configuration/general/cms_integration.html | `pIUa5y9fIImYq2Nf92AUEw==` | `pIUa5y9fIImYq2Nf92AUEw==` | | `OPEN_FORMS_API_URL` | url | | | URL for the Open Forms v2 API. See: https://open-forms.readthedocs.io/en/stable/developers/embedding.html | `` | `http://localhost:8000/api/v2/` | | `OPEN_FORMS_CSS_URL` | url | | | URL for the Open Forms SDK CSS file. See: https://open-forms.readthedocs.io/en/stable/developers/embedding.html | `` | `http://localhost:8000/static/sdk/open-forms-sdk.css` | +| `OPEN_FORMS_MOCK` | string | | | Whether to use a mock implementation of the Open Forms API. This can be used for development and testing without needing to set up an actual Open Forms instance. | `` | `false` | | `OPEN_FORMS_SDK_URL` | url | | | URL for the Open Forms SDK JavaScript file. See: https://open-forms.readthedocs.io/en/stable/developers/embedding.html | `http://localhost:8000/static/sdk/open-forms-sdk.js` | `http://localhost:8000/static/sdk/open-forms-sdk.js` | | `OVERIGE_OBJECTEN_API_CORS` | url | | | CORS settings for the Overige Objecten API. This is used to access the Overige Objecten API from the frontend. | `http://localhost:8000` | `` | | `OVERIGE_OBJECTEN_API_PORT` | port-number | | | Port number for the Overige Objecten API. This is used to access the Overige Objecten API from the frontend. | `4001` | `4001` | diff --git a/apps/overige-objecten-api/package.json b/apps/overige-objecten-api/package.json index b537539b5..5c7184605 100644 --- a/apps/overige-objecten-api/package.json +++ b/apps/overige-objecten-api/package.json @@ -42,8 +42,9 @@ "lodash.memoize": "4.1.2", "lodash.merge": "4.6.2", "lodash.snakecase": "4.1.1", - "react": "18.3.1", - "react-markdown": "9.1.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-markdown": "10.1.0", "rehype-raw": "7.0.0", "slugify": "1.6.9", "swagger-ui-express": "5.0.1", diff --git a/apps/overige-objecten-api/vite.config.ts b/apps/overige-objecten-api/vite.config.ts index 72a5acb0f..9ee30e3a3 100644 --- a/apps/overige-objecten-api/vite.config.ts +++ b/apps/overige-objecten-api/vite.config.ts @@ -5,6 +5,9 @@ import { defineConfig } from 'vite'; export default defineConfig({ plugins: [react()], + resolve: { + dedupe: ['react', 'react-dom'], + }, build: { outDir: 'public/vendor', rollupOptions: { diff --git a/apps/pdc-frontend/custom.d.ts b/apps/pdc-frontend/custom.d.ts index 5c1c18f6c..9dd3223b4 100644 --- a/apps/pdc-frontend/custom.d.ts +++ b/apps/pdc-frontend/custom.d.ts @@ -1 +1,3 @@ +declare module '@utrecht/component-library-css'; declare module '*.md'; +declare module '*.css'; diff --git a/apps/pdc-frontend/eslint.config.mjs b/apps/pdc-frontend/eslint.config.mjs index b126184c0..3cf16a81b 100644 --- a/apps/pdc-frontend/eslint.config.mjs +++ b/apps/pdc-frontend/eslint.config.mjs @@ -1,3 +1,6 @@ +import next from 'eslint-config-next'; +import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'; +import nextTypescript from 'eslint-config-next/typescript'; /* eslint-disable import/no-unresolved */ import { nextJsConfig } from '@frameless/eslint-config/next-js'; @@ -11,4 +14,13 @@ const customConfig = { }, }; -export default [...nextJsConfig, customConfig]; +export default [ + ...next, + ...nextCoreWebVitals, + ...nextTypescript, + ...nextJsConfig, + customConfig, + { + ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + }, +]; diff --git a/apps/pdc-frontend/global.d.ts b/apps/pdc-frontend/global.d.ts deleted file mode 100644 index 0f8c4069c..000000000 --- a/apps/pdc-frontend/global.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ReactNode, Ref } from 'react'; - -declare module 'react' { - function experimental_useOptimistic( - passthrough: State, - ): [State, (action: State | ((pendingState: State) => State)) => void]; - function experimental_useOptimistic( - passthrough: State, - reducer: (state: State, action: Action) => State, - ): [State, (action: Action) => void]; -} diff --git a/apps/pdc-frontend/next.config.mjs b/apps/pdc-frontend/next.config.mjs index 9fb15be69..9147794bb 100644 --- a/apps/pdc-frontend/next.config.mjs +++ b/apps/pdc-frontend/next.config.mjs @@ -35,6 +35,9 @@ const nextConfig = { ]; }, images: { + // Allow localhost images in dev only (blocked in production for security) + // https://nextjs.org/docs/app/api-reference/components/image#dangerouslyallowlocalip + dangerouslyAllowLocalIP: process.env.NODE_ENV === 'development', remotePatterns: [ { protocol: protocol.replace(/:$/, ''), @@ -43,20 +46,13 @@ const nextConfig = { }, ], }, - webpack: (config, { isServer }) => { - if (!isServer) { - config.resolve.conditionNames = ['import', 'require', 'default']; - } - config.module.rules.push({ - test: /\.md$/, - // This is the asset module. - type: 'asset/source', - }); - return config; - }, - - experimental: { - serverActions: true, + turbopack: { + rules: { + '*.md': { + loaders: ['raw-loader'], + as: '*.js', + }, + }, }, }; diff --git a/apps/pdc-frontend/package.json b/apps/pdc-frontend/package.json index 4f9e2dd05..9378dff5e 100644 --- a/apps/pdc-frontend/package.json +++ b/apps/pdc-frontend/package.json @@ -34,31 +34,30 @@ "@frameless/utils": "workspace:*", "@nl-design-system-unstable/documentation": "1.1.0", "@open-formulieren/sdk": "3.4.2", - "@playwright/test": "1.45.3", "@tanstack/react-query": "4.29.25", "@utrecht/component-library-css": "9.0.0", - "@utrecht/component-library-react": "13.0.2", + "@utrecht/component-library-react": "14.0.0", "@utrecht/design-tokens": "5.0.1", "@utrecht/flo-legal-decision-tree-client": "1.0.3", "@utrecht/flolegal-decision-tree-css": "2.1.2", "@utrecht/open-forms-container-css": "2.0.1", "@utrecht/open-forms-container-react": "1.0.5", - "@utrecht/web-component-library-react": "3.0.12", - "accept-language": "3.0.18", + "@utrecht/web-component-library-react": "4.0.4", + "accept-language": "3.0.20", "classnames": "2.3.3", "csp-header": "5.2.1", "downshift": "7.6.2", - "i18next": "22.5.1", - "i18next-browser-languagedetector": "7.0.2", - "i18next-resources-to-backend": "1.1.4", - "is-absolute-url": "4.0.1", + "i18next": "26.1.0", + "i18next-browser-languagedetector": "8.2.1", + "i18next-resources-to-backend": "1.2.1", + "is-absolute-url": "5.0.0", "lodash.mergewith": "4.6.2", - "next": "13.5.6", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-i18next": "12.3.1", - "react-loading-skeleton": "3.3.1", - "react-markdown": "9.1.0", + "next": "16.2.6", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-i18next": "17.0.7", + "react-loading-skeleton": "3.5.0", + "react-markdown": "10.1.0", "rehype-raw": "7.0.0", "sharp": "0.34.5" }, @@ -66,6 +65,7 @@ "@frameless/eslint-config": "1.2.0", "@graphql-codegen/cli": "5.0.2", "@graphql-codegen/client-preset": "4.1.0", + "@playwright/test": "1.45.3", "@testing-library/jest-dom": "6.4.5", "@testing-library/react": "15.0.7", "@types/jest": "29.5.12", @@ -75,13 +75,15 @@ "@utrecht/multiline-data-css": "2.0.1", "@utrecht/spotlight-section-css": "2.0.1", "@utrecht/textbox-css": "3.0.1", - "eslint-config-next": "13.2.4", + "eslint": "9.39.4", + "eslint-config-next": "16.2.6", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", + "raw-loader": "4.0.2", "rimraf": "6.1.3", - "sass": "1.98.0", + "sass": "1.99.0", "ts-node": "10.9.2", - "wait-on": "7.2.0" + "wait-on": "9.0.10" }, "repository": { "type": "git+ssh", diff --git a/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/error.tsx b/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/error.tsx index 2c8bf7b2d..4455154b9 100644 --- a/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/error.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/error.tsx @@ -1,8 +1,7 @@ 'use client'; -import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { forwardRef, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import type { PropsWithChildren } from 'react'; import { useTranslation } from '../../i18n/client'; @@ -19,12 +18,12 @@ interface ErrorPageContentProps { message?: string; } -const ErrorPageContent = forwardRef(({ title, message }: PropsWithChildren) => ( +const ErrorPageContent = ({ title, message }: PropsWithChildren) => ( <> {title && {title}} {message && {message}} -)); +); ErrorPageContent.displayName = 'ErrorPageContent'; @@ -61,7 +60,6 @@ export default function Error({ error, reset }: ErrorPageProps) { label: t('components.breadcrumbs.label.online-loket'), current: false, }} - Link={Link} />
; }; -const FormPage = async ({ - params: { +const FormPage = async (props: FormPageProps) => { + const params = await props.params; + const { locale, slug: [formId], - }, -}: FormPageProps) => { + } = params; const { t } = await useTranslation(locale, 'common'); - const nonce = headers().get('x-nonce') || ''; + const nonce = (await headers()).get('x-nonce') || ''; const formInfo = await openFormValidator({ formId }); return ( @@ -50,14 +49,11 @@ const FormPage = async ({ label: t('components.breadcrumbs.label.products'), current: false, }} - Link={Link} />
{formInfo.name} : null} diff --git a/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/form/error/[errorKey]/page.tsx b/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/form/error/[errorKey]/page.tsx index b22e07121..c75402390 100644 --- a/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/form/error/[errorKey]/page.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/form/error/[errorKey]/page.tsx @@ -1,5 +1,3 @@ -import Link from 'next/link'; - import { GetOpenFormsErrorPageQuery } from '../../../../../../../gql/graphql'; import { useTranslation } from '@/app/i18n'; @@ -13,8 +11,8 @@ type ParamsType = { }; interface OpenFormsErrorPageProps { - params: ParamsType; - searchParams: { [key: string]: string | undefined }; + params: Promise; + searchParams: Promise<{ [key: string]: string | undefined }>; } type ErrorKey = 'formulier-niet-gevonden' | 'formulier-server-is-offline' | 'form-not-found' | 'form-server-down'; @@ -30,7 +28,9 @@ const getMappedError = (key: string): NormalizedError => { return mappedErrorKies[normalized]; }; -const OpenFormsErrorPage = async ({ params: { errorKey, locale } }: OpenFormsErrorPageProps) => { +const OpenFormsErrorPage = async (props: OpenFormsErrorPageProps) => { + const params = await props.params; + const { errorKey, locale } = params; const { t } = await useTranslation(locale, ['open-forms-error-pages', 'common']); const type = getMappedError(errorKey); const { data } = await fetchData<{ data: GetOpenFormsErrorPageQuery }>({ @@ -38,7 +38,7 @@ const OpenFormsErrorPage = async ({ params: { errorKey, locale } }: OpenFormsErr query: GET_OPEN_FORMS_ERROR_PAGE, variables: { locale, type }, }); - const openFromsErrorPageData = data?.openFormsErrorPages[0]; + const openFormsErrorPageData = data?.openFormsErrorPages[0]; return (
@@ -65,11 +65,10 @@ const OpenFormsErrorPage = async ({ params: { errorKey, locale } }: OpenFormsErr label: t('components.breadcrumbs.label.online-loket'), current: false, }} - Link={Link} />
- {openFromsErrorPageData?.title} - {openFromsErrorPageData?.body && {openFromsErrorPageData.body}} + {openFormsErrorPageData?.title} + {openFormsErrorPageData?.body && {openFormsErrorPageData.body}}
); diff --git a/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/layout.tsx b/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/layout.tsx index de46abfac..f818c4dff 100644 --- a/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/layout.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(openFormsLayout)/layout.tsx @@ -23,7 +23,6 @@ import { Logo, LogoImage, MatomoTagManager, - Navigation, NavigationListType, Page, PageContent, @@ -34,6 +33,7 @@ import { Surface, } from '@/components'; // import { ClientLanguageSwitcher } from '@/components/ClientLanguageSwitcher'; +import { Navigation } from '@/components/Navigation'; import '@utrecht/component-library-css'; import '@utrecht/design-tokens/dist/index.css'; import '@utrecht/design-tokens/dist/font-family'; @@ -53,21 +53,23 @@ import '../../../styles/globals.css'; interface LayoutProps { children: React.ReactNode; - params: { + params: Promise<{ locale: string; - }; + }>; } type Params = { - params: { + params: Promise<{ locale: string; - }; + }>; }; -export async function generateMetadata({ params: { locale } }: Params): Promise { +export async function generateMetadata(props: Params): Promise { + const params = await props.params; + const { locale } = params; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, 'common'); - const nonce = headers().get('x-nonce') || ''; + const nonce = (await headers()).get('x-nonce') || ''; const url = buildURL({ env: process.env, key: 'FRONTEND_PUBLIC_URL', @@ -134,12 +136,14 @@ export async function generateMetadata({ params: { locale } }: Params): Promise< }; } -const RootLayout = async ({ children, params: { locale } }: LayoutProps) => { - const nonce = headers().get('x-nonce') || ''; +const RootLayout = async (props: LayoutProps) => { + const params = await props.params; + const { locale } = params; + const { children } = props; + const nonce = (await headers()).get('x-nonce') || ''; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, ['layout', 'common']); - const { isEnabled } = draftMode(); - + const { isEnabled } = await draftMode(); const { data } = await fetchData<{ data: GetTemplateDataQuery }>({ url: getStrapiGraphqlURL(), query: GET_OPEN_FORMS_TEMPLATE, @@ -188,7 +192,13 @@ const RootLayout = async ({ children, params: { locale } }: LayoutProps) => { const matomoScripts = websiteSettingData.websiteSetting?.triggerMatomoScript; return ( - + { }) as string } list={navigationData.navigationList as NavigationListType[]} - mobileBreakpoint={961} toggleButton={{ openText: 'Menu', closeText: 'Sluiten', diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/[...not-found]/page.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/[...not-found]/page.tsx index 7cf931690..5166df5bf 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/[...not-found]/page.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/[...not-found]/page.tsx @@ -8,7 +8,10 @@ import { GET_NOT_FOUND_PAGE } from '@/query'; import { getImageBaseUrl, getStrapiGraphqlURL } from '@/util'; import { fetchData } from '@/util/fetchData'; -const NotFoundPage = async ({ params: { locale } }: { params: { locale: string } }) => { +const NotFoundPage = async (props: { params: Promise<{ locale: string }> }) => { + const params = await props.params; + const { locale } = params; + new Response(null, { status: 404 }); const { t } = await useTranslation(locale, ['common']); const { data } = await fetchData<{ data: GetNotFoundPageQuery }>({ @@ -37,7 +40,6 @@ const NotFoundPage = async ({ params: { locale } }: { params: { locale: string } label: t('components.breadcrumbs.label.online-loket'), current: false, }} - Link={Link} />
{data?.notFoundPage?.title} diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/error.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/error.tsx index b115da5d3..d074ab9e3 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/error.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/error.tsx @@ -1,13 +1,13 @@ 'use client'; -import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useTranslation } from '../../i18n/client'; import { fallbackLng } from '../../i18n/settings'; -import { Breadcrumbs, Button, Heading, Paragraph } from '@/components'; +import { Button, Heading, Paragraph } from '@/components'; +import { Breadcrumbs } from '@/components/Breadcrumbs'; interface ErrorPageProps { error: Error; reset: () => void; @@ -46,7 +46,6 @@ export default function Error({ error, reset }: ErrorPageProps) { label: t('components.breadcrumbs.label.online-loket'), current: false, }} - Link={Link} />
{t('common.title')} diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/layout.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/layout.tsx index 845f04b17..9166b86aa 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/layout.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/layout.tsx @@ -28,7 +28,6 @@ import { Logo, LogoImage, MatomoTagManager, - Navigation, NavigationListType, Page, PageContent, @@ -38,6 +37,7 @@ import { SkipLink, Surface, } from '@/components'; +import { Navigation } from '@/components/Navigation'; // import { ClientLanguageSwitcher } from '@/components/ClientLanguageSwitcher'; import { GoogleTranslate } from '@/components/GoogleTranslate'; import { Main } from '@/components/Main'; @@ -54,21 +54,23 @@ import '../../../styles/globals.css'; interface LayoutProps { children: React.ReactNode; - params: { + params: Promise<{ locale: string; - }; + }>; } type Params = { - params: { + params: Promise<{ locale: string; - }; + }>; }; -export async function generateMetadata({ params: { locale } }: Params): Promise { +export async function generateMetadata(props: Params): Promise { + const params = await props.params; + const { locale } = params; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, 'common'); - const nonce = headers().get('x-nonce') || ''; + const nonce = (await headers()).get('x-nonce') || ''; const url = buildURL({ env: process.env, key: 'FRONTEND_PUBLIC_URL', @@ -129,10 +131,13 @@ export async function generateMetadata({ params: { locale } }: Params): Promise< }; } -const RootLayout = async ({ children, params: { locale } }: LayoutProps) => { - const nonce = headers().get('x-nonce') || ''; +const RootLayout = async (props: LayoutProps) => { + const params = await props.params; + const { locale } = params; + const { children } = props; + const nonce = (await headers()).get('x-nonce') || ''; const { t } = await useTranslation(locale, ['layout', 'common']); - const { isEnabled } = draftMode(); + const { isEnabled } = await draftMode(); const { data } = await fetchData<{ data: GetTemplateDataQuery }>({ url: getStrapiGraphqlURL(), query: GET_TEMPLATE, @@ -159,7 +164,13 @@ const RootLayout = async ({ children, params: { locale } }: LayoutProps) => { const matomoScripts = websiteSettingData.websiteSetting?.triggerMatomoScript; return ( - + { }) as string } list={navigationData.navigationList as NavigationListType[]} - mobileBreakpoint={961} toggleButton={{ openText: 'Menu', closeText: 'Sluiten', diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/not-found.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/not-found.tsx index 40e3d1138..2abcccff0 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/not-found.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/not-found.tsx @@ -1,17 +1,17 @@ import { cookies } from 'next/headers'; -import Link from 'next/link'; import { GetNotFoundPageQuery } from '../../../../gql/graphql'; import { useTranslation } from '../../i18n'; import { fallbackLng } from '../../i18n/settings'; -import { Heading, Markdown, Breadcrumbs } from '@/components'; +import { Heading, Markdown } from '@/components'; +import { Breadcrumbs } from '@/components/Breadcrumbs'; import { GET_NOT_FOUND_PAGE } from '@/query'; import { fetchData, getImageBaseUrl, getStrapiGraphqlURL } from '@/util'; const NotFoundPage = async () => { new Response(null, { status: 404 }); - const locale = cookies().get('i18next')?.value; + const locale = (await cookies()).get('i18next')?.value; const { t } = await useTranslation(locale || fallbackLng, ['common']); const { data } = await fetchData<{ data: GetNotFoundPageQuery }>({ url: getStrapiGraphqlURL(), @@ -39,7 +39,6 @@ const NotFoundPage = async () => { label: t('components.breadcrumbs.label.online-loket'), current: false, }} - Link={Link} />
{data?.notFoundPage?.title} diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/page.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/page.tsx index 79a41d4fc..0abe2c329 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/page.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/page.tsx @@ -1,8 +1,6 @@ import { buildURL, getPathAndSearchParams } from '@frameless/utils'; import { Metadata } from 'next'; import { headers } from 'next/headers'; -import Link from 'next/link'; -import React from 'react'; import { CheckAlphabeticallyProductsAvailabilityQuery, @@ -11,17 +9,9 @@ import { } from '../../../../gql/graphql'; import { useTranslation } from '../../i18n'; -import { - Breadcrumbs, - Grid, - GridCell, - Heading, - Heading2, - IndexCharNav, - IndexCharNavLink, - ScrollToTopButton, - UtrechtIconChevronUp, -} from '@/components'; +import { Grid, GridCell, Heading, Heading2, IndexCharNav, IndexCharNavLink } from '@/components'; +import { ScrollToTopButton } from '@/components/ScrollToTopButton'; +import { Breadcrumbs } from '@/components/Breadcrumbs'; import { KCMSurvey } from '@/components/KCMSurvey'; import { TopTask, TopTaskDataTypes } from '@/components/Toptask'; import { CHECK_ALPHABETICALLY_PRODUCTS_AVAILABILITY, GET_PDC_HOME_PAGE } from '@/query'; @@ -34,12 +24,14 @@ export interface Fields { } type Params = { - params: { + params: Promise<{ locale: string; - }; + }>; }; -export async function generateMetadata({ params: { locale } }: Params): Promise { +export async function generateMetadata(props: Params): Promise { + const params = await props.params; + const { locale } = params; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, ['home-page', 'common']); const title = t('seo.title'); @@ -66,9 +58,11 @@ export async function generateMetadata({ params: { locale } }: Params): Promise< }; } -const Home = async ({ params: { locale } }: { params: any }) => { +const Home = async (props: { params: Promise }) => { + const params = await props.params; + const { locale } = params; const { t } = await useTranslation(locale, ['home-page', 'common']); - const nonce = headers().get('x-nonce') || ''; + const nonce = (await headers()).get('x-nonce') || ''; const { data } = await fetchData<{ data: GetPdcHomePageQuery }>({ url: getStrapiGraphqlURL(), query: GET_PDC_HOME_PAGE, @@ -127,7 +121,6 @@ const Home = async ({ params: { locale } }: { params: any }) => { label: t('components.breadcrumbs.label.home'), current: false, }} - Link={Link} />
{t('h1')} @@ -151,7 +144,7 @@ const Home = async ({ params: { locale } }: { params: any }) => { - {t('actions.scroll-to-top')} + {t('actions.scroll-to-top')}
diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/[slug]/page.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/[slug]/page.tsx index 91c633ff8..80c24f4a8 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/[slug]/page.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/[slug]/page.tsx @@ -4,10 +4,8 @@ import isAbsoluteUrl from 'is-absolute-url'; import { Metadata } from 'next'; import { draftMode, headers } from 'next/headers'; import Image from 'next/image'; -import Link from 'next/link'; import { notFound } from 'next/navigation'; import Script from 'next/script'; -import React from 'react'; import { GetProductBySlugQuery, ProductSectionsDynamicZone } from '../../../../../../gql/graphql'; @@ -30,12 +28,11 @@ import { Markdown, MultiColumnsButton, RichText, - ScrollToTopButton, SpotlightSection, - UtrechtIconChevronUp, } from '@/components'; import { ContactCard } from '@/components/ContactCard'; import { KCMSurvey } from '@/components/KCMSurvey'; +import { ScrollToTopButton } from '@/components/ScrollToTopButton'; import { GET_PRODUCT_BY_SLUG } from '@/query'; import { buildAlternateLinks, @@ -49,7 +46,7 @@ import '@/styles/print.css'; type SpotlightSectionType = 'info' | 'warning' | 'error' | 'ok'; const getAllProducts = async (locale: string, slug: string) => { - const { isEnabled } = draftMode(); + const { isEnabled } = await draftMode(); const { data } = await fetchData<{ data: GetProductBySlugQuery }>({ url: getStrapiGraphqlURL(), query: GET_PRODUCT_BY_SLUG, @@ -73,11 +70,12 @@ type ParamsType = { }; interface ProductProps { - params: ParamsType; - searchParams: { [key: string]: string | undefined }; + params: Promise; + searchParams: Promise<{ [key: string]: string | undefined }>; } -export async function generateMetadata({ params }: { params: ParamsType }): Promise { +export async function generateMetadata(props: { params: Promise }): Promise { + const params = await props.params; const locale = params?.locale; const slug = params?.slug; // eslint-disable-next-line react-hooks/rules-of-hooks @@ -124,7 +122,7 @@ interface SectionsProps { sections: ProductSectionsDynamicZone[]; priceData: any; locale: string; - t: TFunction; + t: TFunction; nonce?: string; } @@ -270,6 +268,8 @@ const Sections = ({ sections, locale, priceData, t, nonce }: SectionsProps) => ( alt={component?.imageData?.alternativeText || ''} figure={component?.imageData?.caption || ''} sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" + // Mark as priority because this is the above-the-fold (LCP) image for faster initial load + loading="eager" /> ); } @@ -327,13 +327,13 @@ const Sections = ({ sections, locale, priceData, t, nonce }: SectionsProps) => ( ); -const Product = async ({ params: { locale, slug } }: ProductProps) => { +const Product = async (props: ProductProps) => { + const params = await props.params; + const { locale, slug } = params; const { product } = await getAllProducts(locale, slug); - const nonce = headers().get('x-nonce') || ''; + const nonce = (await headers()).get('x-nonce') || ''; const priceData: any = product?.price && product?.price?.price; - const { t } = await useTranslation(locale, 'common'); - const { pathSegments: productsSegment } = getPathAndSearchParams({ locale, translations: t, @@ -370,7 +370,6 @@ const Product = async ({ params: { locale, slug } }: ProductProps) => { label: t('components.breadcrumbs.label.products'), current: false, }} - Link={Link} />
@@ -396,9 +395,7 @@ const Product = async ({ params: { locale, slug } }: ProductProps) => { {(product?.enable_kcm_survey ?? true) && } - - {t('actions.scroll-to-top')} - + {t('actions.scroll-to-top')}
diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/alphabet/[q]/page.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/alphabet/[q]/page.tsx index d0e9cdc3e..3e49a1bd7 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/alphabet/[q]/page.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/alphabet/[q]/page.tsx @@ -1,24 +1,14 @@ import { buildURL, getPathAndSearchParams } from '@frameless/utils'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; -import Link from 'next/link'; import { CheckAlphabeticallyProductsAvailabilityQuery, Product } from '../../../../../../../gql/graphql'; import { useTranslation } from '@/app/i18n'; import { languages } from '@/app/i18n/settings'; -import { - Breadcrumbs, - Grid, - GridCell, - Heading, - IndexCharNav, - IndexCharNavLink, - Paragraph, - ScrollToTopButton, - UtrechtIconChevronUp, -} from '@/components'; +import { Breadcrumbs, Grid, GridCell, Heading, IndexCharNav, IndexCharNavLink, Paragraph } from '@/components'; import { KCMSurvey } from '@/components/KCMSurvey'; +import { ScrollToTopButton } from '@/components/ScrollToTopButton'; import { ProductListContainer } from '@/components/ProductListContainer'; import { CHECK_ALPHABETICALLY_PRODUCTS_AVAILABILITY } from '@/query'; import { @@ -33,10 +23,10 @@ import { fetchData } from '@/util/fetchData'; export const revalidate = 3600; // revalidate the data at most every hour type Params = { - params: { + params: Promise<{ locale: string; q: string; - }; + }>; }; export interface Fields { @@ -44,7 +34,9 @@ export interface Fields { body: string; } -export async function generateMetadata({ params: { locale, q } }: Params): Promise { +export async function generateMetadata(props: Params): Promise { + const params = await props.params; + const { locale, q } = params; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, ['alphabet-page', 'common']); const title = t('seo.title'); @@ -78,10 +70,12 @@ export async function generateMetadata({ params: { locale, q } }: Params): Promi }; } -const ProductsAlphabetPage = async ({ params: { locale, q } }: Params) => { +const ProductsAlphabetPage = async (props: Params) => { + const params = await props.params; + const { locale, q } = params; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, ['alphabet-page', 'common']); - const nonce = headers().get('x-nonce') || ''; + const nonce = (await headers()).get('x-nonce') || ''; const { products_connection } = await getAlphabeticallyProductsByLetter({ locale, page: 1, @@ -152,7 +146,6 @@ const ProductsAlphabetPage = async ({ params: { locale, q } }: Params) => { label: t('components.breadcrumbs.label.products'), current: false, }} - Link={Link} />
{t('h1')} @@ -178,7 +171,7 @@ const ProductsAlphabetPage = async ({ params: { locale, q } }: Params) => { - {t('actions.scroll-to-top')} + {t('actions.scroll-to-top')}
diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/page.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/page.tsx index 3cbb14bab..d18a999d4 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/page.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/products/page.tsx @@ -1,16 +1,16 @@ import { buildURL, getPathAndSearchParams } from '@frameless/utils'; import { Metadata } from 'next'; import { headers } from 'next/headers'; -import Link from 'next/link'; import { GetAllProductsSlugQueryQuery, Product } from '../../../../../gql/graphql'; import { GET_ALL_PRODUCTS_SLUG } from '../../../../query'; import { useTranslation } from '../../../i18n'; import { languages } from '@/app/i18n/settings'; -import { Breadcrumbs, Grid, GridCell, Heading, ScrollToTopButton, UtrechtIconChevronUp } from '@/components'; +import { Breadcrumbs, Grid, GridCell, Heading } from '@/components'; import { KCMSurvey } from '@/components/KCMSurvey'; import { ProductListContainer } from '@/components/ProductListContainer'; +import { ScrollToTopButton } from '@/components/ScrollToTopButton'; import { apiSettings, getStrapiGraphqlURL, mappingProducts, buildAlternateLinks, fetchData } from '@/util'; export interface Fields { title: string; @@ -18,9 +18,9 @@ export interface Fields { } type Params = { - params: { + params: Promise<{ locale: string; - }; + }>; }; const fetchAllProducts = async ({ locale }: { locale: string }) => { @@ -32,7 +32,9 @@ const fetchAllProducts = async ({ locale }: { locale: string }) => { return data; }; -export async function generateMetadata({ params: { locale } }: Params): Promise { +export async function generateMetadata(props: Params): Promise { + const params = await props.params; + const { locale } = params; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, ['products-page', 'common']); const title = t('seo.title'); @@ -66,10 +68,12 @@ export async function generateMetadata({ params: { locale } }: Params): Promise< }; } -const Products = async ({ params: { locale } }: { params: { locale: string } }) => { +const Products = async (props: { params: Promise<{ locale: string }> }) => { + const params = await props.params; + const { locale } = params; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, ['products-page', 'common']); - const nonce = headers().get('x-nonce') || ''; + const nonce = (await headers()).get('x-nonce') || ''; const { pathSegments: productSegment } = getPathAndSearchParams({ translations: t, segments: ['segments.products'], @@ -109,7 +113,6 @@ const Products = async ({ params: { locale } }: { params: { locale: string } }) label: t('components.breadcrumbs.label.online-loket'), current: false, }} - Link={Link} />
{t('h1')} @@ -126,7 +129,7 @@ const Products = async ({ params: { locale } }: { params: { locale: string } }) - {t('actions.scroll-to-top')} + {t('actions.scroll-to-top')} {' '}
diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/search/[query]/page.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/search/[query]/page.tsx index ce5498b9a..6ef91019b 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/search/[query]/page.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/search/[query]/page.tsx @@ -1,14 +1,15 @@ import { buildURL, getPathAndSearchParams } from '@frameless/utils'; import { Metadata } from 'next'; import { headers } from 'next/headers'; -import Link from 'next/link'; import { redirect } from 'next/navigation'; import { useTranslation } from '../../../../i18n/index'; import { getSuggestedSearch } from '@/app/actions'; import { languages } from '@/app/i18n/settings'; -import { Breadcrumbs, Grid, GridCell, Heading, ScrollToTopButton, UtrechtIconChevronUp } from '@/components'; +import { Grid, GridCell, Heading } from '@/components'; +import { Breadcrumbs } from '@/components/Breadcrumbs'; +import { ScrollToTopButton } from '@/components/ScrollToTopButton'; import { KCMSurvey } from '@/components/KCMSurvey'; import { ProductListContainer } from '@/components/ProductListContainer'; import { buildAlternateLinks } from '@/util'; @@ -18,17 +19,19 @@ type ParamsType = { }; interface SearchProps { - params: ParamsType; + params: Promise; } type Params = { - params: { + params: Promise<{ locale: string; query: string; - }; + }>; }; -export async function generateMetadata({ params: { locale, query } }: Params): Promise { +export async function generateMetadata(props: Params): Promise { + const params = await props.params; + const { locale, query } = params; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, ['search-page', 'common']); const decodeQuery = decodeURIComponent(query)?.trim(); @@ -64,9 +67,11 @@ export async function generateMetadata({ params: { locale, query } }: Params): P }; } -const Search = async ({ params: { locale, query } }: SearchProps) => { +const Search = async (props: SearchProps) => { + const params = await props.params; + const { locale, query } = params; const { t } = await useTranslation(locale, ['search-page', 'common']); - const nonce = headers().get('x-nonce') || ''; + const nonce = (await headers()).get('x-nonce') || ''; const decodeQuery = decodeURIComponent(query)?.trim(); const searchResults = await getSuggestedSearch(locale, decodeQuery); @@ -121,7 +126,6 @@ const Search = async ({ params: { locale, query } }: SearchProps) => { label: t('components.breadcrumbs.label.online-loket'), current: false, }} - Link={Link} />
{t('page-title', { query: decodeQuery, interpolation: { escapeValue: false } })} @@ -139,7 +143,7 @@ const Search = async ({ params: { locale, query } }: SearchProps) => { - {t('actions.scroll-to-top')} + {t('actions.scroll-to-top')}
diff --git a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/search/tips/page.tsx b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/search/tips/page.tsx index d467364b1..7b4d9f99a 100644 --- a/apps/pdc-frontend/src/app/[locale]/(rootLayout)/search/tips/page.tsx +++ b/apps/pdc-frontend/src/app/[locale]/(rootLayout)/search/tips/page.tsx @@ -1,20 +1,21 @@ import { getPathAndSearchParams } from '@frameless/utils'; import { Metadata } from 'next'; import { headers } from 'next/headers'; -import Link from 'next/link'; import { useTranslation } from '@/app/i18n'; import { Breadcrumbs, Heading, UnorderedList, UnorderedListItem } from '@/components'; import { KCMSurvey } from '@/components/KCMSurvey'; type Params = { - params: { + params: Promise<{ locale: string; query: string; - }; + }>; }; -export async function generateMetadata({ params: { locale, query } }: Params): Promise { +export async function generateMetadata(props: Params): Promise { + const params = await props.params; + const { locale, query } = params; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, ['tips-page']); return { @@ -26,9 +27,12 @@ export async function generateMetadata({ params: { locale, query } }: Params): P }; } -const SearchTips = async ({ params: { locale }, searchParams }: any) => { +const SearchTips = async (props: any) => { + const searchParams = await props.searchParams; + const params = await props.params; + const { locale } = params; const { t } = await useTranslation(locale, ['tips-page', 'common']); - const nonce = headers().get('x-nonce') || ''; + const nonce = (await headers()).get('x-nonce') || ''; const query = searchParams?.query; const tipsList = t('body.section.unordered-list', { returnObjects: true }) as string[]; const decodeQuery = decodeURIComponent(query); @@ -64,7 +68,6 @@ const SearchTips = async ({ params: { locale }, searchParams }: any) => { label: t('components.breadcrumbs.label.online-loket'), current: false, }} - Link={Link} />
diff --git a/apps/pdc-frontend/src/app/[locale]/sitemap.ts b/apps/pdc-frontend/src/app/[locale]/sitemap.ts index 09e18b556..b4e849b1f 100644 --- a/apps/pdc-frontend/src/app/[locale]/sitemap.ts +++ b/apps/pdc-frontend/src/app/[locale]/sitemap.ts @@ -17,7 +17,7 @@ const generateStaticPagesPath = (paths: string[]) => { }); }; export default async function sitemap(): Promise { - const locale = cookies().get('i18nextLng')?.value || 'nl'; + const locale = (await cookies()).get('i18nextLng')?.value || 'nl'; // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = await useTranslation(locale, 'common'); const productsUrl = buildURL({ diff --git a/apps/pdc-frontend/src/app/api/clear-preview/route.ts b/apps/pdc-frontend/src/app/api/clear-preview/route.ts index 63ee2430a..2c6e12932 100644 --- a/apps/pdc-frontend/src/app/api/clear-preview/route.ts +++ b/apps/pdc-frontend/src/app/api/clear-preview/route.ts @@ -2,6 +2,6 @@ import { draftMode } from 'next/headers'; import { redirect } from 'next/navigation'; export async function GET(_request: Request) { - draftMode().disable(); + (await draftMode()).disable(); redirect('/'); } diff --git a/apps/pdc-frontend/src/app/api/current-pathname/route.ts b/apps/pdc-frontend/src/app/api/current-pathname/route.ts index 647a9fae2..7def728e4 100644 --- a/apps/pdc-frontend/src/app/api/current-pathname/route.ts +++ b/apps/pdc-frontend/src/app/api/current-pathname/route.ts @@ -16,7 +16,7 @@ export async function GET(request: Request) { return NextResponse.json({ localizations: [] }); } - const cookieStore = cookies(); + const cookieStore = await cookies(); cookieStore.set('slug', slug); const { data } = await fetchData<{ data: GetProductBySlugQuery }>({ url: getStrapiGraphqlURL(), diff --git a/apps/pdc-frontend/src/app/api/open-forms-mock/[...path]/route.ts b/apps/pdc-frontend/src/app/api/open-forms-mock/[...path]/route.ts new file mode 100644 index 000000000..6bcf388aa --- /dev/null +++ b/apps/pdc-frontend/src/app/api/open-forms-mock/[...path]/route.ts @@ -0,0 +1,284 @@ +'use server'; + +import { type NextRequest, NextResponse } from 'next/server'; + +// Fixed UUIDs (consistent across requests) + +const FORM_UUID = 'e450890a-4166-410e-8d64-0a54ad30ba01'; +const FORM_STEP_UUID = '9e6eb3c5-e5a4-4abf-b64a-73d3243f2bf5'; +const SUB_STEP_ID = '58aad9c3-29c7-4568-9047-3ac7ceb0f0ff'; + +// In-memory submission store (dev only, resets on server restart) + +interface StoredSubmission { + stepData: Record; + completed: boolean; +} +const store = new Map(); + +// Demo form fields + +const COMPONENTS = [ + { id: 'f1', type: 'textfield', key: 'firstName', label: 'Voornaam', validate: { required: true } }, + { id: 'f2', type: 'textfield', key: 'lastName', label: 'Achternaam', validate: { required: true } }, + { id: 'f3', type: 'email', key: 'email', label: 'E-mailadres', validate: { required: false } }, +]; + +const DEMO_FORM = { + uuid: FORM_UUID, + name: 'Demo formulier', + slug: 'demo-form', + url: '/api/open-forms-mock/forms/demo-form', + loginRequired: false, + loginOptions: [], + showProgressIndicator: true, + showSummaryProgress: false, + maintenanceMode: false, + active: true, + submissionAllowed: 'yes', + submissionLimitReached: false, + suspensionAllowed: false, + sendConfirmationEmail: false, + displayMainWebsiteLink: false, + submissionStatementsConfiguration: [], + appointmentOptions: { isAppointment: false, supportsMultipleProducts: null }, + literals: { + beginText: { resolved: 'Begin', value: '' }, + changeText: { resolved: 'Wijzig', value: '' }, + confirmText: { resolved: 'Bevestig', value: '' }, + previousText: { resolved: 'Vorige', value: '' }, + }, + steps: [ + { + uuid: FORM_STEP_UUID, + slug: 'step-1', + formDefinition: 'Stap 1', + index: 0, + literals: { + previousText: { resolved: 'Vorige', value: '' }, + saveText: { resolved: 'Opslaan', value: '' }, + nextText: { resolved: 'Volgende', value: '' }, + }, + url: `/api/open-forms-mock/forms/demo-form/steps/${FORM_STEP_UUID}`, + }, + ], + introductionPageContent: '', + explanationTemplate: '', + requiredFieldsWithAsterisk: true, + resumeLinkLifetime: 7, + autoLoginAuthenticationBackend: '', + translationEnabled: false, + hideNonApplicableSteps: false, + cosignLoginOptions: [], + cosignHasLinkInEmail: false, + paymentRequired: false, + submissionReportDownloadLinkTitle: '', + newRendererEnabled: false, + communicationPreferencesPortalUrl: '', +}; + +// Helpers + +function ok(data: unknown, status = 200) { + return NextResponse.json(data, { + status, + headers: { 'Content-Language': 'nl' }, + }); +} + +function buildSubmission(uuid: string, state?: StoredSubmission) { + return { + id: uuid, + url: `/api/open-forms-mock/submissions/${uuid}`, + form: '/api/open-forms-mock/forms/demo-form', + formUrl: `http://localhost:3000/nl/formulier/demo-form`, + initialDataReference: '', + steps: [ + { + id: SUB_STEP_ID, + name: 'Stap 1', + url: `/api/open-forms-mock/submissions/${uuid}/steps/${FORM_STEP_UUID}`, + formStep: `/api/open-forms-mock/forms/demo-form/steps/${FORM_STEP_UUID}`, + isApplicable: true, + completed: state?.completed ?? false, + canSubmit: true, + }, + ], + submissionAllowed: 'yes', + isAuthenticated: false, + payment: { isRequired: false, amount: null, hasPaid: false }, + }; +} + +function buildStep(uuid: string, state?: StoredSubmission) { + return { + id: SUB_STEP_ID, + slug: 'step-1', + formStep: { + index: 0, + configuration: { components: COMPONENTS }, + }, + data: state?.stepData ?? null, + isApplicable: true, + completed: state?.completed ?? false, + canSubmit: true, + }; +} + +// Route handlers + +type Params = { params: Promise<{ path: string[] }> }; + +export async function GET(req: NextRequest, { params }: Params) { + const { path } = await params; + const [p0, p1, p2, p3] = path; + + // GET /forms/{slug} + if (p0 === 'forms' && path.length === 2) { + return ok(DEMO_FORM); + } + + // GET /i18n/info + if (p0 === 'i18n' && p1 === 'info') { + return ok({ + languages: [ + { code: 'nl', name: 'Nederlands' }, + { code: 'en', name: 'English' }, + ], + current: 'nl', + }); + } + + // GET /i18n/formio/{lang} or /i18n/compiled-messages/{lang}.json + if (p0 === 'i18n') return ok({}); + + // GET /analytics/analytics-tools-config-info + if (p0 === 'analytics') { + return ok({ govmetricSourceId: '', govmetricSecureGuid: '', enableGovmetricAnalytics: false }); + } + + // GET /submissions/{uuid} + if (p0 === 'submissions' && path.length === 2) { + const state = store.get(p1); + if (!state) return ok({ detail: 'Not found.' }, 404); + return ok(buildSubmission(p1, state)); + } + + // GET /submissions/{uuid}/steps/{stepUuid} + if (p0 === 'submissions' && p2 === 'steps' && path.length === 4) { + const state = store.get(p1); + if (!state) return ok({ detail: 'Not found.' }, 404); + return ok(buildStep(p1, state)); + } + + // GET /submissions/{uuid}/summary + if (p0 === 'submissions' && p2 === 'summary') { + const state = store.get(p1); + if (!state) return ok({ detail: 'Not found.' }, 404); + const data = (state?.stepData ?? {}) as Record; + return ok([ + { + slug: 'step-1', + name: 'Stap 1', + data: COMPONENTS.map((c) => ({ + name: c.label, + value: data[c.key] ?? '', + component: c, + })), + }, + ]); + } + + // GET /submissions/{uuid}/status (polled after _complete) + if (p0 === 'submissions' && p2 === 'status') { + return ok({ + status: 'done', + result: 'success', + publicReference: 'MOCK-001', + confirmationPageTitle: 'Bedankt', + confirmationPageContent: '

Uw formulier is ontvangen. Referentie: MOCK-001.

', + reportDownloadUrl: '', + paymentUrl: '', + mainWebsiteUrl: '', + }); + } + + return ok({ detail: 'Not found.' }, 404); +} + +export async function POST(req: NextRequest, { params }: Params) { + const { path } = await params; + const [p0, p1, p2, p3, p4] = path; + const body = await req.json().catch(() => ({})); + + // POST /submissions + if (p0 === 'submissions' && path.length === 1) { + const uuid = crypto.randomUUID(); + store.set(uuid, { stepData: {}, completed: false }); + return ok(buildSubmission(uuid), 201); + } + + // POST /submissions/{uuid}/_complete + if (p0 === 'submissions' && p2 === '_complete') { + return ok({ statusUrl: `/api/open-forms-mock/submissions/${p1}/status` }); + } + + // POST /submissions/{uuid}/steps/{stepUuid}/validate + if (p0 === 'submissions' && p2 === 'steps' && p4 === 'validate') { + return ok({}); + } + + // POST /submissions/{uuid}/steps/{stepUuid}/_check-logic + if (p0 === 'submissions' && p2 === 'steps' && p4 === '_check-logic') { + const state = store.get(p1); + const merged = { ...(state?.stepData ?? {}), ...(body.data ?? {}) }; + return ok({ + submission: buildSubmission(p1, state), + step: { + ...buildStep(p1, state), + data: merged, + }, + }); + } + + return ok({ detail: 'Not found.' }, 404); +} + +export async function PUT(req: NextRequest, { params }: Params) { + const { path } = await params; + const [p0, p1, p2] = path; + const body = await req.json().catch(() => ({})); + + // PUT /submissions/{uuid}/steps/{stepUuid} + if (p0 === 'submissions' && p2 === 'steps' && path.length === 4) { + const existing = store.get(p1) ?? { stepData: {}, completed: false }; + const updated = { ...existing, stepData: body.data ?? {}, completed: true }; + store.set(p1, updated); + return ok(buildStep(p1, updated)); + } + + return ok({ detail: 'Not found.' }, 404); +} + +export async function PATCH(req: NextRequest, ctx: Params) { + return PUT(req, ctx); +} + +export async function DELETE(_req: NextRequest, { params }: Params) { + const { path } = await params; + const [p0, p1, p2] = path; + + // DELETE /submissions/{uuid} + if (p0 === 'submissions' && path.length === 2) { + store.delete(p1); + return new NextResponse(null, { status: 204 }); + } + + // DELETE /authentication/{submissionId}/session (triggered by "Annuleren" button) + if (p0 === 'authentication' && p2 === 'session') { + store.delete(p1); + return new NextResponse(null, { status: 204 }); + } + + return ok({ detail: 'Not found.' }, 404); +} diff --git a/apps/pdc-frontend/src/app/api/preview/route.ts b/apps/pdc-frontend/src/app/api/preview/route.ts index 66855c89d..282606db3 100644 --- a/apps/pdc-frontend/src/app/api/preview/route.ts +++ b/apps/pdc-frontend/src/app/api/preview/route.ts @@ -47,7 +47,7 @@ export async function GET(request: Request) { const path = getCurrentPage(type).path; // Enable Draft Mode by setting the cookie - draftMode().enable(); + (await draftMode()).enable(); // Redirect to the path from the fetched post // We don't redirect to searchParams.slug as that might lead to open redirect vulnerabilities redirect(path ? path : '/'); diff --git a/apps/pdc-frontend/src/components/Breadcrumbs.tsx b/apps/pdc-frontend/src/components/Breadcrumbs.tsx new file mode 100644 index 000000000..883a30ed1 --- /dev/null +++ b/apps/pdc-frontend/src/components/Breadcrumbs.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Breadcrumbs as UIBreadcrumbs } from '@frameless/ui'; +import type { BreadcrumbNavProps } from '@utrecht/component-library-react'; +import Link from 'next/link'; +import { ComponentType } from 'react'; + +type BreadcrumbLinkType = { href: string; label: string; current: boolean }; + +interface BreadcrumbsProps extends BreadcrumbNavProps { + links: BreadcrumbLinkType[]; + backLink?: BreadcrumbLinkType; + label?: string; +} + +export const Breadcrumbs = (props: BreadcrumbsProps) => } />; diff --git a/apps/pdc-frontend/src/components/Markdown/index.tsx b/apps/pdc-frontend/src/components/Markdown/index.tsx index 60965065c..dfff13dfa 100644 --- a/apps/pdc-frontend/src/components/Markdown/index.tsx +++ b/apps/pdc-frontend/src/components/Markdown/index.tsx @@ -3,7 +3,7 @@ import { Markdown as BaseMarkdown, Img, PriceWidget } from '@frameless/ui'; import isAbsoluteUrl from 'is-absolute-url'; import Image from 'next/image'; import NextLink from 'next/link'; -import type { Options } from 'react-markdown'; +import type { Components } from 'react-markdown'; import { useTranslation } from '../../app/i18n/client'; import { fallbackLng } from '../../app/i18n/settings'; @@ -37,7 +37,7 @@ export const Markdown = ({ priceData?: PriceTypes[]; locale?: string; }) => { - const priceWidget: Options['components'] = { + const priceWidget: Components = { span: ({ node, children: spanChildren }) => { if (node?.properties.dataStrapiCategory === 'price') { // eslint-disable-next-line react-hooks/rules-of-hooks @@ -72,6 +72,8 @@ export const Markdown = ({ height={Number(height)} sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" figure={dataFigcaption} + // Mark as priority because this is the above-the-fold (LCP) image for faster initial load + loading="eager" /> ) : null; }, diff --git a/apps/pdc-frontend/src/components/Navigation.tsx b/apps/pdc-frontend/src/components/Navigation.tsx new file mode 100644 index 000000000..c0ff299e6 --- /dev/null +++ b/apps/pdc-frontend/src/components/Navigation.tsx @@ -0,0 +1,3 @@ +'use client'; + +export { Navigation } from '@frameless/ui'; diff --git a/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsEmbed.scss b/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsEmbed.scss index 76a20edb6..4f526789d 100644 --- a/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsEmbed.scss +++ b/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsEmbed.scss @@ -1,7 +1,7 @@ -@import "~@utrecht/multiline-data-css/src/mixin"; -@import "~@utrecht/form-label-css/src/mixin"; -@import "~@utrecht/textbox-css/src/mixin"; -@import "~@utrecht/spotlight-section-css/src/mixin"; +@use "~@utrecht/multiline-data-css/src/mixin" as multiline-data; +@use "~@utrecht/form-label-css/src/mixin" as form-label; +@use "~@utrecht/textbox-css/src/mixin" as textbox; +@use "~@utrecht/spotlight-section-css/src/mixin" as spotlight-section; :root { --of-progress-indicator-padding-block-end: 0; @@ -10,15 +10,15 @@ } .utrecht-form-container--openforms .utrecht-form-label--openforms.utrecht-form-label--radio { - @include utrecht-form-label--radio; + @include form-label.utrecht-form-label--radio; } .utrecht-form-container--openforms .utrecht-textbox--openforms:disabled { - @include utrecht-textbox--read-only; + @include textbox.utrecht-textbox--read-only; } .formio-editor-read-only-content { - @include utrecht-multiline-data; + @include multiline-data.utrecht-multiline-data; } /* previous button alignment */ @@ -26,31 +26,6 @@ justify-content: left; } -/** - * Workaround for Open Forms SDK 2.1.3 - * The selected value for the combobox, when not expanded, would overflow the form control. - * @see https://github.com/frameless/strapi/issues/847 - */ - -// TODO remove the code below when uen upgrade open Forms SDK to 2.3.1 version - -/* stylelint-disable-next-line selector-class-pattern */ -.utrecht-form-field.utrecht-form-field--select .selection { - height: 100% !important; - - /* This line is still needed in Open Forms to leave 44px space for the "Clear" button and the expanded icon */ - max-inline-size: calc(100% - 44px); -} - -/* stylelint-disable selector-class-pattern */ -.utrecht-form-field.utrecht-form-field--select .selection .choices__item, -.utrecht-form-field.utrecht-form-field--select .selection + .choices__list .choices__item { - block-size: fit-content !important; - overflow-wrap: break-word; - text-wrap: wrap; -} -/* stylelint-enable selector-class-pattern */ - /* openforms datalist */ .openforms-app > .openforms-app__body > .openforms-card { --of-card-padding-block-end: 0; @@ -75,29 +50,29 @@ &--info { --of-formio-content-info-border-inline-start-style: none; - @include utrecht-spotlight-section; - @include utrecht-spotlight-section-type("info"); + @include spotlight-section.utrecht-spotlight-section; + @include spotlight-section.utrecht-spotlight-section-type("info"); } &--warning { --of-formio-content-warning-border-inline-start-style: none; - @include utrecht-spotlight-section; - @include utrecht-spotlight-section-type("warning"); + @include spotlight-section.utrecht-spotlight-section; + @include spotlight-section.utrecht-spotlight-section-type("warning"); } &--error { --of-formio-content-error-border-inline-start-style: none; - @include utrecht-spotlight-section; - @include utrecht-spotlight-section-type("warning"); + @include spotlight-section.utrecht-spotlight-section; + @include spotlight-section.utrecht-spotlight-section-type("warning"); } &--success { --of-formio-content-success-border-inline-start-style: none; - @include utrecht-spotlight-section; - @include utrecht-spotlight-section-type("ok"); + @include spotlight-section.utrecht-spotlight-section; + @include spotlight-section.utrecht-spotlight-section-type("ok"); } &--success, diff --git a/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsEmbed.tsx b/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsEmbed.tsx index d3bb22701..852550ff1 100644 --- a/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsEmbed.tsx +++ b/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsEmbed.tsx @@ -1,12 +1,12 @@ 'use client'; import { OpenFormsContainer } from '@utrecht/open-forms-container-react/dist/css'; import { usePathname } from 'next/navigation'; -import React, { type ReactNode, useId } from 'react'; +import { type ReactNode, useId, useRef } from 'react'; import { RichText } from '../index'; import { OpenFormsNLDesignSystem } from './OpenFormsNLDesignSystem'; -import { OpenFormsScript } from './OpenFormsScript'; +import { OpenFormsSDK } from './OpenFormsSDK'; import '@open-formulieren/sdk/styles.css'; import '@utrecht/component-library-css/dist/html.css'; import '@utrecht/open-forms-container-css/dist/index.css'; @@ -16,30 +16,25 @@ export type OpenFormsEmbedProps = { nonce: string; slug: string; apiUrl: string; - sdkUrl: string; - cssUrl: string; fallback?: ReactNode; }; -export const OpenFormsEmbed = ({ nonce, slug, apiUrl, sdkUrl, cssUrl, fallback }: OpenFormsEmbedProps) => { +export const OpenFormsEmbed = ({ nonce, slug, apiUrl, fallback }: OpenFormsEmbedProps) => { const id = useId(); const pathname = usePathname(); + // Capture the mount-time pathname only, the SDK's react-router changes the URL + // as the user moves through steps, but we must not re-derive basePath from those + // changes, as that would re-render OpenFormsScript and try to reinitialise the form. + const basePathRef = useRef(pathname.split('/').slice(0, 4).join('/')); + return ( -
- {fallback} -
+
{fallback}
- - +
); diff --git a/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsSDK.tsx b/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsSDK.tsx new file mode 100644 index 000000000..fa60ce6d8 --- /dev/null +++ b/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsSDK.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +export type OpenFormsScriptProps = { + targetId: string; + apiUrl: string; + formId: string; + basePath: string; + nonce?: string; +}; + +export const OpenFormsSDK = ({ targetId, apiUrl, formId, basePath, nonce }: OpenFormsScriptProps) => { + // Guard against React strict-mode double-invocation and soft-navigation re-renders. + // The SDK attaches a react-router instance to the DOM node; initialising it twice + // on the same element would break routing. + const initializedRef = useRef(false); + + useEffect(() => { + if (initializedRef.current) return; + const target = document.getElementById(targetId); + if (!target) return; + initializedRef.current = true; + + // Dynamic import keeps the SDK out of the SSR bundle and defers the heavy + // formio chunk until the form page is actually mounted in the browser. + import('@open-formulieren/sdk').then(({ OpenForm }) => { + new OpenForm(target, { + baseUrl: apiUrl, + formId, + basePath, + CSPNonce: nonce, + }) + .init() + .catch(console.error); + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // All options are captured once at mount, the form never re-initialises on + // soft navigation because the SDK manages its own routing from that point on. + + return null; +}; diff --git a/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsScript.tsx b/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsScript.tsx deleted file mode 100644 index a16da7d69..000000000 --- a/apps/pdc-frontend/src/components/OpenFormsEmbed/OpenFormsScript.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client'; - -import Script from 'next/script'; -import React, { useRef } from 'react'; - -export type OpenFormsScriptProps = { - nonce: string; - src: string; - targetId: string; -}; - -interface OpenForm { - new (_targetNode: Node, _opts: object): OpenForm; - - init(): Promise; -} - -declare global { - interface Window { - OpenForms: { - OpenForm: OpenForm; - }; - } -} - -export const OpenFormsScript = ({ targetId, nonce, src }: OpenFormsScriptProps) => { - const ref = useRef(null); - - const onLoadOpenForms = () => { - if (!ref.current) return; - - const target = ref.current.ownerDocument.getElementById(targetId); - if (target && window.OpenForms) { - const form = new window.OpenForms.OpenForm(target, target.dataset); - form.init(); - } - }; - - return ( - <> -
-