desktop: Releases > Send for review (App Store submission state, builds by number, Submit / Resubmit / in-app purchases; Play versionCodes by number, Send to production, managed publishing, console plans for countries and Send changes for review), plan panel before every owner dialog with streamed progress; App info console steps for every console plan form with verify; Catalog price-change owner actions and the declined state; Data safety deletion answer; TestFlight create missing groups; Android SDK in Dependencies; upload progress in job toasts; boot check send-for-review cases on D1 and D3 state

This commit is contained in:
CRTkafa 2026-10-12 12:07:59 +08:00
parent 33eb24a3dc
commit 2b101f0fd8
22 changed files with 1527 additions and 57 deletions

View file

@ -5,14 +5,15 @@
// The forms without an API (App Privacy, age rating, the Play questionnaires, a missing App Store app record) have a
// "Fill in console" button (ui/ConsoleFill.tsx): storebound fills them in a visible browser, the owner saves.
import { useState } from 'react';
import { api, type DeclarationsResult, type DeclAsk, type Project } from '../../api';
import { api, type ConsoleForm, type DeclarationsResult, type DeclAsk, type Project } from '../../api';
import { type Key, t } from '../../i18n';
import { declarationsMd, exportName } from '../../lib/exports';
import { ago, num } from '../../lib/format';
import { useData, useRead, useWrite } from '../../store';
import { useExports, useRunExport } from '../../ui/Exports';
import { Icon, StoreGlyph } from '../../ui/icons';
import { ConsoleFillButton } from '../../ui/ConsoleFill';
import { CONSOLE_FORM_NAME, ConsoleFillButton } from '../../ui/ConsoleFill';
import { ConsolePlanButton, PLAN_FORMS } from '../../ui/ConsolePlan';
import { Bar, Card, Chip, Empty, ErrorNotice, Notice, Skeleton } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
import { ascUrl } from '../AppView';
@ -45,6 +46,25 @@ function AskCard({ ask }: { ask: DeclAsk[] }) {
);
}
/** "Can users request that their data is deleted?" (PSL_SUPPORT_DATA_DELETION_BY_USER) as the CSV answers it: a
* delete-account page means yes; else dataSafety.dataDeletion. An older sidecar sends only partialDeletion. */
export function deletionView(ds: NonNullable<DeclarationsResult['dataSafety']>): { answer: 'yes' | 'no' | 'auto90' | null; source: string | null; url: string | null } {
if (ds.dataDeletion !== undefined) return { answer: ds.dataDeletion ?? null, source: ds.dataDeletionSource ?? null, url: ds.dataDeletionUrl ?? null };
return { answer: ds.partialDeletion === 'auto90' ? 'auto90' : ds.partialDeletion === true ? 'yes' : ds.partialDeletion === false ? 'no' : null, source: ds.partialDeletion === null ? null : 'dataSafety.partialDeletion', url: null };
}
function DeletionAnswer({ ds }: { ds: NonNullable<DeclarationsResult['dataSafety']> }) {
const v = deletionView(ds);
if (!v.answer) return <Chip tone="warn" icon="question">{t('ai.notAnswered')}</Chip>;
return (
<span className="stack" style={{ gap: 2 }}>
<span>{v.answer === 'auto90' ? t('ai.dsAuto90') : v.answer === 'yes' ? t('common.yes') : t('common.no')}</span>
{v.source && <span className="small muted">{t('ai.dsFrom', { source: v.source })}</span>}
{v.url && <span className="small muted selectable mono-id">{v.url}</span>}
</span>
);
}
function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsResult; onDone: () => void }) {
const w = useWrite(project.id);
const run = useRunExport();
@ -64,7 +84,7 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR
<dl className="kv">
<dt>{t('ai.dsCollects')}</dt><dd>{yes(ds.collectsData)}</dd>
<dt>{t('ai.dsEncrypted')}</dt><dd>{yes(ds.encryptedInTransit)}</dd>
{!ds.accountCreation.includes('PSL_ACM_NONE') && <><dt>{t('ai.dsDeletion')}</dt><dd>{ds.partialDeletion === 'auto90' ? t('ai.dsAuto90') : yes(ds.partialDeletion)}</dd></>}
{!ds.accountCreation.includes('PSL_ACM_NONE') && <><dt>{t('ai.dsDeletion')}</dt><dd><DeletionAnswer ds={ds} /></dd></>}
<dt>{t('ai.dsAccounts')}</dt><dd className="small">{ds.accountCreation.map(x => typeLabel(x.replace('ACM_', ''))).join(', ')}</dd>
</dl>
{up === null ? <Notice tone="info" icon="info">{t('ai.dsNever')}</Notice>
@ -125,6 +145,29 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe
);
}
/** Every console form without an API, per store: its plan (agent-first: steps, answers, never-press, verify) and,
* where storebound has the scripted fast path, "Fill in console". */
function ConsoleSteps({ project, stores }: { project: Project; stores: DeclarationsResult['stores'] }) {
const forms = PLAN_FORMS.filter(f => (f.store === 'apple' ? stores.apple : stores.play));
if (!forms.length) return null;
return (
<Card title={t('cp.title')} icon="clipboard" sub={t('cp.sub')} pad={false} id="console-steps">
<div className="list">
{forms.map(f => (
<div className="li" key={f.form} data-form={f.form}>
<span className="li-icon tone-bg-info"><StoreGlyph store={f.store === 'apple' ? 'ios' : 'android'} /></span>
<div className="li-main"><span className="li-title">{t(f.name)}</span><span className="li-sub">{t(f.text)}</span></div>
<div className="actions" style={{ flexWrap: 'nowrap' }}>
<ConsolePlanButton project={project} form={f.form} />
{f.form in CONSOLE_FORM_NAME && <ConsoleFillButton project={project} form={f.form as ConsoleForm} />}
</div>
</div>
))}
</div>
</Card>
);
}
export function AppInfoTab({ project }: { project: Project }) {
const data = useData();
const e = data.statusOf(project);
@ -192,19 +235,8 @@ export function AppInfoTab({ project }: { project: Project }) {
</Card>
</div>
)}
{d.stores.play && (
<Card title={t('cf.playTitle')} icon="clipboard" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('cf.playSub')}</span>} pad={false}>
<div className="list">
{([['play-app-record', 'cf.playAppRecord', 'cf.playAppRecordText'], ['play-content-rating', 'cf.playContentRating', 'cf.playContentRatingText'], ['play-target-audience', 'cf.playTargetAudience', 'cf.playTargetAudienceText']] as const).map(([form, title, text]) => (
<div className="li" key={form}>
<span className="li-icon tone-bg-info"><Icon name="clipboard" /></span>
<div className="li-main"><span className="li-title">{t(title)}</span><span className="li-sub">{t(text)}</span></div>
<ConsoleFillButton project={project} form={form} />
</div>
))}
</div>
</Card>
)}
<ConsoleSteps project={project} stores={d.stores} />
<p className="small muted">{t('ai.foot', { when: ago(r.at) })}</p>
</>
);

