diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b46b414 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +## ASTRO +ASTRO_HOST=localhost +ASTRO_PORT=12499 + +## PAYLOAD +PAYLOAD_API_URL=https://payload.domain.com/api +PAYLOAD_USER=myemail@domain.com +PAYLOAD_PASSWORD=somepassword123 +WEB_HOOK_TOKEN=webhookd5e6ea45ef4e66eaa151612bdcb599df + +## OPEN EXCHANGE RATE +OER_APP_ID=oerappid5e6ea45ef4e66eaa151612bdcb599df + +## ANALYTICS +ANALYTICS_URL=http://analytics.domain.com \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 5ab9483..d143ec4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -src/shared/* \ No newline at end of file +src/shared/* +!src/shared/analytics \ No newline at end of file diff --git a/TODO.md b/TODO.md index f628987..7aeb880 100644 --- a/TODO.md +++ b/TODO.md @@ -4,19 +4,21 @@ - Number of audio players seems limited (on Chrome and Firefox) - Automatically generate different sizes of images -- Handle relationship in RichText Content +- [RichTextContent] Handle relationship +- [RichTextContent] Add autolink block support ## Mid term -- [RichTextContent] Add autolink block support - Save cookies for longer than just the session -- Support for nameless section +- [Folders] Support for nameless section - [Timeline] Error if collectible not published? - [Timeline] display source language - [Timeline] Add details button in footer with credits + last updated / created - [Videos] see why no video on Firefox and no poster on Chrome https://v3.accords-library.com/en/videos/661b672825d380e548dbb8c8 - [Videos] Display platform info + channel page -- [Timeline] Handle no JS for footers +- [JSLess] Provide JS-less alternative for timeline card footers +- [JSLess] Provide JS-less alternative for parent pages +- [Analytics] Add analytics ## Long term @@ -30,7 +32,6 @@ - The Changelog - Grid view (all files) - Web archives -- Videos - Contact page - About us page - Global search function @@ -40,6 +41,7 @@ - [Images] add images group (which could be named or not) - [Recorders] add list of contributions on recorders' pages - Check if cache and prefetching can be used / should be used +- Detect unused wording keys ## Bonus diff --git a/src/env.d.ts b/src/env.d.ts index 0277a19..e4c4c87 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -6,5 +6,6 @@ declare namespace App { currentLocale: string; currentTheme: "dark" | "auto" | "light"; currentCurrency: string; + notFound: boolean; } } diff --git a/src/middleware.ts b/src/middleware.ts index 314762c..b435982 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,6 +3,7 @@ import { cache } from "src/utils/payload"; import acceptLanguage from "accept-language"; import type { AstroCookies } from "astro"; import { z } from "astro:content"; +import { trackRequest, trackEvent } from "src/shared/analytics/analytics"; import { defaultLocale } from "src/i18n/i18n"; const getAbsoluteLocaleUrl = (locale: string, url: string) => `/${locale}${url}`; @@ -29,12 +30,14 @@ const localeNegotiator = defineMiddleware(({ cookies, url, request }, next) => { if (!currentLocale) { const redirectURL = getAbsoluteLocaleUrl(bestMatchingLocale, url.pathname); + trackEvent("locale-redirect"); return redirect(redirectURL); } if (currentLocale !== bestMatchingLocale) { const pathnameWithoutLocale = url.pathname.substring(currentLocale.length + 1); const redirectURL = getAbsoluteLocaleUrl(bestMatchingLocale, pathnameWithoutLocale); + trackEvent("locale-redirect"); return redirect(redirectURL); } @@ -49,6 +52,7 @@ const handleActionsSearchParams = defineMiddleware(async ({ url }, next) => { ? url.pathname.substring(currentLocale.length + 1) : url.pathname; const redirectURL = getAbsoluteLocaleUrl(actionLang, pathnameWithoutLocale); + trackEvent("action-lang"); return redirect(redirectURL, { "Set-Cookie": `${CookieKeys.Languages}=${JSON.stringify([actionLang])}; Path=/`, }); @@ -56,6 +60,7 @@ const handleActionsSearchParams = defineMiddleware(async ({ url }, next) => { const actionCurrency = url.searchParams.get("action-currency"); if (isValidCurrency(actionCurrency)) { + trackEvent("action-currency"); return redirect(url.pathname, { "Set-Cookie": `${CookieKeys.Currency}=${JSON.stringify(actionCurrency)}; Path=/`, }); @@ -64,7 +69,7 @@ const handleActionsSearchParams = defineMiddleware(async ({ url }, next) => { const actionTheme = url.searchParams.get("action-theme"); const verifiedActionTheme = themeSchema.safeParse(actionTheme); if (verifiedActionTheme.success) { - url.searchParams.delete("action-theme"); + trackEvent("action-theme"); if (verifiedActionTheme.data === "auto") { return redirect(url.pathname, { "Set-Cookie": `${CookieKeys.Theme}=; Path=/; Expires=${new Date(0).toUTCString()}`, @@ -95,18 +100,26 @@ const provideLocalsToRequest = defineMiddleware(async ({ url, locals, cookies }, return next(); }); +const analytics = defineMiddleware(async (context, next) => { + const { request, params, locals, clientAddress } = context; + const response = await next(); + trackRequest(request, { params, locals, clientAddress }); + return response; +}); + export const onRequest = sequence( addContentLanguageResponseHeader, handleActionsSearchParams, localeNegotiator, - provideLocalsToRequest + provideLocalsToRequest, + analytics ); /* LOCALE */ const getCurrentLocale = (pathname: string): string | undefined => { for (const locale of cache.locales) { - if (pathname.startsWith(`/${locale.id}`)) { + if (pathname.split("/")[1] === locale.id) { return locale.id; } } diff --git a/src/pages/404.astro b/src/pages/404.astro index 300c0e2..4e98923 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -1,6 +1,8 @@ --- import AppLayout from "components/AppLayout/AppLayout.astro"; import AppLayoutTitle from "components/AppLayout/components/AppLayoutTitle.astro"; + +Astro.locals.notFound = true; --- {/* ------------------------------------------- HTML ------------------------------------------- */} diff --git a/src/shared/analytics/analytics.ts b/src/shared/analytics/analytics.ts new file mode 100644 index 0000000..47d85d8 --- /dev/null +++ b/src/shared/analytics/analytics.ts @@ -0,0 +1,71 @@ +import { cache } from "src/utils/payload"; + +const getUnlocalizedPathname = (pathname: string): string => { + for (const locale of cache.locales) { + if (pathname.startsWith(`/${locale.id}`)) { + return pathname.substring(`/${locale.id}`.length) || "/"; + } + } + return pathname; +}; + +type TrackRequestParams = { + params: Record; + locals: App.Locals; + clientAddress: string; +}; + +export const trackRequest = (request: Request, { clientAddress, locals }: TrackRequestParams) => { + const userAgent = request.headers.get("User-Agent"); + const acceptLanguage = request.headers.get("Accept-Language"); + const { method, url: stringUrl, referrer } = request; + const url = new URL(stringUrl); + + const body: AnalyticsBody = { + type: "request", + timestamp: Date.now(), + payload: { + user: { + address: clientAddress, + attributes: { + locale: locals.currentLocale, + currency: locals.currentCurrency, + theme: locals.currentTheme, + }, + }, + request: { + method, + pathname: getUnlocalizedPathname(url.pathname), + referrer, + ...(acceptLanguage ? { acceptLanguage } : {}), + ...(userAgent ? { userAgent } : {}), + }, + response: { + status: locals.notFound ? 404 : 200, + }, + }, + }; + track(body); +}; + +export const trackEvent = (eventName: string) => { + const body: AnalyticsBody = { type: "event", timestamp: Date.now(), eventName }; + track(body); +}; + +type AnalyticsBody = Record & { + type: "event" | "request"; + timestamp: number; +}; + +const track = async (body: AnalyticsBody) => { + try { + await fetch(import.meta.env.ANALYTICS_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } catch (e) { + console.warn("Couldn't send analytics", e); + } +};