feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
@@ -1,62 +1,231 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useBuildCardModel } from "./hooks/useBuildCardModel";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
Check,
|
||||
Fingerprint,
|
||||
LockKeyhole,
|
||||
Orbit,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useBuildsViewModel } from "./hooks/useBuildsViewModel";
|
||||
import type { BuildCardProps, BuildsViewProps } from "./types";
|
||||
import type { BuildsViewProps } from "./types";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { getBuildCopy } from "./buildsCopy";
|
||||
|
||||
function BuildCard({ build, snapshot }: BuildCardProps) {
|
||||
const artifactLookup = new Map(snapshot.artifacts.map((artifact) => [artifact.id, artifact]));
|
||||
export function BuildsView({ snapshot, onOpenInventory }: BuildsViewProps) {
|
||||
const { locale } = useI18n();
|
||||
const copy = getBuildCopy(locale);
|
||||
const {
|
||||
characterName,
|
||||
qualityLabel,
|
||||
roundedScore,
|
||||
explanation,
|
||||
artifactRows,
|
||||
warnings,
|
||||
hasWarnings,
|
||||
} = useBuildCardModel({
|
||||
build,
|
||||
characters: snapshot.characters,
|
||||
artifactLookup,
|
||||
});
|
||||
artifactCount,
|
||||
hasScanData,
|
||||
foundationLabel,
|
||||
foundationDetail,
|
||||
contractLabel,
|
||||
contractDetail,
|
||||
profileLabel,
|
||||
profileDetail,
|
||||
nativeResultCount,
|
||||
isLoadingBuildFit,
|
||||
buildFitLoadError,
|
||||
ranking,
|
||||
aggregateContextFields,
|
||||
setAggregateContextDraft,
|
||||
refreshBuildFit,
|
||||
} = useBuildsViewModel({ snapshot, locale });
|
||||
const suggestions = ranking.suggestions;
|
||||
const hasSuggestions = suggestions.length > 0;
|
||||
|
||||
return (
|
||||
<section className="panel build-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{qualityLabel}</p>
|
||||
<h2>{characterName}</h2>
|
||||
</div>
|
||||
<strong className="score">{roundedScore}</strong>
|
||||
</div>
|
||||
<p className="build-copy">{explanation}</p>
|
||||
<div className="build-pieces">
|
||||
{artifactRows.map((artifact) => (
|
||||
<div key={artifact.id}>
|
||||
<span>{artifact.slot}</span>
|
||||
<strong>{artifact.setName}</strong>
|
||||
<small>{artifact.details}</small>
|
||||
<section className="build-preview-page" aria-labelledby="build-preview-title" aria-busy={isLoadingBuildFit}>
|
||||
<div className="build-preview-hero">
|
||||
<div className="build-preview-copy">
|
||||
<span className="build-preview-kicker"><Sparkles size={14} aria-hidden="true" /> {copy.heroKicker}</span>
|
||||
<h2 id="build-preview-title">{hasSuggestions ? copy.heroReadyTitle : copy.heroEmptyTitle}</h2>
|
||||
<p>
|
||||
{hasSuggestions
|
||||
? copy.heroReadyDescription
|
||||
: copy.heroEmptyDescription}
|
||||
</p>
|
||||
<div className="build-preview-actions">
|
||||
<button className="ghost-button build-inventory-action" type="button" onClick={onOpenInventory}>
|
||||
<Archive size={16} aria-hidden="true" />
|
||||
{artifactCount > 0 ? copy.openArtifacts(artifactCount) : copy.prepareArtifacts}
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button build-refresh-action"
|
||||
type="button"
|
||||
onClick={() => void refreshBuildFit()}
|
||||
disabled={isLoadingBuildFit}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" className={isLoadingBuildFit ? "spin" : ""} />
|
||||
{isLoadingBuildFit ? copy.loadingEvidence : copy.refreshEvidence}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasWarnings && (
|
||||
<div className="warning-list">
|
||||
{warnings.map((warning) => (
|
||||
<span key={warning}><AlertTriangle size={14} />{warning}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={`build-preview-lock ${hasSuggestions ? "ready" : ""}`} aria-label={hasSuggestions ? copy.suggestionsAvailable : copy.suggestionsWaiting}>
|
||||
<div className="build-orbit" aria-hidden="true"><Orbit size={64} /></div>
|
||||
<div className="build-lock-core">{hasSuggestions ? <ShieldCheck size={27} aria-hidden="true" /> : <LockKeyhole size={27} aria-hidden="true" />}</div>
|
||||
<strong>{hasSuggestions ? copy.suggestionCount(suggestions.length) : copy.noSuggestionTitle}</strong>
|
||||
<span>{hasSuggestions ? copy.nativeChecked(nativeResultCount) : copy.blockedDetail}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="build-readiness-grid" aria-label={copy.readinessLabel}>
|
||||
<article className={hasScanData ? "complete" : "pending"}>
|
||||
<span className="build-stage-icon">{hasScanData ? <Check size={18} /> : <Archive size={18} />}</span>
|
||||
<p>{copy.stageArtifacts}</p>
|
||||
<strong>{foundationLabel}</strong>
|
||||
<small>{foundationDetail}</small>
|
||||
</article>
|
||||
<article className="complete">
|
||||
<span className="build-stage-icon"><Fingerprint size={18} /></span>
|
||||
<p>{copy.stageContract}</p>
|
||||
<strong>{contractLabel}</strong>
|
||||
<small>{contractDetail}</small>
|
||||
</article>
|
||||
<article className={hasSuggestions ? "complete" : "active"}>
|
||||
<span className="build-stage-icon"><Users size={18} /></span>
|
||||
<p>{copy.stageProfiles}</p>
|
||||
<strong>{profileLabel}</strong>
|
||||
<small>{profileDetail}</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{isLoadingBuildFit ? (
|
||||
<section className="build-empty-state" role="status" aria-live="polite">
|
||||
<RefreshCw size={19} className="spin" aria-hidden="true" />
|
||||
<div><strong>{copy.loadingTitle}</strong><p>{copy.loadingDescription}</p></div>
|
||||
</section>
|
||||
) : buildFitLoadError ? (
|
||||
<section className="build-empty-state warning" role="status">
|
||||
<AlertTriangle size={19} aria-hidden="true" />
|
||||
<div><strong>{copy.lockedTitle}</strong><p>{buildFitLoadError}</p></div>
|
||||
</section>
|
||||
) : hasSuggestions ? (
|
||||
<section className="build-suggestion-list" aria-label={copy.suggestionList}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<article key={suggestion.id} className="build-suggestion-card">
|
||||
<header>
|
||||
<div>
|
||||
<span>{suggestion.characterName}</span>
|
||||
<h3>{suggestion.targetLabel}</h3>
|
||||
</div>
|
||||
<strong title={copy.coverageTitle}>{suggestion.evidenceCoverage}% {copy.coverageLabel}</strong>
|
||||
</header>
|
||||
<p className="build-suggestion-description">{suggestion.targetDescription}</p>
|
||||
<div className="build-suggestion-meta">
|
||||
<span><ShieldCheck size={14} aria-hidden="true" />{suggestion.matchedSetPlanLabel}</span>
|
||||
<span><Fingerprint size={14} aria-hidden="true" />{copy.safeSlots(suggestion.artifacts.length)}</span>
|
||||
</div>
|
||||
<ul className="build-suggestion-artifacts" aria-label={copy.selectedArtifacts(suggestion.characterName)}>
|
||||
{suggestion.artifacts.map((artifact) => (
|
||||
<li key={artifact.artifactId}>
|
||||
<span>{slotLabel(artifact.slot, copy)}</span>
|
||||
<strong>{artifact.name}</strong>
|
||||
<small>{artifact.setName} · {artifact.mainStat} {artifact.mainValue}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="build-suggestion-reasons">
|
||||
{suggestion.reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||
</ul>
|
||||
{suggestion.warnings.length > 0 ? (
|
||||
<div className="build-suggestion-warnings">
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
<span>{suggestion.warnings.join(" ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="build-suggestion-source">{copy.sourceNote} {suggestion.sourceLabels.join(" · ")}. {copy.sourceDisclaimer}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<section className="build-empty-state" aria-label={copy.blockedProfiles}>
|
||||
<LockKeyhole size={19} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{copy.noSuggestionTitle}</strong>
|
||||
<p>{copy.noSuggestionDescription}</p>
|
||||
<ul className="build-deferred-list">
|
||||
{ranking.deferredProfiles.map((profile) => (
|
||||
<li key={profile.profileId}><strong>{profile.characterName}</strong> · {profile.targetLabel}: {deferredLabel(profile.reason, copy)} {profile.detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{aggregateContextFields.length > 0 ? (
|
||||
<section className="build-context-editor" aria-label={copy.aggregateContext}>
|
||||
<div className="build-context-editor-head">
|
||||
<Fingerprint size={17} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{copy.aggregateContext}</strong>
|
||||
<p>{copy.aggregateContextDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
{aggregateContextFields.map((field) => (
|
||||
<div key={`${field.profileId}:${field.targetKey}`} className="build-context-field">
|
||||
<div>
|
||||
<span>{field.characterName} · {field.targetLabel}</span>
|
||||
<strong>{copy.outsideArtifacts(field.stat, targetRangeLabel(field, copy))}</strong>
|
||||
<small>{field.reason}</small>
|
||||
</div>
|
||||
<label className="build-context-number">
|
||||
<span>{copy.nonArtifactValue}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
inputMode="decimal"
|
||||
value={field.value}
|
||||
onChange={(event) => setAggregateContextDraft(field.profileId, field.targetKey, { value: event.target.value })}
|
||||
aria-describedby={`${field.profileId}-${field.targetKey}-help`}
|
||||
/>
|
||||
<em>{field.unit === "percent" ? "%" : "flat"}</em>
|
||||
</label>
|
||||
<label className="build-context-confirm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.confirmed}
|
||||
onChange={(event) => setAggregateContextDraft(field.profileId, field.targetKey, { confirmed: event.target.checked })}
|
||||
/>
|
||||
<span>{copy.contextConfirm}</span>
|
||||
</label>
|
||||
<small id={`${field.profileId}-${field.targetKey}-help`} className={field.error ? "error" : ""}>
|
||||
{field.error || copy.contextPending}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="build-contract-note">
|
||||
<LockKeyhole size={18} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{copy.contractBoundary}</strong>
|
||||
<p>{copy.contractDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuildsView({ snapshot }: BuildsViewProps) {
|
||||
const { visibleBuilds } = useBuildsViewModel({ snapshot });
|
||||
|
||||
return (
|
||||
<div className="build-grid">
|
||||
{visibleBuilds.map(({ build }) => (
|
||||
<BuildCard key={build.id} build={build} snapshot={snapshot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
function slotLabel(slot: string, copy: ReturnType<typeof getBuildCopy>) {
|
||||
return copy.slots[slot] ?? slot;
|
||||
}
|
||||
|
||||
function deferredLabel(reason: string, copy: ReturnType<typeof getBuildCopy>) {
|
||||
return copy.deferredReasons[reason] ?? copy.deferredFallback;
|
||||
}
|
||||
|
||||
function targetRangeLabel(field: { minimum?: number; maximum?: number; unit: "flat" | "percent" }, copy: ReturnType<typeof getBuildCopy>) {
|
||||
const unit = field.unit === "percent" ? "%" : "";
|
||||
if (field.minimum !== undefined && field.maximum !== undefined) return `(${field.minimum}${unit}–${field.maximum}${unit})`;
|
||||
if (field.minimum !== undefined) return copy.rangeAtLeast(field.minimum, unit);
|
||||
if (field.maximum !== undefined) return copy.rangeAtMost(field.maximum, unit);
|
||||
return "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user