diff --git a/docs/env.md b/docs/env.md index 3e92bc53..a9f0fa53 100644 --- a/docs/env.md +++ b/docs/env.md @@ -200,6 +200,8 @@ The application uses a new environment file structure with `.defaults` and `.loc | Variable | Default | Description | Required | |----------|---------|-------------|----------| | `POSTHOG_KEY` | None | PostHog analytics key | Optional | +| `POSTHOG_HOST` | `https://eu.i.posthog.com` | PostHog analytics host url | Optional | +| `POSTHOG_SURVEY_ID` | None | PostHog survey id to get feedback from users | Optional | ### Logging @@ -303,4 +305,4 @@ For production deployments, ensure: 2. Secrets are managed through secure secret management systems 3. HTTPS is enforced for all external communications 4. Database connections use SSL/TLS -5. File storage uses appropriate access controls \ No newline at end of file +5. File storage uses appropriate access controls diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index ef03f924..eaa40454 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -158,6 +158,11 @@ "nullable": true, "readOnly": true }, + "POSTHOG_SURVEY_ID": { + "type": "string", + "nullable": true, + "readOnly": true + }, "LANGUAGES": { "type": "array", "items": { @@ -174,7 +179,7 @@ "ENVIRONMENT", "POSTHOG_KEY", "POSTHOG_HOST", - "LANGUAGES", + "POSTHOG_SURVEY_IDLANGUAGES", "LANGUAGE_CODE" ] } diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index d2f1ddd0..33d8d583 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -3,8 +3,8 @@ from django.conf import settings import rest_framework as drf +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.permissions import AllowAny -from drf_spectacular.utils import extend_schema, OpenApiResponse class ConfigView(drf.views.APIView): @@ -20,19 +20,37 @@ class ConfigView(drf.views.APIView): response={ "type": "object", "properties": { - "ENVIRONMENT": { "type": "string", "readOnly": True }, - "POSTHOG_KEY": { "type": "string", "nullable": True, "readOnly": True }, - "POSTHOG_HOST": { "type": "string", "nullable": True, "readOnly": True }, - "LANGUAGES": { "type": "array", "items": { "type": "string" }, "readOnly": True }, - "LANGUAGE_CODE": { "type": "string", "readOnly": True }, + "ENVIRONMENT": {"type": "string", "readOnly": True}, + "POSTHOG_KEY": { + "type": "string", + "nullable": True, + "readOnly": True, + }, + "POSTHOG_HOST": { + "type": "string", + "nullable": True, + "readOnly": True, + }, + "POSTHOG_SURVEY_ID": { + "type": "string", + "nullable": True, + "readOnly": True, + }, + "LANGUAGES": { + "type": "array", + "items": {"type": "string"}, + "readOnly": True, + }, + "LANGUAGE_CODE": {"type": "string", "readOnly": True}, }, "required": [ "ENVIRONMENT", "POSTHOG_KEY", "POSTHOG_HOST", + "POSTHOG_SURVEY_ID", "LANGUAGES", - "LANGUAGE_CODE" - ] + "LANGUAGE_CODE", + ], }, ) }, @@ -47,8 +65,9 @@ class ConfigView(drf.views.APIView): "ENVIRONMENT", "POSTHOG_KEY", "POSTHOG_HOST", + "POSTHOG_SURVEY_ID", "LANGUAGES", - "LANGUAGE_CODE" + "LANGUAGE_CODE", ] dict_settings = {} for setting in array_settings: diff --git a/src/backend/core/api/viewsets/draft.py b/src/backend/core/api/viewsets/draft.py index 2c9517a5..8570296a 100644 --- a/src/backend/core/api/viewsets/draft.py +++ b/src/backend/core/api/viewsets/draft.py @@ -253,7 +253,6 @@ class DraftMessageView(APIView): # Update draft body if provided if "draftBody" in request_data: - try: if message.draft_blob: message.draft_blob.delete() diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index 9f059cff..d8cce3eb 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -16,10 +16,11 @@ pytestmark = pytest.mark.django_db @override_settings( - FRONTEND_THEME="test-theme", - MEDIA_BASE_URL="http://testserver/", - POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"}, - SENTRY_DSN="https://sentry.test/123", + POSTHOG_KEY="132456", + POSTHOG_HOST="https://test.i.posthog-test.com", + POSTHOG_SURVEY_ID="7890", + LANGUAGES=[["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]], + LANGUAGE_CODE="en-us", ) @pytest.mark.parametrize("is_authenticated", [False, True]) def test_api_config(is_authenticated): @@ -34,10 +35,9 @@ def test_api_config(is_authenticated): assert response.status_code == HTTP_200_OK assert response.json() == { "ENVIRONMENT": "test", - "FRONTEND_THEME": "test-theme", "LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]], "LANGUAGE_CODE": "en-us", - "MEDIA_BASE_URL": "http://testserver/", - "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"}, - "SENTRY_DSN": "https://sentry.test/123", + "POSTHOG_KEY": "132456", + "POSTHOG_HOST": "https://test.i.posthog-test.com", + "POSTHOG_SURVEY_ID": "7890", } diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index d99e3538..6ec826ea 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -407,6 +407,9 @@ class Base(Configuration): POSTHOG_HOST = values.Value( "https://eu.i.posthog.com", environ_name="POSTHOG_HOST", environ_prefix=None ) + POSTHOG_SURVEY_ID = values.Value( + None, environ_name="POSTHOG_SURVEY_ID", environ_prefix=None + ) # Celery CELERY_BROKER_URL = values.Value( diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 9c742e58..801a1a5d 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -24,6 +24,7 @@ "dompurify": "3.2.6", "i18next": "25.3.0", "next": "15.3.4", + "posthog-js": "1.257.0", "pretty-bytes": "7.0.0", "react": "19.1.0", "react-dom": "19.1.0", @@ -7120,6 +7121,17 @@ "node": ">= 0.6" } }, + "node_modules/core-js": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz", + "integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.5", "license": "MIT", @@ -11942,6 +11954,46 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-js": { + "version": "1.257.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.0.tgz", + "integrity": "sha512-Ujg9RGtWVCu+4tmlRpALSy2ZOZI6JtieSYXIDDdgMWm167KYKvTtbMPHdoBaPWcNu0Km+1hAIBnQFygyn30KhA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "core-js": "^3.38.1", + "fflate": "^0.4.8", + "preact": "^10.19.3", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@rrweb/types": "2.0.0-alpha.17", + "rrweb-snapshot": "2.0.0-alpha.17" + }, + "peerDependenciesMeta": { + "@rrweb/types": { + "optional": true + }, + "rrweb-snapshot": { + "optional": true + } + } + }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, + "node_modules/preact": { + "version": "10.26.9", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz", + "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -15265,6 +15317,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "dev": true, diff --git a/src/frontend/package.json b/src/frontend/package.json index 6f5f7cee..957b5fb4 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -36,6 +36,7 @@ "dompurify": "3.2.6", "i18next": "25.3.0", "next": "15.3.4", + "posthog-js": "1.257.0", "pretty-bytes": "7.0.0", "react": "19.1.0", "react-dom": "19.1.0", @@ -65,7 +66,7 @@ "vitest": "3.2.4" }, "optionalDependencies": { - "@rollup/rollup-linux-x64-musl": "4.44.1", - "@rollup/rollup-linux-x64-gnu": "4.44.1" + "@rollup/rollup-linux-x64-gnu": "4.44.1", + "@rollup/rollup-linux-x64-musl": "4.44.1" } } diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200.ts b/src/frontend/src/features/api/gen/models/config_retrieve200.ts index 6cf9e463..9b174127 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -12,6 +12,8 @@ export type ConfigRetrieve200 = { readonly POSTHOG_KEY: string | null; /** @nullable */ readonly POSTHOG_HOST: string | null; - readonly LANGUAGES: readonly string[]; + /** @nullable */ + readonly POSTHOG_SURVEY_ID?: string | null; + readonly LANGUAGES?: readonly string[]; readonly LANGUAGE_CODE: string; }; diff --git a/src/frontend/src/features/auth/index.tsx b/src/frontend/src/features/auth/index.tsx index 93e4e197..80926d14 100644 --- a/src/frontend/src/features/auth/index.tsx +++ b/src/frontend/src/features/auth/index.tsx @@ -4,8 +4,10 @@ import { getRequestUrl } from "@/features/api/utils"; import { useUsersMeRetrieve } from "@/features/api/gen/users/users"; import { User } from "@/features/api/gen/models/user"; import { Spinner } from "@gouvfr-lasuite/ui-kit"; +import { posthog } from "posthog-js"; export const logout = () => { + posthog.reset() window.location.replace(getRequestUrl("/api/v1.0/logout/")); }; diff --git a/src/frontend/src/features/i18n/translations.json b/src/frontend/src/features/i18n/translations.json index eb7b9621..477e0b6a 100644 --- a/src/frontend/src/features/i18n/translations.json +++ b/src/frontend/src/features/i18n/translations.json @@ -371,6 +371,9 @@ "new_address": "New address" }, "loading": "Loading addresses..." + }, + "posthog": { + "cta": "Give feedback" } } }, @@ -741,6 +744,9 @@ "new_address": "Nouvelle adresse" }, "loading": "Chargement des adresses..." + }, + "posthog": { + "cta": "Faire un retour" } } } diff --git a/src/frontend/src/features/layouts/components/main/header/_index.scss b/src/frontend/src/features/layouts/components/main/header/_index.scss index 2dc365d2..fb3d3dd6 100644 --- a/src/frontend/src/features/layouts/components/main/header/_index.scss +++ b/src/frontend/src/features/layouts/components/main/header/_index.scss @@ -18,7 +18,7 @@ @media screen and (max-width: breakpoint(tablet)) { grid-template-columns: auto 1fr 1fr; grid-template-rows: 1fr 1fr; - grid-template-areas: + grid-template-areas: "toggle left left" "center center center"; padding: 0 var(--c--theme--spacings--base); @@ -65,10 +65,5 @@ &__right { grid-area: right; justify-content: flex-end; - &__language-picker { - @media screen and (max-width: breakpoint(tablet)) { - display: none; - } - } } } diff --git a/src/frontend/src/features/layouts/components/main/header/anonymous.tsx b/src/frontend/src/features/layouts/components/main/header/anonymous.tsx index 72301527..b77c3dbc 100644 --- a/src/frontend/src/features/layouts/components/main/header/anonymous.tsx +++ b/src/frontend/src/features/layouts/components/main/header/anonymous.tsx @@ -1,7 +1,8 @@ -import { HeaderProps } from "@gouvfr-lasuite/ui-kit"; +import { HeaderProps, useResponsive } from "@gouvfr-lasuite/ui-kit"; import { Button } from "@openfun/cunningham-react"; -import { LanguagePicker } from "../language-picker"; import { useTranslation } from "react-i18next"; +import { PostHogSurveyButton } from "@/features/ui/components/feedback-button"; +import { LanguagePicker } from "../language-picker"; export const AnonymousHeader = ({ @@ -10,6 +11,7 @@ export const AnonymousHeader = ({ isPanelOpen, }: HeaderProps) => { const { t } = useTranslation(); + const { isDesktop } = useResponsive(); return (
@@ -26,11 +28,18 @@ export const AnonymousHeader = ({ } />
-
{leftIcon}
+
+ {leftIcon} + { + isDesktop && ( + + ) + } +
-
- -
+ {isDesktop && ( + + )}
); diff --git a/src/frontend/src/features/layouts/components/main/left-panel/index.tsx b/src/frontend/src/features/layouts/components/main/left-panel/index.tsx index 4d2dbb1d..d0df9cfb 100644 --- a/src/frontend/src/features/layouts/components/main/left-panel/index.tsx +++ b/src/frontend/src/features/layouts/components/main/left-panel/index.tsx @@ -2,9 +2,14 @@ import { useResponsive } from "@gouvfr-lasuite/ui-kit"; import { MailboxPanel } from "../../mailbox-panel"; import { useAuth } from "@/features/auth"; import { HeaderRight } from "../header/authenticated"; +import { PostHogSurveyButton } from "@/features/ui/components/feedback-button"; +import { useConfig } from "@/features/providers/config"; +import { usePostHog } from "posthog-js/react"; export const LeftPanel = ({ hasNoMailbox = true }: { hasNoMailbox?: boolean }) => { const { user } = useAuth(); + const posthog = usePostHog(); + const config = useConfig(); const { isTablet } = useResponsive(); if (!isTablet && hasNoMailbox) return null; @@ -19,6 +24,11 @@ export const LeftPanel = ({ hasNoMailbox = true }: { hasNoMailbox?: boolean }) = } + {posthog.__loaded && config.POSTHOG_SURVEY_ID && ( +
+ +
+ )} ) } diff --git a/src/frontend/src/features/providers/config.tsx b/src/frontend/src/features/providers/config.tsx new file mode 100644 index 00000000..e284b4f4 --- /dev/null +++ b/src/frontend/src/features/providers/config.tsx @@ -0,0 +1,36 @@ +import { ConfigRetrieve200, useConfigRetrieve } from "@/features/api/gen"; +import { PropsWithChildren, createContext, useContext, useMemo } from "react"; + +const DEFAULT_CONFIG: ConfigRetrieve200 = { + ENVIRONMENT: "", + POSTHOG_KEY: null, + POSTHOG_HOST: null, + POSTHOG_SURVEY_ID: null, + LANGUAGES: [], + LANGUAGE_CODE: "", +} + +const ConfigContext = createContext(DEFAULT_CONFIG) + +/** + * A global provider in charge of fetching the config at first load + * and sharing it to the app. + */ +export const ConfigProvider = ({ children }: PropsWithChildren) => { + const { data: config } = useConfigRetrieve(); + const configValue = useMemo(() => config?.data ?? DEFAULT_CONFIG, [config]) + + return ( + + {children} + + ) +} + +export const useConfig = () => { + const config = useContext(ConfigContext) + if (!config) { + throw new Error("`useConfig` must be used within a children of `ConfigProvider`.") + } + return config +} diff --git a/src/frontend/src/features/providers/posthog.tsx b/src/frontend/src/features/providers/posthog.tsx new file mode 100644 index 00000000..6536bff7 --- /dev/null +++ b/src/frontend/src/features/providers/posthog.tsx @@ -0,0 +1,28 @@ +import { PostHogProvider as PostHogProviderBase } from "posthog-js/react"; +import { PropsWithChildren } from "react"; +import { useConfig } from './config'; + +/** + * A global provider in charge of initializing PostHog if the config has + * the POSTHOG_KEY and POSTHOG_HOST set. + */ +export const PostHogProvider = ({ children, }: PropsWithChildren) => { + const config = useConfig(); + + if (!config?.POSTHOG_KEY || !config?.POSTHOG_HOST) { + return children; + } + + return ( + + {children} + + ); +}; diff --git a/src/frontend/src/features/ui/components/feedback-button/_index.scss b/src/frontend/src/features/ui/components/feedback-button/_index.scss new file mode 100644 index 00000000..672dd679 --- /dev/null +++ b/src/frontend/src/features/ui/components/feedback-button/_index.scss @@ -0,0 +1,15 @@ +.feedback-button.c__button { + white-space: nowrap; + color: var(--c--theme--colors--info-600); + background-color: var(--c--theme--colors--info-100); + + &:hover { + color: var(--c--theme--colors--info-600); + background-color: var(--c--theme--colors--info-200); + } + + &:active { + color: var(--c--theme--colors--info-800); + background-color: var(--c--theme--colors--info-200); + } +} diff --git a/src/frontend/src/features/ui/components/feedback-button/index.tsx b/src/frontend/src/features/ui/components/feedback-button/index.tsx new file mode 100644 index 00000000..fd6c3326 --- /dev/null +++ b/src/frontend/src/features/ui/components/feedback-button/index.tsx @@ -0,0 +1,33 @@ +import { useConfig } from "@/features/providers/config" +import { Icon, IconType } from "@gouvfr-lasuite/ui-kit" +import { Button, ButtonProps } from "@openfun/cunningham-react" +import { usePostHog } from "posthog-js/react" +import { useTranslation } from "react-i18next" + +/** + * A button that opens the PostHog survey modal. + * + * This button is only visible if PostHog is loaded. To work, a survey must be + * created in PostHog with type Feedback button and as CSS Selector you must + * use `#posthog-feedback-survey`. + * + */ +export const PostHogSurveyButton = (props: ButtonProps) => { + const { t } = useTranslation() + const posthog = usePostHog() + const config = useConfig() + + if (!config.POSTHOG_SURVEY_ID || !posthog.__loaded) return null; + + return ( + + ) +} diff --git a/src/frontend/src/pages/_app.tsx b/src/frontend/src/pages/_app.tsx index 0e977404..642b25c4 100644 --- a/src/frontend/src/pages/_app.tsx +++ b/src/frontend/src/pages/_app.tsx @@ -21,6 +21,8 @@ import { errorToString } from "@/features/api/api-error"; import Head from "next/head"; import { useTranslation } from "react-i18next"; import { Auth } from "@/features/auth"; +import { ConfigProvider } from "@/features/providers/config"; +import { PostHogProvider } from "@/features/providers/posthog"; export type NextPageWithLayout

= NextPage & { getLayout?: (page: ReactElement) => ReactNode; @@ -84,11 +86,15 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - {getLayout()} - - + + + + + {getLayout()} + + + + ); diff --git a/src/frontend/src/styles/main.scss b/src/frontend/src/styles/main.scss index 123d481a..a9a71403 100644 --- a/src/frontend/src/styles/main.scss +++ b/src/frontend/src/styles/main.scss @@ -21,6 +21,7 @@ @use "./../features/ui/components/info-row"; @use "./../features/ui/components/bar"; @use "./../features/ui/components/banner"; +@use "./../features/ui/components/feedback-button"; @use "./../features/layouts/components/mailbox-panel"; @use "./../features/layouts/components/mailbox-panel/components/mailbox-actions"; @use "./../features/layouts/components/mailbox-panel/components/mailbox-list";