Desktop v2 screens: releases (store cards, stage stepper, rollout ring), submit checklist, TestFlight cards; listings as store pages with completeness, framed screenshots with issue badges, product cards, price matrix with gap shading; health tiles and issue cards, store-style review feed with rating distribution, growth funnel, readiness score with fix-first, tools grid, store-info sheet; settings, accounts, folders, dependencies and onboarding in the v2 language
This commit is contained in:
parent
ce939ee063
commit
f131de9df2
47 changed files with 2309 additions and 927 deletions
|
|
@ -3,7 +3,7 @@
|
|||
// resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API
|
||||
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
|
||||
import { plainError } from '../../../../../cli/lib/google-errors';
|
||||
import { useState } from 'react';
|
||||
import { type CSSProperties, type ReactNode, useState } from 'react';
|
||||
import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api';
|
||||
import { laneGap, storesDiffer } from '../../../../../cli/lib/train';
|
||||
import type { UnifiedApp } from '../../lib/identity';
|
||||
|
|
@ -14,8 +14,10 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory }
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { RolloutBar } from '../../ui/charts';
|
||||
import { Card, Chip, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
|
||||
import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds';
|
||||
import { AppleSubmitCard, PlayProductionCard } from './Submit';
|
||||
import '../../styles/releases.css';
|
||||
|
||||
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
|
||||
|
||||
|
|
@ -39,32 +41,55 @@ function gapText(lane: Lane, version: string, st: AppStatus): { who: string; lin
|
|||
return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') };
|
||||
}
|
||||
|
||||
function LaneRow({ lane, version, st }: { lane: Lane; version: string; st: AppStatus }) {
|
||||
/** One store's card of a release train: store mark, version (build), the current state as a pill, the five stages
|
||||
* as a stepper, a big rollout ring when it is rolling out, and that store's rollout controls. */
|
||||
function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: AppStatus; app: UnifiedApp }) {
|
||||
const r = lane.release;
|
||||
const gap = lane.at < 0 ? gapText(lane, version, st) : null;
|
||||
const ph = r ? phaseLabel(r) : null;
|
||||
const rolling = lane.at === STAGES.length - 1 && lane.share !== null;
|
||||
const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent';
|
||||
return (
|
||||
<div className="tl-lane">
|
||||
<div className="tl-who">
|
||||
<StoreGlyph store={lane.store} />
|
||||
<div>
|
||||
<div className="w1">{storeName(lane.store)}</div>
|
||||
<div className="w2">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}</div>
|
||||
<article className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
|
||||
<header className="rel-store-h">
|
||||
<span className="rel-mark"><StoreGlyph store={lane.store} /></span>
|
||||
<div className="rel-who">
|
||||
<div className="rel-store-name">{storeName(lane.store)}</div>
|
||||
<div className="rel-store-ver num">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}</div>
|
||||
</div>
|
||||
</div>
|
||||
{gap ? <div className="tl-none" data-gap={gap.kind}>{gap.line}</div> : STAGES.map((s, i) => {
|
||||
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : '';
|
||||
const last = s === 'rollout';
|
||||
return (
|
||||
<div key={s} className={`tl-step ${cls}${i === 0 ? ' first' : ''}`} title={i === lane.at ? r?.state : undefined}>
|
||||
<span className="tl-node">{i < lane.at && <Icon name="check" />}</span>
|
||||
{last && i === lane.at && lane.share !== null ? (
|
||||
<div className="tl-roll"><RolloutBar value={lane.share} tone={lane.tone === 'bad' ? 'bad' : lane.share >= 1 ? 'ok' : 'accent'} label={t('tl.rolloutAria')} />
|
||||
<span className="pct">{lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}</span></div>
|
||||
) : <span className="tl-cap">{stageCaption(lane, i)}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{ph && <StatusPill tone={ph.tone} title={r?.state} solid={lane.tone === 'bad'}>{ph.text}</StatusPill>}
|
||||
</header>
|
||||
{gap ? <div className="tl-none rel-gap" data-gap={gap.kind}><Icon name={gap.kind === 'unreadable' ? 'alert' : 'info'} />{gap.line}</div> : (
|
||||
<div className={`rel-body${rolling ? ' has-ring' : ''}`}>
|
||||
<ol className="rel-steps" aria-label={t('v2rel.stages', { store: storeName(lane.store) })}>
|
||||
{STAGES.map((s, i) => {
|
||||
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : 'todo';
|
||||
const cap = stageCaption(lane, i);
|
||||
return (
|
||||
<li key={s} className={`rel-step ${cls}`} title={i === lane.at ? r?.state : undefined} aria-current={i === lane.at ? 'step' : undefined}>
|
||||
<span className="rel-node">{i < lane.at ? <Icon name="check" /> : <span className="num">{i + 1}</span>}</span>
|
||||
<span className="rel-step-l">{t(STAGE_KEY[s])}</span>
|
||||
{cap && <span className="rel-step-c">{cap}</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{rolling && lane.share !== null && (
|
||||
<div className={`tl-roll rel-ring-wrap ${ringTone}`}>
|
||||
<div className="rel-ring" style={{ '--p': String(Math.round(Math.min(1, lane.share) * 100)) } as CSSProperties} aria-hidden="true">
|
||||
<span className="rel-ring-v num">{lane.share >= 1 ? '100' : Math.round(lane.share * 100)}<small>%</small></span>
|
||||
</div>
|
||||
<div className="rel-ring-side">
|
||||
<span className="pct">{lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}</span>
|
||||
<span className="rel-ring-sub">{t('v2rel.ofUsers')}</span>
|
||||
<RolloutBar value={lane.share} tone={ringTone} label={t('tl.rolloutAria')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{r && app.project && <RolloutControls app={app} r={r} />}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -122,19 +147,22 @@ function Train({ title, train, tf, tone, app, st }: { title: string; train: Rele
|
|||
// "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not
|
||||
const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title;
|
||||
const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version;
|
||||
const created = train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null;
|
||||
return (
|
||||
<Card pad={false} title={<span className="train-head"><span className="ver">{ver}</span><Chip tone={differ ? 'neutral' : tone === 'ok' && label !== title ? 'info' : tone}>{label}</Chip></span>}
|
||||
sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
|
||||
<div className="tl" style={{ paddingTop: 12 }}>
|
||||
<div className="tl-head"><span>{t('tl.store')}</span>{STAGES.map(s => <span key={s}>{t(STAGE_KEY[s])}</span>)}</div>
|
||||
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} st={st} />)}
|
||||
</div>
|
||||
{lanes.some(l => l.release) && app.project && (
|
||||
<div className="card-foot roll-foot">
|
||||
{lanes.map(l => l.release && <RolloutControls key={l.store} app={app} r={l.release} />)}
|
||||
<section className={`rel-train ${tone}`} aria-label={`${label} ${ver}`}>
|
||||
<header className="rel-train-h">
|
||||
<div className="rel-train-title">
|
||||
<span className="rel-train-eyebrow">{t('v2rel.version')}</span>
|
||||
<span className="rel-train-ver num">{ver}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<StatusPill tone={differ ? 'neutral' : tone === 'ok' && label !== title ? 'info' : tone} solid={tone === 'accent' && !differ}>{label}</StatusPill>
|
||||
<span className="grow" />
|
||||
{created && <span className="rel-train-meta"><Icon name="clock" />{created}</span>}
|
||||
</header>
|
||||
<div className="rel-lanes">
|
||||
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} st={st} app={app} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -142,22 +170,26 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
const d = useData();
|
||||
const l = d.liveFor(app);
|
||||
const project = app.project;
|
||||
const hue = useTint(d.iconFor(app), app.name);
|
||||
const tf = useRead<TestFlightStatus>(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight', skip: !app.ios && !d.statusFor(app)?.app?.platforms.ios });
|
||||
const sum = project ? d.statusOf(project)?.summary : null;
|
||||
const submit = project && (sum?.apple || sum?.play) ? (
|
||||
<section className="stack" aria-labelledby="sub-section" data-section="submit">
|
||||
<h2 id="sub-section" className="section-h row" style={{ gap: 8 }}><Icon name="send" />{t('sub.section')}</h2>
|
||||
<div className="grid g2" style={{ alignItems: 'start' }}>
|
||||
<section className="rel-submit stack" aria-labelledby="sub-section" data-section="submit">
|
||||
<div className="rel-sec-h">
|
||||
<h2 id="sub-section" className="rel-sec-title"><span className="ds-sec-ic"><Icon name="send" /></span>{t('sub.section')}</h2>
|
||||
<p className="rel-sec-sub">{t('releases.ownerNote')}</p>
|
||||
</div>
|
||||
<div className="rel-submit-grid">
|
||||
{sum?.apple && <AppleSubmitCard app={app} project={project} />}
|
||||
{sum?.play && <PlayProductionCard app={app} project={project} />}
|
||||
</div>
|
||||
<p className="small muted">{t('releases.ownerNote')}</p>
|
||||
</section>
|
||||
) : null;
|
||||
const wrap = (children: ReactNode) => <div className="scr-releases" style={tintStyle(hue)}>{children}</div>;
|
||||
|
||||
if (!l && d.portfolioBusy === 'live') return <><Card><Skeleton lines={5} block /></Card>{submit}</>;
|
||||
if (!l && d.portfolioBusy === 'live') return wrap(<><Card><Skeleton lines={5} block /></Card>{submit}</>);
|
||||
if (!l) {
|
||||
return (
|
||||
return wrap(
|
||||
<>
|
||||
<Card pad={false}>
|
||||
<Empty illo="cloud" title={t('releases.notReadTitle')} actions={<>
|
||||
|
|
@ -166,12 +198,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
</>}>{t('releases.notReadText')}</Empty>
|
||||
</Card>
|
||||
{submit}
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
const history = versionHistory(l.release.all ?? []);
|
||||
const errs = Object.entries(l.storeErrors);
|
||||
return (
|
||||
return wrap(
|
||||
<>
|
||||
{errs.map(([s, msg]) => {
|
||||
const when = l.staleStores?.[s as 'ios'];
|
||||
|
|
@ -179,17 +211,18 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
})}
|
||||
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} st={l} />}
|
||||
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} st={l} />}
|
||||
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}
|
||||
{!l.release.next && !l.release.current && <Section><EmptyState icon="layers">{t('releases.noVersions')} {t('releases.noVersionsText')}</EmptyState></Section>}
|
||||
|
||||
<Card title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false}>
|
||||
<Section title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false} className="rel-history"
|
||||
actions={history.length ? <span className="rel-count">{t('v2rel.versions', { count: history.length })}</span> : undefined}>
|
||||
{history.length ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<div className="ds-table-wrap">
|
||||
<table className="table v2">
|
||||
<thead><tr><th>{t('col.version')}</th><th>App Store</th><th>{t('col.build')}</th><th>Google Play</th><th>{t('col.versionCode')}</th><th>{t('col.track')}</th></tr></thead>
|
||||
<tbody>
|
||||
{history.map(h => (
|
||||
<tr key={h.version}>
|
||||
<td className="strong num">{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}</td>
|
||||
<td className="strong num rel-hv">{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}</td>
|
||||
<td>{h.ios ? <PhaseChip r={h.ios} /> : <span className="muted small">–</span>}</td>
|
||||
<td className="num muted">{h.ios?.build ?? '–'}</td>
|
||||
<td>{h.android ? <PhaseChip r={h.android} /> : <span className="muted small">–</span>}</td>
|
||||
|
|
@ -200,10 +233,10 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : <Empty compact illo="unknown" title={t('releases.noVersions')} />}
|
||||
</Card>
|
||||
) : <div className="ds-sec-b"><EmptyState icon="layers">{t('releases.noVersions')}</EmptyState></div>}
|
||||
</Section>
|
||||
|
||||
{submit}
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue