fix(usage): reformat the consent modal so the disclosure can be read
It was seven anonymous paragraphs in one scrolling block, with the title and the buttons scrolling away with them. The scroll container is keyboard-focusable, and unstyled it drew a default focus ring, so the disclosure also looked like a giant textarea. Now: a fixed header carrying the icon, title and purpose; a scroll region with six labelled sections, each with a small heading and icon so the thing can be scanned rather than only read; and a fixed footer with the consent checkbox and the actions, which no longer scroll out of reach on a short screen. "What is never included" is set apart as a tinted block, since it is the part that answers the question an operator actually has. The focus ring is now a deliberate inset ring on a labelled region, which is correct for keyboard use instead of an accident that looked like a form field. Dark mode is fixed as part of this, and it was my own doing: the dialog used `bg-theme-surface`, which does not follow dark mode, and the section text I added carries dark: variants. Light surface plus near-white text is unreadable. The surface is class-driven now — neutral-800, which is what `.card` resolves to in dark and what the rest of the admin UI uses. Verified in both themes through the app's own theme toggle rather than by forcing the class, which is what produced the misleading half-state the first time I looked. Six section headings added in EN and DE. Refs #1110
This commit is contained in:
@@ -1,13 +1,41 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, type ComponentType } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
productUsageService as service,
|
productUsageService as service,
|
||||||
type ProductFeedback
|
type ProductFeedback
|
||||||
} from '../../../services/productUsage.service';
|
} from '../../../services/productUsage.service';
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
ListChecks,
|
||||||
|
MessageSquare,
|
||||||
|
Send,
|
||||||
|
ShieldOff,
|
||||||
|
Sparkles,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-react';
|
||||||
import { useConfirm } from '../../../components/common/ConfirmDialog';
|
import { useConfirm } from '../../../components/common/ConfirmDialog';
|
||||||
import { Button, Card } from '../../../components/common';
|
import { Button, Card } from '../../../components/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections of the disclosure, in reading order. Each is a translated
|
||||||
|
* paragraph; the heading and icon give it a shape you can scan instead of
|
||||||
|
* seven identical blocks of prose.
|
||||||
|
*/
|
||||||
|
const DISCLOSURE: {
|
||||||
|
key: string;
|
||||||
|
heading: string;
|
||||||
|
Icon: ComponentType<{ className?: string }>;
|
||||||
|
tone?: 'positive';
|
||||||
|
}[] = [
|
||||||
|
{ key: 'fields', heading: 'sectionFields', Icon: ListChecks },
|
||||||
|
{ key: 'excluded', heading: 'sectionExcluded', Icon: ShieldOff, tone: 'positive' },
|
||||||
|
{ key: 'transport', heading: 'sectionTransport', Icon: Send },
|
||||||
|
{ key: 'visibility', heading: 'sectionVisibility', Icon: Globe },
|
||||||
|
{ key: 'deletion', heading: 'sectionDeletion', Icon: Trash2 },
|
||||||
|
{ key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare }
|
||||||
|
];
|
||||||
|
|
||||||
function ConsentDialog({
|
function ConsentDialog({
|
||||||
close,
|
close,
|
||||||
enable,
|
enable,
|
||||||
@@ -30,27 +58,64 @@ function ConsentDialog({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
onCancel={close}
|
onCancel={close}
|
||||||
aria-labelledby="usage-consent-title"
|
aria-labelledby="usage-consent-title"
|
||||||
className="w-full max-w-2xl rounded-xl p-6 text-theme bg-theme-surface backdrop:bg-black/50"
|
// Column layout with its own scroll region, so the title stays put and
|
||||||
|
// the actions never scroll out of reach on a short screen.
|
||||||
|
//
|
||||||
|
// Surface is class-driven rather than `bg-theme-surface`: that variable
|
||||||
|
// does not follow dark mode, so it stayed white while the dark: text
|
||||||
|
// variants below turned near-white. neutral-800 is what `.card`
|
||||||
|
// resolves to in dark, which is what the rest of the admin UI uses.
|
||||||
|
className="w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden rounded-xl p-0 bg-white dark:bg-neutral-800 shadow-xl backdrop:bg-black/50"
|
||||||
|
>
|
||||||
|
<header className="flex items-start gap-3 px-6 pt-6 pb-4">
|
||||||
|
<span className="mt-0.5 flex h-9 w-9 flex-none items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900/30">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary-600 dark:text-primary-300" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2
|
||||||
|
id="usage-consent-title"
|
||||||
|
className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||||
>
|
>
|
||||||
<h2 id="usage-consent-title" className="text-xl font-semibold">
|
|
||||||
{t('productUsage.consentTitle')}
|
{t('productUsage.consentTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="my-4 max-h-[55vh] overflow-y-auto space-y-3">
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{[
|
{t('productUsage.purpose')}
|
||||||
'purpose',
|
</p>
|
||||||
'fields',
|
|
||||||
'excluded',
|
|
||||||
'transport',
|
|
||||||
'visibility',
|
|
||||||
'deletion',
|
|
||||||
'feedbackDisclosure'
|
|
||||||
].map((key) => (
|
|
||||||
<p key={key}>{t(`productUsage.${key}`, { collector })}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex flex-wrap gap-x-6 gap-y-1 text-sm">
|
</header>
|
||||||
|
|
||||||
|
{/* A scrollable region is focusable, which is correct for keyboard use —
|
||||||
|
but unstyled it drew a default ring that made the disclosure look
|
||||||
|
like a textarea. Given a real label and ring so it reads as what it
|
||||||
|
is: a document you can scroll. */}
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="group"
|
||||||
|
aria-label={t('productUsage.consentTitle') as string}
|
||||||
|
className="flex-1 overflow-y-auto border-y border-neutral-200 dark:border-neutral-700 px-6 py-4 space-y-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500"
|
||||||
|
>
|
||||||
|
{DISCLOSURE.map(({ key, heading, Icon, tone }) => (
|
||||||
|
<section
|
||||||
|
key={key}
|
||||||
|
className={
|
||||||
|
tone === 'positive'
|
||||||
|
? 'rounded-lg border border-primary-200 dark:border-primary-800 bg-primary-50/60 dark:bg-primary-900/20 p-3'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{t(`productUsage.${heading}`)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t(`productUsage.${key}`, { collector })}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 pt-1 text-sm">
|
||||||
<a
|
<a
|
||||||
className={'text-primary-600 dark:text-primary-400 hover:underline'}
|
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
href={collector}
|
href={collector}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
@@ -58,7 +123,7 @@ function ConsentDialog({
|
|||||||
{t('productUsage.linkCollector')}
|
{t('productUsage.linkCollector')}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
className={'text-primary-600 dark:text-primary-400 hover:underline'}
|
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
href={`${collector}/transparency`}
|
href={`${collector}/transparency`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
@@ -66,13 +131,17 @@ function ConsentDialog({
|
|||||||
{t('productUsage.transparency')}
|
{t('productUsage.transparency')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-start gap-2 mb-4">
|
</div>
|
||||||
|
|
||||||
|
<footer className="px-6 pt-4 pb-6 space-y-4">
|
||||||
|
<label className="flex items-start gap-2.5 text-sm text-neutral-800 dark:text-neutral-200">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 flex-none"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={(e) => setChecked(e.target.checked)}
|
onChange={(e) => setChecked(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{t('productUsage.consentCheck')}
|
<span>{t('productUsage.consentCheck')}</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline" onClick={close} disabled={busy}>
|
<Button variant="outline" onClick={close} disabled={busy}>
|
||||||
@@ -82,6 +151,7 @@ function ConsentDialog({
|
|||||||
{t('productUsage.enable')}
|
{t('productUsage.enable')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
</dialog>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfe den Status und versuche es erneut.",
|
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfe den Status und versuche es erneut.",
|
||||||
"purpose": "Hilf bei der Priorisierung von PicPeak-Funktionen, Fehlerbehebungen und Wartung mit groben Informationen über teilnehmende Installationen.",
|
"purpose": "Hilf bei der Priorisierung von PicPeak-Funktionen, Fehlerbehebungen und Wartung mit groben Informationen über teilnehmende Installationen.",
|
||||||
"consentTitle": "Produktnutzung freiwillig teilen",
|
"consentTitle": "Produktnutzung freiwillig teilen",
|
||||||
|
"sectionFields": "Was ein Bericht enthält",
|
||||||
|
"sectionExcluded": "Was niemals enthalten ist",
|
||||||
|
"sectionTransport": "Wie er gesendet wird",
|
||||||
|
"sectionVisibility": "Wo er sichtbar ist",
|
||||||
|
"sectionDeletion": "Beenden und löschen",
|
||||||
|
"sectionFeedback": "Feedback ist getrennt",
|
||||||
"fields": "Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, Berichtstag, Schema- und Signaturmetadaten, Galerie-Layouts sowie Konfiguriert/Genutzt-Werte für CRM und Unterfunktionen, Buchhaltung, Workflows, Newsletter, Gesichtserkennung, eigenes CSS, OAuth, SMTP, WhatsApp, Backups, S3 und eingebundene Freigaben. „Genutzt“ bedeutet seit der Teilnahme beobachtet, nicht wie häufig.",
|
"fields": "Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, Berichtstag, Schema- und Signaturmetadaten, Galerie-Layouts sowie Konfiguriert/Genutzt-Werte für CRM und Unterfunktionen, Buchhaltung, Workflows, Newsletter, Gesichtserkennung, eigenes CSS, OAuth, SMTP, WhatsApp, Backups, S3 und eingebundene Freigaben. „Genutzt“ bedeutet seit der Teilnahme beobachtet, nicht wie häufig.",
|
||||||
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
|
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
|
||||||
"transport": "Dein PicPeak-Backend verwahrt den Signaturschlüssel und sendet signierte Berichte an {{collector}}, einmal pro UTC-Tag bei Admin-Nutzung. Du kannst Berichte vorab ansehen und alle angenommenen Rohpakete herunterladen.",
|
"transport": "Dein PicPeak-Backend verwahrt den Signaturschlüssel und sendet signierte Berichte an {{collector}}, einmal pro UTC-Tag bei Admin-Nutzung. Du kannst Berichte vorab ansehen und alle angenommenen Rohpakete herunterladen.",
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
"failed": "The operation could not be completed. Check the status and try again.",
|
"failed": "The operation could not be completed. Check the status and try again.",
|
||||||
"purpose": "Help prioritize PicPeak features, fixes, and maintenance using coarse information about participating installations.",
|
"purpose": "Help prioritize PicPeak features, fixes, and maintenance using coarse information about participating installations.",
|
||||||
"consentTitle": "Choose whether to share product usage",
|
"consentTitle": "Choose whether to share product usage",
|
||||||
|
"sectionFields": "What a report contains",
|
||||||
|
"sectionExcluded": "What is never included",
|
||||||
|
"sectionTransport": "How it is sent",
|
||||||
|
"sectionVisibility": "Where it is visible",
|
||||||
|
"sectionDeletion": "Leaving and deleting",
|
||||||
|
"sectionFeedback": "Feedback is separate",
|
||||||
"fields": "Reports contain an installation fingerprint, PicPeak version, report day, schema and signing metadata, gallery layout choices, and configured/used booleans for CRM and its subfeatures, accounting, workflows, newsletters, face recognition, custom CSS, OAuth, SMTP, WhatsApp, backups, S3, and share mounts. “Used” means observed since joining, not how often.",
|
"fields": "Reports contain an installation fingerprint, PicPeak version, report day, schema and signing metadata, gallery layout choices, and configured/used booleans for CRM and its subfeatures, accounting, workflows, newsletters, face recognition, custom CSS, OAuth, SMTP, WhatsApp, backups, S3, and share mounts. “Used” means observed since joining, not how often.",
|
||||||
"excluded": "No gallery visitors, clickstreams, photo or gallery counts, names, emails, domains, filenames, or configuration secrets are included in automatic usage reports.",
|
"excluded": "No gallery visitors, clickstreams, photo or gallery counts, names, emails, domains, filenames, or configuration secrets are included in automatic usage reports.",
|
||||||
"transport": "Your PicPeak backend keeps the signing key and sends signed reports to {{collector}} once per UTC day when an admin uses the app. You can preview reports and download every accepted raw packet.",
|
"transport": "Your PicPeak backend keeps the signing key and sends signed reports to {{collector}} once per UTC day when an admin uses the app. You can preview reports and download every accepted raw packet.",
|
||||||
|
|||||||
Reference in New Issue
Block a user