View file

@ -6,6 +6,7 @@ import { ago, num } from '../../lib/format';
import { type CatalogDiff, type CellState, parityMatrix } from '../../lib/model';
import { useData, useRead, useWrite } from '../../store';
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
import { OwnerAction } from '../../ui/OwnerAction';
import { WriteButton } from '../../ui/WriteButton';
import { Card, Chip, Empty, ErrorNotice, Kpi, Skeleton } from '../../ui/kit';
import { catalogMd, exportName } from '../../lib/exports';
@ -39,6 +40,8 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
<WriteButton run={w} icon="tag" disabled={!products} w={{ method: 'play.products', args: {}, title: t('act.playProducts'), done: t('act.synced'), after: refresh }}>{t('act.playProducts')}</WriteButton>
<WriteButton run={w} icon="refresh" disabled={!subs} w={{ method: 'play.subscriptions', args: {}, title: t('act.playSubs'), done: t('act.synced'), after: refresh }}>{t('act.playSubs')}</WriteButton>
{products > 0 && <OwnerAction project={project} method="owner.prices_play_products" args={{}} label={t('act.priceProducts')} icon="coins" done={t('act.priceDone')} lead={t('act.priceLead')} hint={t('act.priceHint')} after={refresh} />}
{subs > 0 && <OwnerAction project={project} method="owner.prices_play_subscriptions" args={{}} label={t('act.priceSubs')} icon="coins" done={t('act.priceDone')} lead={t('act.priceLead')} hint={t('act.priceHint')} after={refresh} />}
</div>
)}
{ios && (
@ -46,6 +49,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
<WriteButton run={w} icon="tag" disabled={!products} w={{ method: 'apple.iap', args: {}, title: t('act.appleIap'), done: t('act.synced'), after: refresh }}>{t('act.appleIap')}</WriteButton>
<WriteButton run={w} icon="refresh" disabled={!subs} w={{ method: 'apple.subscriptions', args: {}, title: t('act.appleSubs'), done: t('act.synced'), after: refresh }}>{t('act.appleSubs')}</WriteButton>
{subs > 0 && <OwnerAction project={project} method="owner.prices_apple_subscriptions" args={{}} label={t('act.priceSubs')} icon="coins" done={t('act.priceDone')} lead={t('act.priceLead')} hint={t('act.priceHint')} after={refresh} />}
</div>
)}
<div className="act-col">

