Desktop D2 UI: design system, portfolio, release timeline, catalog parity, listings, health, reviews, TestFlight, checks, command palette, toasts, first-run guide, en + tr with i18n check, 7:1 theme check, dev fixture mode and screenshot harness
This commit is contained in:
parent
95b7cf7ced
commit
2ef0769730
44 changed files with 5475 additions and 742 deletions
116
apps/desktop/src/screens/tabs/Releases.tsx
Normal file
116
apps/desktop/src/screens/tabs/Releases.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// Releases: the release timeline (one version on both stores on one track: build -> testing -> review -> live ->
|
||||
// rollout %), then every version read from the stores. Submit and release stay in the consoles.
|
||||
import { api, type Project, type ReleaseTrain } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
import { ago, date, share } from '../../lib/format';
|
||||
import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, Notice, PhaseChip, storeName } from '../../ui/kit';
|
||||
import { ascUrl, PLAY_CONSOLE } from '../AppView';
|
||||
|
||||
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
|
||||
|
||||
function stageCaption(l: Lane, i: number): string {
|
||||
if (i !== l.at || !l.release) return '';
|
||||
const r = l.release;
|
||||
if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing'));
|
||||
if (i === 2) return t(`phase.${r.phase}` as Key);
|
||||
return '';
|
||||
}
|
||||
|
||||
function LaneRow({ lane, version }: { lane: Lane; version: string }) {
|
||||
const r = lane.release;
|
||||
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 ? `${version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{lane.at < 0 ? <div className="tl-none">{t('tl.noVersion', { version, store: storeName(lane.store) })}</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"><Bar 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>
|
||||
);
|
||||
}
|
||||
|
||||
function Train({ title, train, tf, tone }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok' }) {
|
||||
const lanes = trainLanes(train, tf);
|
||||
return (
|
||||
<Card pad={false} title={<span className="train-head"><span className="ver">{train.version}</span><Chip tone={tone}>{title}</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} />)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleasesTab({ project }: { project: Project }) {
|
||||
const d = useData();
|
||||
const l = d.liveOf(project);
|
||||
const tf = useRead<TestFlightStatus>(project.id, 'apple.testflight_status', { network: true, title: 'TestFlight' });
|
||||
const open = (url: string) => { api.openExternal(url).catch(() => {}); };
|
||||
|
||||
if (!l) {
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<Empty illo="cloud" title={t('releases.notReadTitle')} actions={<>
|
||||
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
|
||||
{!project.playAccount && !project.appleAccount && <span className="small muted">{t('releases.noAccounts')}</span>}
|
||||
</>}>{t('releases.notReadText')}</Empty>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const history = versionHistory(l.release.all ?? []);
|
||||
const errs = Object.entries(l.storeErrors);
|
||||
return (
|
||||
<>
|
||||
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: msg })}</Notice>)}
|
||||
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" />}
|
||||
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" />}
|
||||
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}
|
||||
|
||||
<Card title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false}>
|
||||
{history.length ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<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">{h.version}</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>
|
||||
<td className="num muted">{h.android?.build ?? '–'}</td>
|
||||
<td className="muted">{h.android?.track ?? '–'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : <Empty compact illo="unknown" title={t('releases.noVersions')} />}
|
||||
</Card>
|
||||
|
||||
<Notice tone="info" icon="shield" action={<div className="actions">
|
||||
{l.app?.platforms.ios && <button className="btn btn-sm" onClick={() => open(ascUrl(l.app?.platforms.ios?.ascAppId))}><Icon name="external" />App Store Connect</button>}
|
||||
{l.app?.platforms.android && <button className="btn btn-sm" onClick={() => open(PLAY_CONSOLE)}><Icon name="external" />Play Console</button>}
|
||||
</div>}>{t('releases.consoleNote')}</Notice>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue