232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
import {
|
||
AlertTriangle,
|
||
Archive,
|
||
Check,
|
||
Fingerprint,
|
||
LockKeyhole,
|
||
Orbit,
|
||
RefreshCw,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
Users,
|
||
} from "lucide-react";
|
||
import { useBuildsViewModel } from "./hooks/useBuildsViewModel";
|
||
import type { BuildsViewProps } from "./types";
|
||
import { useI18n } from "../../i18n";
|
||
import { getBuildCopy } from "./buildsCopy";
|
||
|
||
export function BuildsView({ snapshot, onOpenInventory }: BuildsViewProps) {
|
||
const { locale } = useI18n();
|
||
const copy = getBuildCopy(locale);
|
||
const {
|
||
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="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>
|
||
|
||
<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>
|
||
);
|
||
}
|
||
|
||
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 "";
|
||
}
|