From 025732a94126c871546c17f9d3ad8954084897f2 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:01:05 +0700 Subject: [PATCH 01/25] feat(api): serve the figures of the dashboard, by role Co-Authored-By: Claude Opus 5.5 --- apps/api/src/admin/stats.ts | 108 +++++++++++++++++++++++++++ apps/api/src/routes/admin.test.ts | 59 ++++++++++++++- apps/api/src/routes/admin.ts | 22 +++++- packages/api-client/src/client.ts | 11 +++ packages/shared/src/schemas/admin.ts | 39 ++++++++++ 5 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/admin/stats.ts diff --git a/apps/api/src/admin/stats.ts b/apps/api/src/admin/stats.ts new file mode 100644 index 00000000..08ceff7f --- /dev/null +++ b/apps/api/src/admin/stats.ts @@ -0,0 +1,108 @@ +import { + adminLateAfterHours, + canOpenAdminSection, + countedAdminSections, + type AdminStats, + type AdminToHandle, + type CountedAdminSection, + type UserRole, +} from '@widoo/shared'; +import { sql } from 'drizzle-orm'; +import type { Db } from '../db/client'; + +type StatsRow = { + pending: number; + pending_late: number; + pending_oldest: Date | null; + published_routes: number; + premium_routes: number; + official_routes: number; + places_in_routes: number; + cities: number; + active_creators: number; + registered: number; + new_users: number; + premium_active: number; +}; + +const day = 24 * 60 * 60 * 1000; +const ago = (now: Date, ms: number) => new Date(now.getTime() - ms).toISOString(); + +/** + * The figures of the dashboard (A-05), read in one query. « À traiter » follows the role, as the + * counters of the navigation do. Deleted accounts + * are left out of every figure about users. + */ +export async function readAdminStats( + db: Db, + role: UserRole, + now = new Date(), +): Promise { + const lateBefore = ago(now, adminLateAfterHours * 60 * 60 * 1000); + const [row] = await db.execute(sql` + select + (select count(*)::int from routes where status = 'pending') as pending, + (select count(*)::int from routes + where status = 'pending' and updated_at < ${lateBefore}::timestamptz) as pending_late, + (select min(updated_at) from routes where status = 'pending') as pending_oldest, + (select count(*)::int from routes where status = 'published') as published_routes, + (select count(*)::int from routes + where status = 'published' and access = 'premium') as premium_routes, + (select count(*)::int from routes where status = 'published' and is_official) + as official_routes, + (select count(distinct s.place_id)::int from steps s) as places_in_routes, + (select count(distinct city_id)::int from routes where status = 'published') as cities, + (select count(distinct author_id)::int from routes + where author_id is not null and updated_at >= ${ago(now, 30 * day)}::timestamptz) + as active_creators, + (select count(*)::int from users where deleted_at is null) as registered, + (select count(*)::int from users + where deleted_at is null and created_at >= ${ago(now, 7 * day)}::timestamptz) as new_users, + (select count(*)::int from users + where deleted_at is null and plan = 'premium' + and (plan_expires_at is null or plan_expires_at > ${now.toISOString()}::timestamptz)) + as premium_active`); + if (!row) { + throw new Error('Dashboard figures not read'); + } + + // What waits in each section. Reports, support messages, AI drafts and failed translations + // have no table yet: nothing can wait there, and each one gets its query with its section. + const nothing: AdminToHandle = { count: 0, lateCount: 0, oldestSince: null }; + const waiting: Record = { + moderation: { + count: row.pending, + lateCount: row.pending_late, + oldestSince: row.pending_oldest ? new Date(row.pending_oldest).toISOString() : null, + }, + reports: nothing, + support: nothing, + aiDrafts: nothing, + translations: nothing, + }; + const toHandle: AdminStats['toHandle'] = {}; + for (const section of countedAdminSections) { + if (canOpenAdminSection(role, section)) { + toHandle[section] = waiting[section]; + } + } + return { + toHandle, + catalog: { + publishedRoutes: row.published_routes, + premiumRoutes: row.premium_routes, + officialRoutes: row.official_routes, + placesInRoutes: row.places_in_routes, + cities: row.cities, + activeCreators: row.active_creators, + }, + users: { + registered: row.registered, + newLast7Days: row.new_users, + premiumActive: row.premium_active, + // No unit unlock exists yet (`route_unlocks`): none happened. + unlocksLast30Days: 0, + }, + generatedAt: now.toISOString(), + }; +} diff --git a/apps/api/src/routes/admin.test.ts b/apps/api/src/routes/admin.test.ts index dbf0d44e..6cb59dd8 100644 --- a/apps/api/src/routes/admin.test.ts +++ b/apps/api/src/routes/admin.test.ts @@ -1,4 +1,4 @@ -import { AdminCounters, ApiError, type UserRole } from '@widoo/shared'; +import { AdminCounters, AdminStats, ApiError, type UserRole } from '@widoo/shared'; import { eq, inArray } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; @@ -135,3 +135,60 @@ describe('GET /v1/admin/counters', () => { expect(AdminCounters.parse(response.json())).toEqual({}); }); }); + +describe('GET /v1/admin/stats', () => { + const getStats = (headers: Record) => + app.inject({ url: '/v1/admin/stats', headers }); + + it('refuses an account outside the team', async () => { + expect((await getStats(await signIn('user'))).statusCode).toBe(403); + }); + + it('gives a moderator what waits, with the oldest route of the queue', async () => { + const headers = await signIn('moderator'); + await addPendingRoute(90); + + const stats = AdminStats.parse((await getStats(headers)).json()); + expect(Object.keys(stats.toHandle).sort()).toEqual([ + 'moderation', + 'reports', + 'support', + 'translations', + ]); + const moderation = stats.toHandle.moderation; + expect(moderation?.count).toBeGreaterThanOrEqual(1); + expect(moderation?.lateCount).toBeGreaterThanOrEqual(1); + const oldest = new Date(moderation?.oldestSince ?? '').getTime(); + expect(Date.now() - oldest).toBeGreaterThanOrEqual(90 * hour - 60_000); + }); + + it('gives an editor the catalog, the users and the tiles of its sections only', async () => { + const response = await getStats(await signIn('editor')); + expect(response.headers['cache-control']).toBe('no-store'); + const stats = AdminStats.parse(response.json()); + const nothing = { count: 0, lateCount: 0, oldestSince: null }; + expect(stats.toHandle).toEqual({ aiDrafts: nothing, translations: nothing }); + expect(stats.catalog.publishedRoutes).toBeGreaterThanOrEqual(1); + expect(stats.catalog.premiumRoutes).toBeLessThanOrEqual(stats.catalog.publishedRoutes); + expect(stats.catalog.placesInRoutes).toBeGreaterThanOrEqual(1); + expect(stats.catalog.cities).toBeGreaterThanOrEqual(1); + expect(stats.users.registered).toBeGreaterThanOrEqual(1); + expect(stats.users.newLast7Days).toBeGreaterThanOrEqual(1); + expect(stats.users.unlocksLast30Days).toBe(0); + }); + + it('counts a new account once, and a Premium plan only while it runs', async () => { + const headers = await signIn('admin'); + const before = AdminStats.parse((await getStats(headers)).json()).users; + const day = 24 * hour; + for (const planExpiresAt of [new Date(Date.now() + day), new Date(Date.now() - day)]) { + const { uid } = verifier.issue(); + createdUids.push(uid); + await app.db.insert(users).values({ firebaseUid: uid, plan: 'premium', planExpiresAt }); + } + + const after = AdminStats.parse((await getStats(headers)).json()).users; + expect(after.registered - before.registered).toBeGreaterThanOrEqual(2); + expect(after.premiumActive - before.premiumActive).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index d42a4ef1..d5c65b4c 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,6 +1,7 @@ -import { AdminCounters } from '@widoo/shared'; +import { AdminCounters, AdminStats } from '@widoo/shared'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { readAdminCounters } from '../admin/counters'; +import { readAdminStats } from '../admin/stats'; import { httpError } from '../errors'; const security = [{ firebaseIdToken: [] }]; @@ -33,4 +34,23 @@ export const adminRoutes: FastifyPluginAsyncZod = async (app) => { return readAdminCounters(app.db, request.user.role); }, ); + + app.get( + '/admin/stats', + { + onRequest: app.requireAdminSection('dashboard'), + schema: { + tags: ['admin'], + summary: 'Figures of the dashboard', + security, + response: { 200: AdminStats }, + }, + }, + async (request) => { + if (!request.user) { + throw httpError(401, 'Missing bearer token'); + } + return readAdminStats(app.db, request.user.role); + }, + ); }; diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index c26f192c..a62114cd 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -3,6 +3,7 @@ import { ModerationQueue, ModerationDecision, AdminCounters, + AdminStats, AppConfig, GeocodeResult, LegalDocument, @@ -243,6 +244,16 @@ export function createApiClient(options: ApiClientOptions) { signal, }), + /** `GET /v1/admin/stats`: the figures of the dashboard (A-05), for the role of the caller. */ + getAdminStats: (signal?: AbortSignal) => + request({ + method: 'GET', + path: '/v1/admin/stats', + schema: AdminStats, + auth: true, + signal, + }), + /** `GET /v1/admin/moderation`: a page of the moderation queue (A-02). Moderators and admins. */ getModerationQueue: (params: ModerationQueueParams, signal?: AbortSignal) => request({ diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 7c6f5b3d..a9cdc4c7 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -18,3 +18,42 @@ export type AdminCounter = z.infer; */ export const AdminCounters = z.partialRecord(z.enum(countedAdminSections), AdminCounter); export type AdminCounters = z.infer; + +const count = z.number().int().nonnegative(); + +/** A tile « à traiter » of the dashboard: a counter, and since when its oldest item waits. */ +export const AdminToHandle = AdminCounter.extend({ + /** Null when nothing waits. */ + oldestSince: z.iso.datetime().nullable(), +}); +export type AdminToHandle = z.infer; + +/** + * Body of `GET /admin/stats`, the figures of the dashboard (A-05). `toHandle` holds the sections + * the role of the caller opens and the API counts; the other figures are the same for the team. + */ +export const AdminStats = z.object({ + toHandle: z.partialRecord(z.enum(countedAdminSections), AdminToHandle), + catalog: z.object({ + publishedRoutes: count, + /** Among the published routes. */ + premiumRoutes: count, + officialRoutes: count, + /** Places that at least one route goes through. */ + placesInRoutes: count, + /** Cities with at least one published route. */ + cities: count, + /** Authors who created or changed a route in the last 30 days. */ + activeCreators: count, + }), + users: z.object({ + registered: count, + newLast7Days: count, + /** Accounts whose Premium plan runs now, subscription or pass. */ + premiumActive: count, + /** Always 0 until unit unlocks exist (`route_unlocks`). */ + unlocksLast30Days: count, + }), + generatedAt: z.iso.datetime(), +}); +export type AdminStats = z.infer; From 4c58c56ab436b92fc90433d8001cf79b8fc8baa7 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:01:05 +0700 Subject: [PATCH 02/25] feat(admin): show the dashboard with what waits, the catalog and the users (A-05) Co-Authored-By: Claude Opus 5.5 --- apps/admin/.env.example | 5 + apps/admin/src/app/(shell)/page.tsx | 9 +- .../components/dashboard/DashboardSection.tsx | 21 +++ .../dashboard/ExternalDashboardLink.tsx | 16 +++ .../src/components/dashboard/FigureBand.tsx | 26 ++++ .../components/dashboard/RefreshStatus.tsx | 28 ++++ .../src/components/dashboard/ToHandleTile.tsx | 49 +++++++ .../components/dashboard/ToHandleTiles.tsx | 51 ++++++++ apps/admin/src/config/env.test.ts | 10 ++ apps/admin/src/config/env.ts | 9 ++ apps/admin/src/config/external-dashboards.ts | 11 ++ apps/admin/src/config/public-env.ts | 2 + .../src/containers/DashboardContainer.tsx | 121 ++++++++++++++++++ apps/admin/src/hooks/useMinuteTick.ts | 10 ++ apps/admin/src/lib/numbers.test.ts | 10 ++ apps/admin/src/lib/numbers.ts | 6 + apps/admin/src/messages/fr.ts | 41 ++++++ packages/ui/tailwind-preset.cjs | 2 + 18 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 apps/admin/src/components/dashboard/DashboardSection.tsx create mode 100644 apps/admin/src/components/dashboard/ExternalDashboardLink.tsx create mode 100644 apps/admin/src/components/dashboard/FigureBand.tsx create mode 100644 apps/admin/src/components/dashboard/RefreshStatus.tsx create mode 100644 apps/admin/src/components/dashboard/ToHandleTile.tsx create mode 100644 apps/admin/src/components/dashboard/ToHandleTiles.tsx create mode 100644 apps/admin/src/config/external-dashboards.ts create mode 100644 apps/admin/src/containers/DashboardContainer.tsx create mode 100644 apps/admin/src/hooks/useMinuteTick.ts create mode 100644 apps/admin/src/lib/numbers.test.ts create mode 100644 apps/admin/src/lib/numbers.ts diff --git a/apps/admin/.env.example b/apps/admin/.env.example index 52615e47..dee2a02a 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -17,3 +17,8 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=demo-widoo # FIREBASE_AUTH_EMULATOR_HOST of the API. Accepted with a demo-* project only; empty on staging # and production. NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL=http://127.0.0.1:9099 + +# Dashboards the admin links to from its home page (A-05), https: the exact page of the Widoo +# project. Empty: the home page of PostHog Cloud EU and of RevenueCat. +NEXT_PUBLIC_POSTHOG_URL= +NEXT_PUBLIC_REVENUECAT_URL= diff --git a/apps/admin/src/app/(shell)/page.tsx b/apps/admin/src/app/(shell)/page.tsx index 890f7b5a..2bf9f7e4 100644 --- a/apps/admin/src/app/(shell)/page.tsx +++ b/apps/admin/src/app/(shell)/page.tsx @@ -1,7 +1,6 @@ -/** - * A-05, the dashboard: its tiles and figures come with the ticket of the dashboard (#55). The - * shell around it already shows its title. - */ +import { DashboardContainer } from '@/containers/DashboardContainer'; + +/** A-05, the dashboard: the home page of the admin. */ export default function DashboardPage() { - return null; + return ; } diff --git a/apps/admin/src/components/dashboard/DashboardSection.tsx b/apps/admin/src/components/dashboard/DashboardSection.tsx new file mode 100644 index 00000000..33901ccb --- /dev/null +++ b/apps/admin/src/components/dashboard/DashboardSection.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +type DashboardSectionProps = { + title: string; + /** On the right of the title: when the figures were read, or a link to another dashboard. */ + aside?: ReactNode; + children: ReactNode; +}; + +/** A block of the dashboard: its title, what goes on its right, then its figures. */ +export function DashboardSection({ title, aside, children }: DashboardSectionProps) { + return ( +
+
+

{title}

+ {aside} +
+ {children} +
+ ); +} diff --git a/apps/admin/src/components/dashboard/ExternalDashboardLink.tsx b/apps/admin/src/components/dashboard/ExternalDashboardLink.tsx new file mode 100644 index 00000000..3bc09ade --- /dev/null +++ b/apps/admin/src/components/dashboard/ExternalDashboardLink.tsx @@ -0,0 +1,16 @@ +import { ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr'; + +/** Link to a dashboard kept outside the admin (PostHog, RevenueCat), in a new tab. */ +export function ExternalDashboardLink({ href, label }: { href: string; label: string }) { + return ( + + {label} + + + ); +} diff --git a/apps/admin/src/components/dashboard/FigureBand.tsx b/apps/admin/src/components/dashboard/FigureBand.tsx new file mode 100644 index 00000000..e5a0212a --- /dev/null +++ b/apps/admin/src/components/dashboard/FigureBand.tsx @@ -0,0 +1,26 @@ +import { formatCount } from '@/lib/numbers'; + +export type Figure = { + label: string; + value: number; + /** What the figure counts, under it: « sur les 30 derniers jours ». */ + detail: string; +}; + +/** Figures side by side on warm gray, separated by a line: figures alone, no chart (A-05). */ +export function FigureBand({ figures }: { figures: Figure[] }) { + return ( +
+ {figures.map((figure) => ( +
+
{figure.label}
+
{formatCount(figure.value)}
+
{figure.detail}
+
+ ))} +
+ ); +} diff --git a/apps/admin/src/components/dashboard/RefreshStatus.tsx b/apps/admin/src/components/dashboard/RefreshStatus.tsx new file mode 100644 index 00000000..5bc368e8 --- /dev/null +++ b/apps/admin/src/components/dashboard/RefreshStatus.tsx @@ -0,0 +1,28 @@ +import { useMinuteTick } from '@/hooks/useMinuteTick'; +import { formatRelativeDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import { ArrowClockwiseIcon } from '@phosphor-icons/react/dist/ssr'; + +type RefreshStatusProps = { + /** When the figures shown were read. */ + readAt: Date; + onRefresh: () => void; +}; + +/** « Mis à jour il y a 2 min » and the button that reads the figures again. */ +export function RefreshStatus({ readAt, onRefresh }: RefreshStatusProps) { + useMinuteTick(); + return ( + + {messages.dashboard.updated(formatRelativeDate(readAt))} + + + ); +} diff --git a/apps/admin/src/components/dashboard/ToHandleTile.tsx b/apps/admin/src/components/dashboard/ToHandleTile.tsx new file mode 100644 index 00000000..ce5c21cd --- /dev/null +++ b/apps/admin/src/components/dashboard/ToHandleTile.tsx @@ -0,0 +1,49 @@ +import { formatCount } from '@/lib/numbers'; +import { ArrowRightIcon, WarningCircleIcon } from '@phosphor-icons/react/dist/ssr'; +import type { Icon } from '@phosphor-icons/react'; +import { cn } from '@widoo/ui'; +import Link from 'next/link'; + +type ToHandleTileProps = { + /** The list the tile opens, already filtered on what waits. */ + href: string; + icon: Icon; + label: string; + count: number; + /** Line under the figure: « Le plus ancien attend depuis 3 j 4 h ». */ + detail: string; + /** Something has been waiting for more than 72 hours: the detail turns red, with an icon. */ + isLate: boolean; +}; + +/** A tile « à traiter » of the dashboard, a link to its list as a whole. */ +export function ToHandleTile({ + href, + icon: IconOf, + label, + count, + detail, + isLate, +}: ToHandleTileProps) { + return ( + + + + + + {label} + + + {formatCount(count)} + + {isLate ? : null} + {detail} + + + ); +} diff --git a/apps/admin/src/components/dashboard/ToHandleTiles.tsx b/apps/admin/src/components/dashboard/ToHandleTiles.tsx new file mode 100644 index 00000000..52adcb54 --- /dev/null +++ b/apps/admin/src/components/dashboard/ToHandleTiles.tsx @@ -0,0 +1,51 @@ +import { sectionIcons } from '@/components/shell/section-icons'; +import { formatWaitingTime } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import { countedAdminSections, type AdminStats, type CountedAdminSection } from '@widoo/shared'; +import { ToHandleTile } from './ToHandleTile'; + +const texts = messages.dashboard.toHandle; + +type ToHandleTilesProps = { + /** What waits in each section the role opens; a section the role does not open is absent. */ + toHandle: AdminStats['toHandle']; + /** The list each tile opens, already filtered on what waits. */ + hrefs: Record; +}; + +/** + * The tiles « à traiter » of the dashboard, in the order of the navigation: one per section of + * the role, with its count, and since when its oldest item waits. + */ +export function ToHandleTiles({ toHandle, hrefs }: ToHandleTilesProps) { + const sections = countedAdminSections.filter((section) => toHandle[section] !== undefined); + if (sections.length === 0) { + return

{texts.nothing}

; + } + return ( +
    + {sections.map((section) => { + const waiting = toHandle[section]; + if (!waiting) { + return null; + } + return ( +
  • + 0} + /> +
  • + ); + })} +
+ ); +} diff --git a/apps/admin/src/config/env.test.ts b/apps/admin/src/config/env.test.ts index b3441f60..c407d2e6 100644 --- a/apps/admin/src/config/env.test.ts +++ b/apps/admin/src/config/env.test.ts @@ -13,6 +13,8 @@ describe('parseEnv', () => { expect(parseEnv(valid)).toEqual({ ...valid, NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: undefined, + NEXT_PUBLIC_POSTHOG_URL: undefined, + NEXT_PUBLIC_REVENUECAT_URL: undefined, }); }); @@ -55,4 +57,12 @@ describe('parseEnv', () => { }), ).toThrow(/demo-\* project/); }); + + it('reads the dashboards the admin links to, https only', () => { + const env = parseEnv({ ...valid, NEXT_PUBLIC_POSTHOG_URL: 'https://eu.posthog.com/project/1' }); + expect(env.NEXT_PUBLIC_POSTHOG_URL).toBe('https://eu.posthog.com/project/1'); + expect(() => + parseEnv({ ...valid, NEXT_PUBLIC_REVENUECAT_URL: 'http://plain.example' }), + ).toThrow(/NEXT_PUBLIC_REVENUECAT_URL/); + }); }); diff --git a/apps/admin/src/config/env.ts b/apps/admin/src/config/env.ts index b0006793..82e62a81 100644 --- a/apps/admin/src/config/env.ts +++ b/apps/admin/src/config/env.ts @@ -10,6 +10,12 @@ const Origin = z /** A host name, without scheme or path: `project.firebaseapp.com`. */ const Host = z.string().regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/); +const OptionalHttpsUrl = z + .string() + .optional() + .transform((value) => value || undefined) + .pipe(z.url({ protocol: /^https$/ }).optional()); + /** * Environment variables of the admin, validated once. See `.env.example`. Every value is public: * Next.js writes the `NEXT_PUBLIC_` variables in the pages at build time. @@ -24,6 +30,9 @@ const Env = z.object({ NEXT_PUBLIC_FIREBASE_API_KEY: z.string().min(1), NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: Host, NEXT_PUBLIC_FIREBASE_PROJECT_ID: z.string().regex(/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/), + /** Exact pages of the Widoo project in PostHog and RevenueCat, linked from A-05. */ + NEXT_PUBLIC_POSTHOG_URL: OptionalHttpsUrl, + NEXT_PUBLIC_REVENUECAT_URL: OptionalHttpsUrl, /** Local Auth emulator; empty on staging and production. */ NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: z .string() diff --git a/apps/admin/src/config/external-dashboards.ts b/apps/admin/src/config/external-dashboards.ts new file mode 100644 index 00000000..7dee5cf3 --- /dev/null +++ b/apps/admin/src/config/external-dashboards.ts @@ -0,0 +1,11 @@ +import { publicEnv } from './public-env'; + +/** + * Dashboards kept outside the admin, linked from A-05. By default their home page, which opens + * the project of the person once signed in: PostHog Cloud EU (wiki Securite-et-RGPD) and + * RevenueCat. The environment can name the exact page of the Widoo project instead. + */ +export const externalDashboards = { + posthog: publicEnv.NEXT_PUBLIC_POSTHOG_URL ?? 'https://eu.posthog.com', + revenueCat: publicEnv.NEXT_PUBLIC_REVENUECAT_URL ?? 'https://app.revenuecat.com', +}; diff --git a/apps/admin/src/config/public-env.ts b/apps/admin/src/config/public-env.ts index c1a2b3bc..0e6836b5 100644 --- a/apps/admin/src/config/public-env.ts +++ b/apps/admin/src/config/public-env.ts @@ -10,4 +10,6 @@ export const publicEnv = parseEnv({ NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL, + NEXT_PUBLIC_POSTHOG_URL: process.env.NEXT_PUBLIC_POSTHOG_URL, + NEXT_PUBLIC_REVENUECAT_URL: process.env.NEXT_PUBLIC_REVENUECAT_URL, }); diff --git a/apps/admin/src/containers/DashboardContainer.tsx b/apps/admin/src/containers/DashboardContainer.tsx new file mode 100644 index 00000000..88c168f2 --- /dev/null +++ b/apps/admin/src/containers/DashboardContainer.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { DashboardSection } from '@/components/dashboard/DashboardSection'; +import { ExternalDashboardLink } from '@/components/dashboard/ExternalDashboardLink'; +import { FigureBand } from '@/components/dashboard/FigureBand'; +import { RefreshStatus } from '@/components/dashboard/RefreshStatus'; +import { ToHandleTiles } from '@/components/dashboard/ToHandleTiles'; +import { PageMessage } from '@/components/states/PageMessage'; +import { sectionPaths } from '@/config/navigation'; +import { externalDashboards } from '@/config/external-dashboards'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { getSessionClients } from '@/lib/auth/clients'; +import { messages } from '@/messages/fr'; +import type { CountedAdminSection } from '@widoo/shared'; +import { Button } from '@widoo/ui'; + +const texts = messages.dashboard; + +/** The list each tile « à traiter » opens; the moderation queue filtered on what waits. */ +const toHandlePaths: Record = { + moderation: `${sectionPaths.moderation}?statut=relecture`, + reports: sectionPaths.reports, + support: sectionPaths.support, + aiDrafts: sectionPaths.aiDrafts, + translations: sectionPaths.translations, +}; + +/** + * A-05, the dashboard: what waits for the role of the member, then the figures of the catalog + * and of the users. Figures alone; the analyses stay in PostHog and RevenueCat. + */ +export function DashboardContainer() { + const stats = useRemoteData('stats', (signal) => getSessionClients().api.getAdminStats(signal)); + + if (stats.status === 'loading') { + return ; + } + if (stats.status === 'error') { + return ( + {messages.list.retry}} + /> + ); + } + + const { toHandle, catalog, users, generatedAt } = stats.data; + return ( +
+ } + > + + + + } + > + + + + } + > + + +
+ ); +} diff --git a/apps/admin/src/hooks/useMinuteTick.ts b/apps/admin/src/hooks/useMinuteTick.ts new file mode 100644 index 00000000..a697e4d3 --- /dev/null +++ b/apps/admin/src/hooks/useMinuteTick.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from 'react'; + +/** Renders the component again every minute, for what is written relative to now. */ +export function useMinuteTick(): void { + const [, setTick] = useState(0); + useEffect(() => { + const timer = window.setInterval(() => setTick((tick) => tick + 1), 60_000); + return () => window.clearInterval(timer); + }, []); +} diff --git a/apps/admin/src/lib/numbers.test.ts b/apps/admin/src/lib/numbers.test.ts new file mode 100644 index 00000000..7c340749 --- /dev/null +++ b/apps/admin/src/lib/numbers.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { formatCount } from './numbers'; + +describe('formatCount', () => { + it('groups the thousands as French does', () => { + // Narrow no-break space (U+202F) between the groups. + expect(formatCount(2140)).toBe('2\u202f140'); + expect(formatCount(86)).toBe('86'); + }); +}); diff --git a/apps/admin/src/lib/numbers.ts b/apps/admin/src/lib/numbers.ts new file mode 100644 index 00000000..0868914a --- /dev/null +++ b/apps/admin/src/lib/numbers.ts @@ -0,0 +1,6 @@ +const frenchNumber = new Intl.NumberFormat('fr-FR'); + +/** A count as the admin writes it, with the French thousands separator: « 2 140 ». */ +export function formatCount(value: number): string { + return frenchNumber.format(value); +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index 79a33103..830d8fd8 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -174,6 +174,47 @@ export const messages = { error: "La liste n'a pas pu être chargée.", retry: 'Réessayer', }, + dashboard: { + error: "Le tableau de bord n'a pas pu être chargé.", + updated: (when: string) => `Mis à jour ${when}`, + refresh: 'Actualiser', + toHandle: { + title: 'À traiter', + nothing: 'Rien à traiter pour le moment.', + oldest: (time: string) => `Le plus ancien attend depuis ${time}`, + tiles: { + moderation: { label: 'Parcours à relire', none: 'Aucun parcours en attente' }, + reports: { label: 'Signalements ouverts', none: 'Aucun signalement ouvert' }, + support: { label: 'Messages de support', none: 'Aucun message à traiter' }, + aiDrafts: { label: 'Brouillons IA à relire', none: 'Aucun brouillon à relire' }, + translations: { label: 'Traductions en échec', none: 'Aucune traduction en échec' }, + }, + }, + catalog: { + title: 'Catalogue', + link: 'Analyses dans PostHog', + publishedRoutes: 'Parcours publiés', + publishedDetail: (premium: number, official: number) => + `dont ${premium} Premium et ${official} de Widoo`, + places: 'Lieux', + placesDetail: 'utilisés par les parcours', + cities: 'Villes', + citiesDetail: 'où des parcours existent', + creators: 'Créateurs actifs', + last30Days: 'sur les 30 derniers jours', + }, + users: { + title: 'Utilisateurs', + link: 'Abonnements dans RevenueCat', + registered: 'Inscrits', + registeredDetail: 'depuis le lancement', + newUsers: 'Nouveaux', + newUsersDetail: 'sur les 7 derniers jours', + premium: 'Premium actifs', + premiumDetail: 'abonnements et pass', + unlocks: 'Déblocages', + }, + }, moderation: { search: 'Rechercher un titre ou un auteur', total: (count: number) => `${count} parcours`, diff --git a/packages/ui/tailwind-preset.cjs b/packages/ui/tailwind-preset.cjs index 5b69bd35..19c1f6ac 100644 --- a/packages/ui/tailwind-preset.cjs +++ b/packages/ui/tailwind-preset.cjs @@ -99,6 +99,8 @@ module.exports = { // Text of a button of 36 px and of a navigation entry: `body-s` in 700. fontSize: { control: ['14px', { lineHeight: '20px', fontWeight: '700' }], + // Figure of the dashboard: 30/36 in 800. + figure: ['30px', { lineHeight: '36px', fontWeight: '800', letterSpacing: '-0.6px' }], // Header of a table column and text of a badge: 12/16 in 600. 'column-head': ['12px', { lineHeight: '16px', fontWeight: '600' }], }, From 780c52bf8568ac8b3eb483fc19de47ae6bc42236 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:50:12 +0700 Subject: [PATCH 03/25] fix(api): name who decided in the history of a route, even without a first name Co-Authored-By: Claude Opus 5.5 --- .../src/components/moderation/sheet/HistoryCard.tsx | 2 +- apps/admin/src/containers/ModerationSheetContainer.tsx | 2 +- apps/api/src/moderation/detail.ts | 6 +++--- apps/api/src/routes/admin-moderation.test.ts | 10 ++++++++-- packages/shared/src/schemas/moderation.ts | 7 +++++-- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/admin/src/components/moderation/sheet/HistoryCard.tsx b/apps/admin/src/components/moderation/sheet/HistoryCard.tsx index bbd65d66..9064c154 100644 --- a/apps/admin/src/components/moderation/sheet/HistoryCard.tsx +++ b/apps/admin/src/components/moderation/sheet/HistoryCard.tsx @@ -16,7 +16,7 @@ export function HistoryCard({ history }: { history: ModerationHistoryEntry[] }) {history.map((entry) => (
  • - {texts.by(texts.actions[entry.action], entry.actorFirstName)} + {texts.by(texts.actions[entry.action], entry.actorName)} {formatRelativeDate(new Date(entry.createdAt))} diff --git a/apps/admin/src/containers/ModerationSheetContainer.tsx b/apps/admin/src/containers/ModerationSheetContainer.tsx index f558cd56..4ebf8e67 100644 --- a/apps/admin/src/containers/ModerationSheetContainer.tsx +++ b/apps/admin/src/containers/ModerationSheetContainer.tsx @@ -106,7 +106,7 @@ export function ModerationSheetContainer({ routeId }: { routeId: string }) {