Rate & opt-in dialog #22

This commit is contained in:
Novattz
2026-03-28 15:05:57 +01:00
parent 487e974274
commit 85d670931a
2 changed files with 246 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
import React from 'react'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, info } from '@/components/icons'
interface OptInDialogProps {
visible: boolean
onAccept: () => void
onDecline: () => void
}
/**
* First-launch opt-in dialog for the compatibility reporting system.
* Shown once when the app fully starts. Does not close until the user makes
* an explicit choice.
*/
const OptInDialog: React.FC<OptInDialogProps> = ({ visible, onAccept, onDecline }) => {
return (
<Dialog visible={visible} onClose={() => {}} size="medium">
<DialogHeader onClose={() => {}} hideCloseButton={true}>
<h3>Help improve CreamLinux</h3>
</DialogHeader>
<DialogBody>
<div className="optin-content">
<p className="optin-intro">
CreamLinux can collect anonymous compatibility reports to help users know which
games work with CreamLinux and SmokeAPI before they install them.
</p>
<div className="optin-details">
<h4>What we collect</h4>
<ul>
<li>
<strong>A one-way anonymous hash</strong> derived from your machine ID, Steam
install path, and a locally-stored random salt. <em>This cannot be reversed
to identify you</em>, and even we cannot link it to your machine.
</li>
<li>The Steam App ID of the game you rated.</li>
<li>Which unlocker you used (CreamLinux or SmokeAPI).</li>
<li>Whether it worked or not.</li>
</ul>
<h4>What we do not collect</h4>
<ul>
<li>Your username, IP address, or any personally identifiable information.</li>
</ul>
</div>
<div className="optin-notice">
<Icon name={info} variant="solid" size="md" />
<span>
If you opt out, the local salt will be deleted and no data will ever be sent.
You will not be able to submit compatibility votes, but the app works fully
without this feature.
</span>
</div>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={onDecline}>
No thanks
</Button>
<Button variant="primary" onClick={onAccept}>
Enable reporting
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default OptInDialog

View File

@@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, info } from '@/components/icons'
interface LocalReport {
game_id: string
unlocker: string
worked: boolean
}
export interface RatingDialogProps {
visible: boolean
gameTitle: string
gameId: string
/** 'creamlinux' | 'smokeapi' whichever is currently installed */
unlocker: 'creamlinux' | 'smokeapi'
onClose: () => void
onSubmit: (worked: boolean) => Promise<void>
}
const UNLOCKER_LABELS: Record<string, string> = {
creamlinux: 'CreamLinux',
smokeapi: 'SmokeAPI',
}
/**
* Per-game rating dialog. Submits exactly one report for the installed unlocker.
*/
const RatingDialog: React.FC<RatingDialogProps> = ({
visible,
gameTitle,
gameId,
unlocker,
onClose,
onSubmit,
}) => {
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
// Which vote the user has already cast for this game+unlocker, if any
const [previousVote, setPreviousVote] = useState<boolean | null>(null)
useEffect(() => {
if (!visible) return
// Reset submit state each time the dialog opens
setSubmitted(false)
// Load the local reports to see if this game+unlocker has already been started
invoke<LocalReport[]>('get_local_reports')
.then((reports) => {
const existing = reports.find(
(r) => r.game_id === gameId && r.unlocker === unlocker
)
setPreviousVote(existing ? existing.worked : null)
})
.catch(() => setPreviousVote(null))
}, [visible, gameId, unlocker])
const handleSubmit = async (worked: boolean) => {
if (submitting || submitted) return
setSubmitting(true)
try {
await onSubmit(worked)
setSubmitted(true)
} finally {
setSubmitting(false)
}
}
const handleClose = () => {
setSubmitted(false)
onClose()
}
const label = UNLOCKER_LABELS[unlocker] ?? unlocker
// A button is "already chosen" if it matches the previous vote
const workedAlreadyChosen = previousVote === true
const brokenAlreadyChosen = previousVote === false
return (
<Dialog visible={visible} onClose={handleClose} size="small">
<DialogHeader onClose={handleClose} hideCloseButton={true}>
<h3>Submit rating</h3>
</DialogHeader>
<DialogBody>
{submitted ? (
<div className="rating-submitted">
<p>Thanks for your report! Your vote helps other users.</p>
</div>
) : (
<div className="rating-content">
<p>
You have <strong>{label}</strong> installed for{' '}
<strong>{gameTitle}</strong>. Did it work?
</p>
{previousVote !== null && (
<p className="rating-subtext">
You previously voted <strong>{previousVote ? 'worked' : "didn't work"}</strong>.
You can change your vote below.
</p>
)}
{previousVote === null && (
<p className="rating-subtext">
Your rating is anonymous and helps other users know if{' '}
{label} works with this game.
</p>
)}
<div className="rating-buttons">
<Button
variant="success"
className={`rating-btn rating-btn--worked${workedAlreadyChosen ? ' rating-btn--active' : ''}`}
onClick={() => handleSubmit(true)}
disabled={submitting || workedAlreadyChosen}
title={workedAlreadyChosen ? 'Already voted' : undefined}
leftIcon={<Icon name="Check" variant="solid" size="sm" />}
>
It worked
</Button>
<Button
variant="danger"
className={`rating-btn rating-btn--broken${brokenAlreadyChosen ? ' rating-btn--active' : ''}`}
onClick={() => handleSubmit(false)}
disabled={submitting || brokenAlreadyChosen}
title={brokenAlreadyChosen ? 'Already voted' : undefined}
leftIcon={<Icon name="Close" variant="solid" size="sm" />}
>
Didn't work
</Button>
</div>
<div className="rating-notice">
<Icon name={info} variant="solid" size="md" />
<span>Only the result for {label} will be submitted.</span>
</div>
</div>
)}
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={handleClose}>
{submitted ? 'Close' : 'Cancel'}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default RatingDialog