Make verified repair acceptance prominent and simplify confirmation email
Magent CI/CD / verify (push) Canceled after 5m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-06 22:32:21 +12:00
parent 74c49fad5b
commit a32928b1c5
9 changed files with 254 additions and 43 deletions
+63
View File
@@ -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>
}
+2 -1
View File
@@ -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) }
+11 -27
View File
@@ -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">
+19
View File
@@ -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>
}
+13
View File
@@ -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; } }