storebound/apps/desktop/src/screens/tabs/Submit.tsx

197 lines
14 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.
import { 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, 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 { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
import { OwnerAction } from '../../ui/OwnerAction';
import { ascUrl, PLAY_CONSOLE } from '../AppView';
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'],
};
/** 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}`)) ?? 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 } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) };
const refresh = () => { r.refresh(); };
return (
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{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')}</span>}
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>
</>}>
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
{!s ? (r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <Empty compact illo="cloud" title={t('sub.notRead')}>{t('sub.notReadText')}</Empty>) : (
<div className="stack">
{s.submissions.length > 0 && (
<div className="list sub-list" aria-label={t('sub.submissions')}>
{s.submissions.filter(x => x.kind !== 'done').slice(0, 6).map(x => {
const [tone, key] = SUB_KIND[x.kind];
return (
<div className="li" key={x.id} data-sub-kind={x.kind}>
<span className={`li-icon tone-bg-${tone === 'neutral' ? 'info' : tone}`}><Icon name={x.kind === 'unresolved' ? 'alertCircle' : x.kind === 'emptyDraft' ? 'info' : 'clock'} /></span>
<div className="li-main">
<span className="li-title">{t(key)}</span>
<span className="li-sub"><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>
)}
{!s.submissions.some(x => x.kind !== 'done') && <span className="small muted">{t('sub.noOpen')}</span>}
{s.busy && <Notice tone="info" icon="clock">{t('sub.busy', { state: s.busy.state })}</Notice>}
{!s.busy && (
<>
<div className="row" style={{ gap: 10, flexWrap: 'wrap', alignItems: 'end' }}>
<label className="stack" style={{ gap: 4 }}>
<span className="small strong">{t('sub.build')}</span>
<select className="input input-sm" 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>
<div className="stack small" style={{ gap: 4 }}>
<span className="strong">{t('sub.version')}</span>
<span>{version ? `${version.version} · ${version.state}` : <Chip tone="warn" icon="question">{t('sub.noVersion')}</Chip>}</span>
</div>
</div>
<span id="sub-build-hint" className="small muted">{s.builds.length ? t('sub.buildHint') : t('sub.noBuilds')}</span>
{s.unresolved ? (
<>
<Notice tone="bad" icon="alertCircle">{t('sub.unresolvedText')}</Notice>
<label className="row small" style={{ gap: 8 }}><input type="checkbox" checked={resolveIaps} onChange={e => setResolveIaps(e.target.checked)} />{t('sub.resolveIaps')}{rejectedIap ? ` ${t('sub.resolveIapsNeeded')}` : ''}</label>
<div className="actions">
<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">
<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>
)}
{iaps > 0 && (
<div className="sub-iaps stack" style={{ gap: 6 }}>
<span className="small strong">{t('sub.iapTitle', { count: iaps })}</span>
<span className="small muted">{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>
)}
</Card>
);
}
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) } : {}) };
return (
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{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')}</span>}
actions={<>
<ReadControl r={r} label={t('sub.read')} />
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>
</>}>
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
<div className="stack">
{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>}
{!s ? (r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <Empty compact illo="cloud" title={t('sub.notRead')}>{t('sub.playNotReadText')}</Empty>) : (
<>
{s.production.length > 0 && (
<span className="small">{t('sub.prodNow')}: {s.production.map(p => `${p.name ?? p.codes.join(', ')} · ${p.status}${p.fraction !== null ? ` · ${Math.round(p.fraction * 100)} %` : ''}`).join('; ')}</span>
)}
<div className="row" style={{ gap: 10, flexWrap: 'wrap', alignItems: 'end' }}>
<label className="stack" style={{ gap: 4 }}>
<span className="small strong">{t('sub.versionCode')}</span>
<select className="input input-sm" 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>
<div className="stack" style={{ gap: 4 }}>
<span className="small strong">{t('sub.as')}</span>
<Seg value={status} onChange={setStatus} label={t('sub.as')} options={[['completed', t('sub.sCompleted')], ['inProgress', t('sub.sStaged')], ['draft', t('sub.sDraft')]]} />
</div>
{status === 'inProgress' && (
<label className="stack" style={{ gap: 4 }}>
<span className="small 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>
<span id="sub-code-hint" className="small muted">{s.codes.length ? t('sub.codeHint') : t('sub.noCodes')}</span>
<div className="actions">
<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>
</>
)}
<Notice tone="info" icon="clipboard" action={<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 className="stack" style={{ gap: 2 }}>
<b>{t('sub.consoleStepTitle')}</b>
<span className="small">{t('sub.consoleStepText', { countries: pr?.countries ? t('sub.countriesN', { count: pr.countries }) : t('sub.countriesAll') })}</span>
</div>
</Notice>
</div>
</Card>
);
}