Make verified repair acceptance prominent and simplify confirmation email
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
|
||||
- A media repair starts a new collection cycle: preserve Requested/Approved and unaffected TV episodes, but do not reuse an old torrent or Jellyfin entry to mark the replacement ready. Show Pending → Downloading → Indexing → Available from collector/file evidence. Subtitle-only repairs must not reset video availability.
|
||||
- Request action feedback uses a compact Latest activity card beside download status; full event history opens in a native modal dialog without expanding the page. Keep messages outcome-based and do not equate a successful service response with playable media.
|
||||
- Issue acceptance uses `ui/ResolutionChoice.tsx`: large YES/NO choices at the top of issue details and on `/issues/confirm/[id]`. Email links only open that page; answers require an authenticated POST. A NO must wait for a new repair before automatic acceptance is proposed again.
|
||||
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
|
||||
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
|
||||
|
||||
@@ -21,6 +22,7 @@ Build the frontend before reviewing. The scripts in `scripts/` run using Node an
|
||||
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
|
||||
- `review_repair_pipeline_ui.cjs`: fixture-only movie/TV replacement stages, old-cycle progress rejection, desktop/mobile layout and automatic availability transitions.
|
||||
- `review_activity_ui.cjs`: fixture-only latest activity, desktop/tablet/mobile placement, modal history, keyboard dismissal and focus restoration.
|
||||
- `review_acceptance_ui.cjs`: fixture-only acceptance choices, exact YES/NO submissions, email-link safety, permissions and sign-in return links.
|
||||
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
|
||||
|
||||
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase, clearToken } from '../../../lib/auth'
|
||||
import ResolutionChoice from '../../../ui/ResolutionChoice'
|
||||
|
||||
type Issue = { id: number; kind: string; title: string; status: string; permissions?: { can_confirm_resolution?: boolean } }
|
||||
|
||||
export default function ConfirmIssuePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [item, setItem] = useState<Issue | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const login = () => {
|
||||
clearToken()
|
||||
router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`)
|
||||
}
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setLoading(true); setItem(null); setError(''); setResult('')
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/portal/items/${id}`, { signal: controller.signal, cache: 'no-store' })
|
||||
if (response.status === 401) { login(); return }
|
||||
if (!response.ok) throw new Error('This issue is unavailable. Please sign in with the account that reported it.')
|
||||
const data = await response.json()
|
||||
if (data.item?.kind !== 'issue') throw new Error('This link does not belong to an issue.')
|
||||
setItem(data.item)
|
||||
} catch (err) { if (!controller.signal.aborted) setError(err instanceof Error ? err.message : 'Could not load this issue. Please try again.') }
|
||||
finally { if (!controller.signal.aborted) setLoading(false) }
|
||||
}
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
// The confirmation link identifies one issue. Never submit an answer on GET.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id])
|
||||
|
||||
const answer = async (resolved: boolean) => {
|
||||
if (busy) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolved }),
|
||||
})
|
||||
if (response.status === 401) { login(); return }
|
||||
if (!response.ok) throw new Error('Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.')
|
||||
setResult(resolved ? 'Thanks! Your issue is now closed.' : 'Thanks for letting us know. Your issue stays open for another look.')
|
||||
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save your answer. Please try again.') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
return <main className="resolution-response-page">
|
||||
{error && <p role="alert" className="status-banner">{error}</p>}
|
||||
{loading ? <p role="status">Loading your issue…</p> : result ? <section className="resolution-choice" role="status"><h2>{result}</h2><a href="/portal/issues">Back to issues</a></section> : item ? (
|
||||
item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution
|
||||
? <ResolutionChoice title={item.title} busy={busy} onAnswer={(value) => void answer(value)} />
|
||||
: <section className="resolution-choice"><h2>{item.status === 'awaiting_confirmation' ? 'This question is for the person who reported the issue.' : 'No answer is needed right now.'}</h2><p>{item.title}</p><a href={`/portal/issues?item=${item.id}`}>View issue</a></section>
|
||||
) : null}
|
||||
</main>
|
||||
}
|
||||
@@ -70,7 +70,8 @@ export default function LoginPage() {
|
||||
const data = await response.json()
|
||||
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
|
||||
setToken('cookie')
|
||||
window.location.assign('/')
|
||||
const next = new URLSearchParams(window.location.search).get('next') || ''
|
||||
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/')
|
||||
} catch {
|
||||
setError('Could not reach Magent. Check your connection and try again.')
|
||||
} finally { setLoading(false) }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import ResolutionChoice from '../ui/ResolutionChoice'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
import IssueFlowStep from './IssueFlowStep'
|
||||
@@ -1501,6 +1502,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
actions={workspace === 'issue' ? <span className="page-heading-meta">{visibleKindCount} reported {visibleKindCount === 1 ? 'issue' : 'issues'}</span> : undefined}
|
||||
/>
|
||||
|
||||
{workspace === 'issue' && items.filter((item) => item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution && item.created_by_username === me?.username).map((item) => (
|
||||
<section key={item.id} className="resolution-choice">
|
||||
<h2>Is it fixed?</h2><p>{item.title}</p>
|
||||
<a className="button" href={`/issues/confirm/${item.id}`}>Answer YES or NO →</a>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{workspace === 'request' ? (
|
||||
<section className="portal-workspace-switch">
|
||||
<button type="button" className="is-active" disabled>
|
||||
@@ -2225,6 +2233,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selectedItem.kind === 'issue' && selectedItem.status === 'awaiting_confirmation' && selectedItem.permissions?.can_confirm_resolution && (
|
||||
<ResolutionChoice title={selectedItem.title} busy={respondingResolution} onAnswer={(value) => void respondToResolution(value)} />
|
||||
)}
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>
|
||||
@@ -2294,33 +2305,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedItem.kind === 'issue' && selectedItem.status === 'awaiting_confirmation' ? (
|
||||
<section className="issue-confirmation-card" aria-live="polite">
|
||||
<div>
|
||||
<span className="section-kicker">Resolution check</span>
|
||||
<h3>Has this issue been fixed?</h3>
|
||||
<p>
|
||||
Magent is waiting for the reporter to confirm the result.
|
||||
{(selectedItem.issue?.confirmation?.maximum_attempts ?? 0) > 0
|
||||
? ` ${selectedItem.issue?.confirmation?.attempts_sent ?? 0} of ${selectedItem.issue?.confirmation?.maximum_attempts ?? 0} confirmation emails have been attempted.`
|
||||
: ' Confirmation emails are disabled, so this issue will close automatically.'}
|
||||
</p>
|
||||
{selectedItem.issue?.confirmation?.next_contact_at ? (
|
||||
<small>Next reminder or automatic closure check: {formatDate(selectedItem.issue.confirmation.next_contact_at)}</small>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedItem.permissions?.can_confirm_resolution ? (
|
||||
<div className="issue-confirmation-actions">
|
||||
<button type="button" disabled={respondingResolution} onClick={() => void respondToResolution(true)}>
|
||||
Yes, it is fixed
|
||||
</button>
|
||||
<button type="button" className="ghost-button" disabled={respondingResolution} onClick={() => void respondToResolution(false)}>
|
||||
No, it is still happening
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
||||
<label className="portal-field-span-2">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import './resolution-choice.css'
|
||||
|
||||
export default function ResolutionChoice({ title, busy, onAnswer }: {
|
||||
title: string; busy: boolean; onAnswer: (resolved: boolean) => void
|
||||
}) {
|
||||
return <section className="resolution-choice" aria-labelledby="resolution-question" aria-busy={busy}>
|
||||
<span className="section-kicker">Your answer is needed</span>
|
||||
<h2 id="resolution-question">Is it fixed?</h2>
|
||||
<p>{title}</p>
|
||||
<p>Try the affected content in Grizzlyflix, then choose:</p>
|
||||
<div className="resolution-choice-buttons">
|
||||
<button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}><strong>YES</strong><span>It works — close this issue</span></button>
|
||||
<button id="no" type="button" className="resolution-no" disabled={busy} onClick={() => onAnswer(false)}><strong>NO</strong><span>Still broken — keep it open</span></button>
|
||||
</div>
|
||||
{busy && <p role="status">Saving your answer…</p>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.resolution-choice { padding: clamp(20px, 4vw, 36px); border: 1px solid var(--ops-border, #555); border-radius: 18px; background: var(--ops-surface, #202023); margin-bottom: 20px; }
|
||||
.resolution-choice h2 { margin: 10px 0; font-size: clamp(2rem, 5vw, 3.25rem); line-height: 1.1; }
|
||||
.resolution-choice p { line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.resolution-choice-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px; }
|
||||
.resolution-choice-buttons button { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 130px; padding: 20px; border-radius: 14px; border: 2px solid transparent; text-transform: none; }
|
||||
.resolution-choice-buttons button strong { font-size: 2.75rem; line-height: 1; color: inherit; }
|
||||
.resolution-choice-buttons button span { color: inherit; opacity: 1; font-size: .9rem; }
|
||||
/* Scoped overrides for the legacy global !important button palette. */
|
||||
.page .resolution-choice-buttons button.resolution-yes { background: #b4f4d2 !important; color: #10261b !important; border-color: #b4f4d2 !important; }
|
||||
.page .resolution-choice-buttons button.resolution-no { background: #ffc1c5 !important; color: #391318 !important; border-color: #ffc1c5 !important; }
|
||||
.resolution-choice-buttons button:focus-visible { outline: 3px solid var(--ops-accent, #c7baff); outline-offset: 4px; }
|
||||
.resolution-response-page { width: min(760px, 100%); margin: 20px auto; }
|
||||
@media (max-width: 520px) { .resolution-choice-buttons { grid-template-columns: 1fr; } .resolution-choice-buttons button { min-height: 104px; } }
|
||||
Reference in New Issue
Block a user