feat: add opt-in product usage and feedback integration (#1110)

This commit is contained in:
Paul Nothaft
2026-09-05 12:59:06 +02:00
parent c71ffae912
commit b53e5d97b4
24 changed files with 2360 additions and 18 deletions
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { lazy, Suspense, useState } from 'react';
import { Outlet, Navigate } from 'react-router-dom';
import { useAdminAuth } from '../../contexts';
@@ -11,6 +11,7 @@ import { MigrationBanner } from './MigrationBanner';
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
const ProductUsageNotice = lazy(() => import('./ProductUsageNotice'));
export const AdminLayout: React.FC = () => {
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
@@ -125,6 +126,7 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
(or remove this mount) after operators have had time to update their
docker-compose.yml. See #669. */}
<MigrationBanner />
{!mustChangePassword && <Suspense fallback={null}><ProductUsageNotice /></Suspense>}
{/* Page content - disabled when password change required.
overflow moved up to the column so the scrollbar gutter is
@@ -138,4 +140,4 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
);
};
AdminLayout.displayName = 'AdminLayout';
AdminLayout.displayName = 'AdminLayout';
@@ -0,0 +1,69 @@
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePermissions } from '../../contexts/PermissionsContext';
import { productUsageService } from '../../services/productUsage.service';
// Loaded only inside the authenticated admin tree. Gallery routes never import
// this chunk, make usage requests, or record product usage markers.
export default function ProductUsageNotice() {
const { t } = useTranslation();
const { hasPermission } = usePermissions();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ['productUsage'],
queryFn: productUsageService.status,
enabled: hasPermission('settings.edit')
});
useEffect(() => {
let running = false;
const tick = async () => {
if (document.visibilityState === 'hidden' || running) return;
running = true;
try {
await productUsageService.activity();
} catch {
/* Best-effort delivery; settings show persisted retry state. */
} finally {
running = false;
}
};
void tick();
const timer = window.setInterval(tick, 5 * 60 * 1000);
document.addEventListener('visibilitychange', tick);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', tick);
};
}, []);
if (!hasPermission('settings.edit') || !data || data.status !== 'disabled' || data.notice_dismissed) return null;
return (
<aside
className="mx-6 mt-4 rounded-lg border border-theme p-4 text-theme bg-theme-surface"
aria-label={t('productUsage.title')}
>
<p>{t('productUsage.notice')}</p>
<div className="mt-2 flex flex-wrap gap-4">
<Link className="underline" to="/admin/settings?tab=usage">
{t('productUsage.review')}
</Link>
<button
className="underline"
onClick={async () => {
try {
queryClient.setQueryData(
['productUsage'],
await productUsageService.dismiss()
);
} catch {
/* The notice remains available. */
}
}}
>
{t('productUsage.later')}
</button>
</div>
</aside>
);
}