280 lines
18 KiB
TypeScript
280 lines
18 KiB
TypeScript
// Releases > send for review: the owner's buttons, each a plan first (ui/OwnerAction.tsx), then a native dialog, then
|
|
// the host-minted token. App Store: the submission state from `apple status` (waiting for / in review, back from App
|
|
// Review with UNRESOLVED_ISSUES and its items, empty drafts labelled harmless), the uploaded builds to pick by number
|
|
// (never "the latest"), Submit for review (`apple submit --build n`), Resubmit after a rejection (`apple resubmit
|
|
// --build n [--resolve-iaps]`) and in-app purchases to review (`apple iap-submit [--with-version]`). Google Play: the
|
|
// versionCodes on Play to pick by number and Send to production (`play release --version-code n --production ...`),
|
|
// the managed-publishing warning, and the console steps Play has no API for (production countries, "Send changes for
|
|
// review" in Publishing overview) as console plans with a deep link.
|
|
// v2: each store is a numbered checklist (ready / blocked / to do per step) that ends in its one primary button.
|
|
import { type ReactNode, useState } from 'react';
|
|
import { api, type Project } from '../../api';
|
|
import { type Key, t } from '../../i18n';
|
|
import { ago, date } from '../../lib/format';
|
|
import { type AppleStatusData, appleSubmitState, platformFlag, platformLabel, type PlayTrackData, playSubmitState, type SubmissionKind } from '../../lib/submit';
|
|
import type { UnifiedApp } from '../../lib/identity';
|
|
import { useData, useRead } from '../../store';
|
|
import { ConsolePlanButton } from '../../ui/ConsolePlan';
|
|
import { Icon, StoreGlyph } from '../../ui/icons';
|
|
import { Chip, ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
|
|
import { EmptyState, StatusPill } from '../../ui/ds';
|
|
import { OwnerAction } from '../../ui/OwnerAction';
|
|
import { ascUrl } from '../AppView';
|
|
import { playUrlFor } from '../../ui/app-menu';
|
|
import '../../styles/submit.css';
|
|
|
|
const SUB_KIND: Record<SubmissionKind, ['ok' | 'warn' | 'bad' | 'info' | 'neutral', Key]> = {
|
|
waiting: ['info', 'sub.kWaiting'], inReview: ['info', 'sub.kInReview'], unresolved: ['bad', 'sub.kUnresolved'], draft: ['neutral', 'sub.kDraft'],
|
|
emptyDraft: ['neutral', 'sub.kEmptyDraft'], done: ['ok', 'sub.kDone'], other: ['neutral', 'sub.kOther'],
|
|
};
|
|
|
|
type StepState = 'ready' | 'blocked' | 'todo' | 'warn';
|
|
const STEP_PILL: Record<StepState, ['ok' | 'bad' | 'neutral' | 'warn', Key]> = {
|
|
ready: ['ok', 'v2rel.ready'], blocked: ['bad', 'v2rel.blocked'], todo: ['neutral', 'v2rel.todo'], warn: ['warn', 'v2rel.attention'],
|
|
};
|
|
|
|
/** One numbered step of a send-for-review checklist: node (number, check or alert), title, state pill, body. */
|
|
function Step({ n, state: st, title, children }: { n: number; state: StepState | undefined; title: string; children?: ReactNode }) {
|
|
const state = st ?? 'todo';
|
|
const [tone, key] = STEP_PILL[state];
|
|
return (
|
|
<li className={`sub-step ${state}`} data-step-state={state}>
|
|
<span className="sub-node" aria-hidden="true">{state === 'ready' ? <Icon name="check" /> : state === 'blocked' ? <Icon name="alertCircle" /> : <span className="num">{n}</span>}</span>
|
|
<div className="sub-step-b">
|
|
<div className="sub-step-h"><span className="sub-step-t">{title}</span><StatusPill tone={tone}>{t(key)}</StatusPill></div>
|
|
{children}
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** The head of a store's send card: store mark, title, read state line, ready count, read / console buttons. */
|
|
function SubHead({ store, title, sub, actions, done, total }: { store: 'ios' | 'android'; title: string; sub: ReactNode; actions: ReactNode; done: number | null; total: number }) {
|
|
return (
|
|
<header className="sub-head">
|
|
<span className="sub-mark"><StoreGlyph store={store} /></span>
|
|
<div className="sub-head-t">
|
|
<h3>{title}</h3>
|
|
<div className="sub-head-s">{sub}</div>
|
|
</div>
|
|
{done !== null && (
|
|
<span className={`sub-count${done === total ? ' all' : ''}`} title={t('v2rel.readyCount', { done, total })}>
|
|
<span aria-hidden="true"><span className="num">{done}</span><span className="of">/{total}</span></span>
|
|
<span className="sr">{t('v2rel.readyCount', { done, total })}</span>
|
|
</span>
|
|
)}
|
|
<div className="sub-head-a actions">{actions}</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/** The App Store card: submission state, build picker, Submit / Resubmit / in-app purchases. */
|
|
export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Project }) {
|
|
const d = useData();
|
|
const r = useRead<AppleStatusData>(project.id, 'apple.status', { network: true, title: t('sub.appleRead'), appName: app.name });
|
|
const s = r.data ? appleSubmitState(r.data) : null;
|
|
const [build, setBuild] = useState('');
|
|
const [resolveIaps, setResolveIaps] = useState(false);
|
|
const [withVersion, setWithVersion] = useState(false);
|
|
const summary = d.statusOf(project)?.summary;
|
|
const iaps = summary?.catalog.ios.length ?? 0;
|
|
const picked = s?.builds.find(b => b.number === build && b.valid) ?? null;
|
|
const version = s?.unresolved ? s.versions.find(v => s.unresolved!.items.some(i => i.label === `app version ${v.version} (${platformLabel(v.platform)})`)) ?? s.editable : s?.editable ?? null;
|
|
const rejectedIap = s?.unresolved?.items.some(i => i.rejected && i.kind !== 'appStoreVersion') ?? false;
|
|
const args = { build, ...(version ? { version: version.version, platform: platformFlag(version.platform) } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) };
|
|
const refresh = () => { r.refresh(); };
|
|
const open = s ? s.submissions.filter(x => x.kind !== 'done') : [];
|
|
const states: StepState[] = s ? [
|
|
'ready',
|
|
s.busy ? 'blocked' : s.unresolved ? 'warn' : 'ready',
|
|
picked ? 'ready' : s.builds.length ? 'todo' : 'blocked',
|
|
version ? 'ready' : 'blocked',
|
|
picked && version && !s.busy ? 'ready' : 'todo',
|
|
] : [];
|
|
const done = s ? states.filter(x => x === 'ready').length : null;
|
|
return (
|
|
<article className="card sub-card" data-store="ios">
|
|
<SubHead store="ios" title={t('sub.appleTitle')} done={done} total={5}
|
|
sub={r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.appleSub')}
|
|
actions={<>
|
|
<ReadControl r={{ ...r, refresh: async () => refresh() }} label={t('sub.read')} />
|
|
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(ascUrl(app.ios?.app.id ?? summary?.apple?.appId)).catch(() => {})}><Icon name="external" />App Store Connect</button>
|
|
</>} />
|
|
<div className="sub-body">
|
|
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
|
{!s ? (r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <EmptyState icon="cloud"><b>{t('sub.notRead')}</b> {t('sub.notReadText')}</EmptyState>) : (
|
|
<ol className="sub-steps" aria-label={t('v2rel.steps')}>
|
|
<Step n={1} state={states[0]} title={t('v2rel.stepRead')}>
|
|
{open.length > 0 ? (
|
|
<div className="sub-list" aria-label={t('sub.submissions')}>
|
|
{open.slice(0, 6).map(x => {
|
|
const [tone, key] = SUB_KIND[x.kind];
|
|
return (
|
|
<div className={`sub-item tone-${tone}`} key={x.id} data-sub-kind={x.kind}>
|
|
<span className="sub-item-ic"><Icon name={x.kind === 'unresolved' ? 'alertCircle' : x.kind === 'emptyDraft' ? 'info' : 'clock'} /></span>
|
|
<div className="sub-item-m">
|
|
<span className="sub-item-t">{t(key)}</span>
|
|
<span className="sub-item-s"><code className="selectable">{x.id}</code> · {x.state}{x.date ? ` · ${date(x.date)}` : ''}{x.kind === 'emptyDraft' ? ` · ${t('sub.harmless')}` : ''}</span>
|
|
{x.items.length > 0 && (
|
|
<ul className="note-list">
|
|
{x.items.map(i => (
|
|
<li key={i.id || i.label}><b>{i.label}</b>: {i.state}{i.rejected ? ` · ${i.kind === 'appStoreVersion' ? t('sub.markVersion') : t('sub.markIap')}` : ''}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
<Chip tone={tone}>{x.state}</Chip>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : <span className="sub-hint">{t('sub.noOpen')}</span>}
|
|
</Step>
|
|
<Step n={2} state={states[1]} title={t('v2rel.stepNoOpen')}>
|
|
{s.busy && <Notice tone="info" icon="clock">{t('sub.busy', { state: s.busy.state })}</Notice>}
|
|
{!s.busy && s.unresolved && <Notice tone="bad" icon="alertCircle">{t('sub.unresolvedText')}</Notice>}
|
|
</Step>
|
|
{!s.busy && (
|
|
<>
|
|
<Step n={3} state={states[2]} title={t('v2rel.stepBuild')}>
|
|
<label className="sub-field">
|
|
<span className="sr">{t('sub.build')}</span>
|
|
<select className="input" value={build} onChange={e => setBuild(e.target.value)} aria-describedby="sub-build-hint">
|
|
<option value="">{t('sub.pickBuild')}</option>
|
|
{s.builds.map(b => (
|
|
<option key={b.id} value={b.number} disabled={!b.valid}>
|
|
{`${b.number}${b.version ? ` · ${b.version}` : ''} · ${b.state}${b.uploaded ? ` · ${date(b.uploaded)}` : ''}${b.attachedTo ? ` · ${t('sub.attachedTo', { version: b.attachedTo })}` : ''}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<span id="sub-build-hint" className="sub-hint">{s.builds.length ? t('sub.buildHint') : t('sub.noBuilds')}</span>
|
|
</Step>
|
|
<Step n={4} state={states[3]} title={t('v2rel.stepVersion')}>
|
|
<div className="sub-version">
|
|
<span className="sr">{t('sub.version')}</span>
|
|
{version ? <><span className="sub-version-v num">{version.version}</span><span className="sub-hint">{platformLabel(version.platform)} · {version.state}</span></> : <Chip tone="warn" icon="question">{t('sub.noVersion')}</Chip>}
|
|
</div>
|
|
</Step>
|
|
<Step n={5} state={states[4]} title={s.unresolved ? t('sub.resubmit') : t('v2rel.stepSend')}>
|
|
{s.unresolved ? (
|
|
<>
|
|
<label className="row small sub-check"><input type="checkbox" checked={resolveIaps} onChange={e => setResolveIaps(e.target.checked)} />{t('sub.resolveIaps')}{rejectedIap ? ` ${t('sub.resolveIapsNeeded')}` : ''}</label>
|
|
<div className="actions sub-go">
|
|
<OwnerAction project={project} method="owner.apple_resubmit" args={args} label={t('sub.resubmit')} icon="send" primary disabled={!picked || !version}
|
|
hint={!picked ? t('sub.pickFirst') : undefined} done={t('sub.resubmitted')} lead={t('sub.resubmitLead')} after={refresh} />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="actions sub-go">
|
|
<OwnerAction project={project} method="owner.apple_submit" args={args} label={t('sub.submit')} icon="send" primary disabled={!picked || !version}
|
|
hint={!picked ? t('sub.pickFirst') : undefined} done={t('sub.submitted')} lead={t('sub.submitLead')} after={refresh} />
|
|
</div>
|
|
)}
|
|
</Step>
|
|
</>
|
|
)}
|
|
</ol>
|
|
)}
|
|
{s && !s.busy && iaps > 0 && (
|
|
<div className="sub-extra">
|
|
<div className="sub-extra-h"><Icon name="tag" /><span className="strong">{t('sub.iapTitle', { count: iaps })}</span></div>
|
|
<span className="sub-hint">{t('sub.iapText')}</span>
|
|
<div className="row" style={{ gap: 10, flexWrap: 'wrap' }}>
|
|
<label className="row small" style={{ gap: 8 }}><input type="checkbox" checked={withVersion} onChange={e => setWithVersion(e.target.checked)} />{t('sub.withVersion')}</label>
|
|
<OwnerAction project={project} method="owner.apple_iap_submit" args={withVersion ? { with_version: true } : {}} label={t('sub.iapSubmit')} icon="tag"
|
|
done={t('sub.submitted')} lead={t('sub.iapLead')} after={refresh} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
const SHARES = [1, 2, 5, 10, 20, 50];
|
|
|
|
/** The Google Play card: versionCode picker, Send to production, managed publishing, the console steps without an API. */
|
|
export function PlayProductionCard({ app, project }: { app: UnifiedApp; project: Project }) {
|
|
const d = useData();
|
|
const r = useRead<{ tracks?: PlayTrackData[] }>(project.id, 'play.status', { network: true, title: t('sub.playRead'), appName: app.name });
|
|
const s = r.data ? playSubmitState(r.data) : null;
|
|
const pr = d.statusOf(project)?.summary?.playRelease ?? null;
|
|
const [code, setCode] = useState('');
|
|
const [status, setStatus] = useState<'completed' | 'inProgress' | 'draft'>('completed');
|
|
const [share, setShare] = useState('10');
|
|
const picked = s?.codes.find(c => c.code === code) ?? null;
|
|
const args = { version_code: code, status, ...(status === 'inProgress' ? { percent: Number(share) } : {}) };
|
|
const managed = pr && pr.managedPublishing !== true ? <Notice tone="warn" icon="alert">{pr.managedPublishing === false ? t('sub.managedOff') : t('sub.managedUnset')}</Notice>
|
|
: pr && pr.managedPublishing === true ? <Notice tone="info" icon="info">{t('sub.managedOn')}</Notice> : null;
|
|
const states: StepState[] = s ? [
|
|
pr && pr.managedPublishing !== true ? 'warn' : 'ready',
|
|
picked ? 'ready' : s.codes.length ? 'todo' : 'blocked',
|
|
'ready',
|
|
picked ? 'ready' : 'todo',
|
|
] : [];
|
|
const done = s ? states.filter(x => x === 'ready').length : null;
|
|
return (
|
|
<article className="card sub-card" data-store="android">
|
|
<SubHead store="android" title={t('sub.playTitle')} done={done} total={4}
|
|
sub={r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.playSub')}
|
|
actions={<>
|
|
<ReadControl r={r} label={t('sub.read')} />
|
|
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(playUrlFor(d, app)).catch(() => {})}><Icon name="external" />Play Console</button>
|
|
</>} />
|
|
<div className="sub-body">
|
|
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
|
{!s ? (
|
|
<>
|
|
{managed}
|
|
{r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <EmptyState icon="cloud"><b>{t('sub.notRead')}</b> {t('sub.playNotReadText')}</EmptyState>}
|
|
</>
|
|
) : (
|
|
<ol className="sub-steps" aria-label={t('v2rel.steps')}>
|
|
<Step n={1} state={states[0]} title={t('v2rel.stepRead')}>
|
|
{managed}
|
|
{s.production.length > 0 && (
|
|
<span className="sub-hint">{t('sub.prodNow')}: {s.production.map(p => `${p.name ?? p.codes.join(', ')} · ${p.status}${p.fraction !== null ? ` · ${Math.round(p.fraction * 100)} %` : ''}`).join('; ')}</span>
|
|
)}
|
|
</Step>
|
|
<Step n={2} state={states[1]} title={t('v2rel.stepCode')}>
|
|
<label className="sub-field">
|
|
<span className="sr">{t('sub.versionCode')}</span>
|
|
<select className="input" value={code} onChange={e => setCode(e.target.value)} aria-describedby="sub-code-hint">
|
|
<option value="">{t('sub.pickCode')}</option>
|
|
{s.codes.map(c => <option key={c.code} value={c.code} disabled={c.inProduction}>{`${c.code}${c.name ? ` · ${c.name}` : ''} · ${c.tracks.join(', ')}${c.inProduction ? ` · ${t('sub.inProduction')}` : ''}`}</option>)}
|
|
</select>
|
|
</label>
|
|
<span id="sub-code-hint" className="sub-hint">{s.codes.length ? t('sub.codeHint') : t('sub.noCodes')}</span>
|
|
</Step>
|
|
<Step n={3} state={states[2]} title={t('sub.as')}>
|
|
<div className="row" style={{ gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<Seg value={status} onChange={setStatus} label={t('sub.as')} options={[['completed', t('sub.sCompleted')], ['inProgress', t('sub.sStaged')], ['draft', t('sub.sDraft')]]} />
|
|
{status === 'inProgress' && (
|
|
<label className="row small" style={{ gap: 8 }}>
|
|
<span className="strong">{t('roll.share')}</span>
|
|
<select className="input input-sm" value={share} onChange={e => setShare(e.target.value)}>{SHARES.map(x => <option key={x} value={x}>{x} %</option>)}</select>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</Step>
|
|
<Step n={4} state={states[3]} title={status === 'draft' ? t('sub.playDraft') : t('sub.playSend')}>
|
|
<div className="actions sub-go">
|
|
<OwnerAction project={project} method="owner.play_production" args={args} label={status === 'draft' ? t('sub.playDraft') : t('sub.playSend')} icon="send" primary={status !== 'draft'}
|
|
disabled={!picked} hint={!picked ? t('sub.pickCodeFirst') : undefined} done={status === 'draft' ? t('sub.playDrafted') : t('sub.playSent')} lead={status === 'draft' ? t('sub.playDraftLead') : t('sub.playLead')} after={() => { r.refresh(); }} />
|
|
</div>
|
|
</Step>
|
|
</ol>
|
|
)}
|
|
<div className="sub-extra">
|
|
<div className="sub-extra-h"><Icon name="clipboard" /><span className="strong">{t('sub.consoleStepTitle')}</span></div>
|
|
<span className="sub-hint">{t('sub.consoleStepText', { countries: pr?.countries ? t('sub.countriesN', { count: pr.countries }) : t('sub.countriesAll') })}</span>
|
|
<div className="actions">
|
|
<ConsolePlanButton project={project} form="play-production-countries" label={t('cp.playCountries')} />
|
|
<ConsolePlanButton project={project} form="play-production-release" label={t('sub.sendChanges')} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|