feat(setup): add anonymous usage-reporting opt-in to the first-run wizard
Adds a step between the SMTP/site-URL config step and the final thank-you screen, asking whether to participate in the existing Product usage & feedback reporting. Kept short — three points on how it differs from typical telemetry (one-way only, no personal data, and participants can browse the same shared feature-adoption dataset other installations report) plus an explicit consent checkbox — and calls the same enable() endpoint the post-install Settings page uses. Skipping does nothing; participation can be toggled anytime from Settings → Product usage.
This commit is contained in:
@@ -5043,6 +5043,21 @@
|
|||||||
"saveFailed": "Einige Änderungen konnten nicht gespeichert werden — Sie können sie unter Einstellungen → Veranstaltungsarten abschließen.",
|
"saveFailed": "Einige Änderungen konnten nicht gespeichert werden — Sie können sie unter Einstellungen → Veranstaltungsarten abschließen.",
|
||||||
"deleteFailed": "Eine Löschung ist fehlgeschlagen — die Liste wurde neu geladen. Mitgelieferte Arten können nur hier gelöscht werden; versuchen Sie es erneut oder fahren Sie mit ihnen fort."
|
"deleteFailed": "Eine Löschung ist fehlgeschlagen — die Liste wurde neu geladen. Mitgelieferte Arten können nur hier gelöscht werden; versuchen Sie es erneut oder fahren Sie mit ihnen fort."
|
||||||
},
|
},
|
||||||
|
"usageReporting": {
|
||||||
|
"subtitle": "Noch eine Sache",
|
||||||
|
"intro": "Möchten Sie helfen, die Weiterentwicklung von PicPeak mitzugestalten? Die anonyme Nutzungsstatistik ist freiwillig — und funktioniert anders als das Tracking, das Sie sonst gewohnt sind abzulehnen.",
|
||||||
|
"oneWayTitle": "Anders als Google Analytics & Co.",
|
||||||
|
"oneWayDesc": "Ein reiner Einwegkanal: PicPeak sendet signierte Berichte, kann aber niemals Befehle zurückempfangen. Keine Fotos, Namen, E-Mails oder Klickverfolgung — niemals.",
|
||||||
|
"mutualTitle": "Sehen, was alle anderen nutzen",
|
||||||
|
"mutualDesc": "Als Teilnehmer sehen Sie denselben geteilten Datensatz — welche Funktionen andere Installationen wirklich verwenden, nicht nur das, was Sie selbst senden.",
|
||||||
|
"feedbackTitle": "Feedback direkt teilen",
|
||||||
|
"feedbackDesc": "Senden Sie kurzes Feedback oder Funktionswünsche direkt an den Entwickler — ganz einfach aus den Einstellungen.",
|
||||||
|
"consentCheck": "Ich bin einverstanden — jederzeit wieder abschaltbar unter Einstellungen → Produktnutzung.",
|
||||||
|
"enable": "Ja, ich möchte teilnehmen",
|
||||||
|
"skip": "Vielleicht später",
|
||||||
|
"enabled": "Danke für Ihre Teilnahme!",
|
||||||
|
"enableFailed": "Konnte gerade nicht aktiviert werden — Sie können es jederzeit unter Einstellungen → Produktnutzung erneut versuchen."
|
||||||
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"subtitle": "Alles bereit",
|
"subtitle": "Alles bereit",
|
||||||
"mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass Sie es ausprobieren.",
|
"mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass Sie es ausprobieren.",
|
||||||
|
|||||||
@@ -4925,6 +4925,21 @@
|
|||||||
"saveFailed": "Some event type changes could not be saved — you can finish them in Settings → Event Types.",
|
"saveFailed": "Some event type changes could not be saved — you can finish them in Settings → Event Types.",
|
||||||
"deleteFailed": "A deletion failed — the list has been reloaded. Built-in types can only be deleted here, so retry or continue with them kept."
|
"deleteFailed": "A deletion failed — the list has been reloaded. Built-in types can only be deleted here, so retry or continue with them kept."
|
||||||
},
|
},
|
||||||
|
"usageReporting": {
|
||||||
|
"subtitle": "One more thing",
|
||||||
|
"intro": "Want to help shape what PicPeak builds next? Anonymous usage reporting is optional, and it works differently from the analytics you're used to declining.",
|
||||||
|
"oneWayTitle": "Not like Google Analytics",
|
||||||
|
"oneWayDesc": "A one-way channel only: PicPeak sends signed reports but can never receive commands back. No photos, names, emails or click tracking — ever.",
|
||||||
|
"mutualTitle": "See what everyone else uses",
|
||||||
|
"mutualDesc": "As a participant, you can browse the same shared dataset — which features other installations actually use, not just what you send.",
|
||||||
|
"feedbackTitle": "Share feedback directly",
|
||||||
|
"feedbackDesc": "Send quick feedback or feature requests straight to the maintainer, right from Settings.",
|
||||||
|
"consentCheck": "I agree to participate — I can turn this off anytime in Settings → Product usage.",
|
||||||
|
"enable": "Yes, I'll participate",
|
||||||
|
"skip": "Maybe later",
|
||||||
|
"enabled": "Thanks for participating!",
|
||||||
|
"enableFailed": "Couldn't enable it just now — you can try again anytime in Settings → Product usage."
|
||||||
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"subtitle": "You're all set",
|
"subtitle": "You're all set",
|
||||||
"mission": "PicPeak exists so photographers can own their galleries and client data — on their own server, without monthly SaaS fees. Thanks for giving it a try.",
|
"mission": "PicPeak exists so photographers can own their galleries and client data — on their own server, without monthly SaaS fees. Thanks for giving it a try.",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee } from 'lucide-react';
|
import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee, ShieldOff, Users, MessageSquare } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -11,6 +11,7 @@ import { useAdminAuth } from '../contexts';
|
|||||||
import { setupService } from '../services/setup.service';
|
import { setupService } from '../services/setup.service';
|
||||||
import { settingsService } from '../services/settings.service';
|
import { settingsService } from '../services/settings.service';
|
||||||
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
|
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
|
||||||
|
import { productUsageService } from '../services/productUsage.service';
|
||||||
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
|
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
|
||||||
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
|
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
|
||||||
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
|
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
|
||||||
@@ -36,6 +37,17 @@ const COMMUNITY_LINKS: {
|
|||||||
{ key: 'support', href: 'https://www.buymeacoffee.com/theluap', icon: Coffee },
|
{ key: 'support', href: 'https://www.buymeacoffee.com/theluap', icon: Coffee },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Anonymous usage-reporting opt-in, one step before the final thank-you
|
||||||
|
// screen. Kept deliberately short — most people reflexively decline "send us
|
||||||
|
// data" prompts, so this leads with what makes PicPeak's reporting different
|
||||||
|
// from typical analytics rather than repeating the full disclosure the
|
||||||
|
// Settings → Product usage tab already shows in detail.
|
||||||
|
const USAGE_REPORTING_POINTS: { key: string; icon: LucideIcon }[] = [
|
||||||
|
{ key: 'oneWay', icon: ShieldOff },
|
||||||
|
{ key: 'mutual', icon: Users },
|
||||||
|
{ key: 'feedback', icon: MessageSquare },
|
||||||
|
];
|
||||||
|
|
||||||
// "How will you use PicPeak?" — the opt-in feature groups shown after the admin
|
// "How will you use PicPeak?" — the opt-in feature groups shown after the admin
|
||||||
// account is created. galleries/analytics/userManagement are always on and not
|
// account is created. galleries/analytics/userManagement are always on and not
|
||||||
// listed. Labels/descriptions reuse the existing Settings→Features i18n keys
|
// listed. Labels/descriptions reuse the existing Settings→Features i18n keys
|
||||||
@@ -70,7 +82,7 @@ export const SetupPage: React.FC = () => {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'eventTypes' | 'restore' | 'config' | 'community'>('token');
|
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'eventTypes' | 'restore' | 'config' | 'usageReporting' | 'community'>('token');
|
||||||
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
|
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -79,6 +91,8 @@ export const SetupPage: React.FC = () => {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [selectedFeatures, setSelectedFeatures] = useState<Set<FeatureKey>>(new Set());
|
const [selectedFeatures, setSelectedFeatures] = useState<Set<FeatureKey>>(new Set());
|
||||||
const [isSavingFeatures, setIsSavingFeatures] = useState(false);
|
const [isSavingFeatures, setIsSavingFeatures] = useState(false);
|
||||||
|
const [usageReportingConsent, setUsageReportingConsent] = useState(false);
|
||||||
|
const [isEnablingUsageReporting, setIsEnablingUsageReporting] = useState(false);
|
||||||
|
|
||||||
if (statusLoading) {
|
if (statusLoading) {
|
||||||
return <Loading fullScreen />;
|
return <Loading fullScreen />;
|
||||||
@@ -275,6 +289,22 @@ export const SetupPage: React.FC = () => {
|
|||||||
setStep('config');
|
setStep('config');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Best-effort, same as the feature-flag save above: a collector hiccup on a
|
||||||
|
// fresh install must not trap the admin here. They can always opt in later
|
||||||
|
// from Settings → Product usage, where the full disclosure lives.
|
||||||
|
const enableUsageReporting = async () => {
|
||||||
|
setIsEnablingUsageReporting(true);
|
||||||
|
try {
|
||||||
|
await productUsageService.enable();
|
||||||
|
toast.success(t('setup.usageReporting.enabled'));
|
||||||
|
} catch (_) {
|
||||||
|
toast.warn(t('setup.usageReporting.enableFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsEnablingUsageReporting(false);
|
||||||
|
setStep('community');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
|
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -305,9 +335,11 @@ export const SetupPage: React.FC = () => {
|
|||||||
? t('setup.restoreStepSubtitle')
|
? t('setup.restoreStepSubtitle')
|
||||||
: step === 'config'
|
: step === 'config'
|
||||||
? t('setup.config.subtitle')
|
? t('setup.config.subtitle')
|
||||||
: step === 'community'
|
: step === 'usageReporting'
|
||||||
? t('setup.community.subtitle')
|
? t('setup.usageReporting.subtitle')
|
||||||
: t('setup.usageSubtitle')}
|
: step === 'community'
|
||||||
|
? t('setup.community.subtitle')
|
||||||
|
: t('setup.usageSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
{(step === 'token' || step === 'account' || step === 'usage') && (
|
{(step === 'token' || step === 'account' || step === 'usage') && (
|
||||||
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
|
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
|
||||||
@@ -537,8 +569,62 @@ export const SetupPage: React.FC = () => {
|
|||||||
) : step === 'config' ? (
|
) : step === 'config' ? (
|
||||||
<SetupConfigStep
|
<SetupConfigStep
|
||||||
selectedFeatures={selectedFeatures}
|
selectedFeatures={selectedFeatures}
|
||||||
onDone={() => setStep('community')}
|
onDone={() => setStep('usageReporting')}
|
||||||
/>
|
/>
|
||||||
|
) : step === 'usageReporting' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-sm text-neutral-700">{t('setup.usageReporting.intro')}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{USAGE_REPORTING_POINTS.map(({ key, icon: Icon }) => (
|
||||||
|
<div key={key} className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3">
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-primary, #5C8762)' }} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-neutral-800">
|
||||||
|
{t(`setup.usageReporting.${key}Title`)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500">
|
||||||
|
{t(`setup.usageReporting.${key}Desc`)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-neutral-300"
|
||||||
|
checked={usageReportingConsent}
|
||||||
|
onChange={(e) => setUsageReportingConsent(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-neutral-600">{t('setup.usageReporting.consentCheck')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
isLoading={isEnablingUsageReporting}
|
||||||
|
disabled={!usageReportingConsent}
|
||||||
|
onClick={enableUsageReporting}
|
||||||
|
>
|
||||||
|
{t('setup.usageReporting.enable')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isEnablingUsageReporting}
|
||||||
|
onClick={() => setStep('community')}
|
||||||
|
>
|
||||||
|
{t('setup.usageReporting.skip')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<p className="text-sm text-neutral-700">{t('setup.community.mission')}</p>
|
<p className="text-sm text-neutral-700">{t('setup.community.mission')}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user