View file

@ -1,7 +1,9 @@
// 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.
// rollout %), then every version read from the stores, then sending for review (Submit.tsx): App Store submit /
// 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 { useState } from 'react';
import { api, type ReleaseTrain, type StoreRelease } from '../../api';
import type { ReleaseTrain, StoreRelease } from '../../api';
import type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton';
import { type Key, t } from '../../i18n';
@ -11,7 +13,7 @@ import { useData, useRead, useWrite } from '../../store';
import { Icon, StoreGlyph } from '../../ui/icons';
import { RolloutBar } from '../../ui/charts';
import { Card, Chip, Empty, Notice, PhaseChip, storeName } from '../../ui/kit';
import { ascUrl, PLAY_CONSOLE } from '../AppView';
import { AppleSubmitCard, PlayProductionCard } from './Submit';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
@ -121,16 +123,29 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
const l = d.liveFor(app);
const project = app.project;
const tf = useRead<TestFlightStatus>(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight', skip: !app.ios && !d.statusFor(app)?.app?.platforms.ios });
const open = (url: string) => { api.openExternal(url).catch(() => {}); };
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' }}>
{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;
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 && !project.playAccount && !project.appleAccount && <span className="small muted">{t('releases.noAccounts')}</span>}
</>}>{t('releases.notReadText')}</Empty>
</Card>
{submit}
</>
);
}
const history = versionHistory(l.release.all ?? []);
@ -164,10 +179,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
) : <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>
{submit}
</>
);
}

View file

@ -0,0 +1,197 @@
// 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, 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) })) : t('sub.appleSub')}</span>}
actions={<>
<button className="btn btn-sm" onClick={refresh} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
<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) })) : t('sub.playSub')}</span>}
actions={<>
<button className="btn btn-sm" onClick={() => { r.refresh(); }} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
<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>
);
}

View file

@ -100,7 +100,11 @@ export function TestFlightTab({ app }: { app: UnifiedApp }) {
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" />{t('action.refresh')}</button>
</div>
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
{s.missingGroups.length > 0 && <Notice tone="warn">{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}</Notice>}
{s.missingGroups.length > 0 && (
<Notice tone="warn" action={writable ? <WriteButton run={w} icon="plus" w={{ method: 'apple.testflight_groups', args: {}, title: t('tf.groupsSync'), done: t('tf.groupsSynced'), after: r.refresh }} hint={t('tf.groupsSyncHint')}>{t('tf.groupsSync')}</WriteButton> : undefined}>
{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}
</Notice>
)}
<div className="grid-cards">
{s.groups.map(g => (
<div key={g.id} className="tf-group">