store-only apps edit their listing on the store (storebound listing live / set: old -> new per field with the checks, a PATCH of only the edited fields; serve listing.set is the one store-only write, its token bound to the store ids; policy.rs STORE_ONLY_WRITES, write_with_confirm for store targets); screenshot captions per language and scene edited as a text edit of the shots copy JSON (JSON files keep their layout); Play content rating, target audience and App content answers in App info; docs/console-parity.md after the editors (73 rows: 55 complete, 18 with the reason) and tests/console-parity.test.ts keeping it honest

This commit is contained in:
CRTkafa 2026-10-14 00:53:38 +08:00
parent 3cea2af4e1
commit 7ac028421a
33 changed files with 852 additions and 157 deletions

View file

@ -98,6 +98,9 @@ function Declarations({ project, data }: { project: Project; data: EditableResul
</div>
{data.play && <JsonField project={project} area="appinfo" path="dataSafety.data" saved={ds.data ?? null} label={t('ed.dsTypes')} hint={t('ed.dsTypesHint')} />}
{data.apple && <JsonField project={project} area="appinfo" path="appPrivacy" saved={data.config.appPrivacy} label={t('ed.apAnswers')} hint={t('ed.apHint')} />}
{data.play && <JsonField project={project} area="appinfo" path="console.contentRating" saved={obj(data.config.console).contentRating ?? null} label={t('ed.contentRating')} rows={6} hint={t('ed.contentRatingHint')} />}
{data.play && <JsonField project={project} area="appinfo" path="console.targetAudience" saved={obj(data.config.console).targetAudience ?? null} label={t('ed.targetAudience')} rows={4} hint={t('ed.noApiHint')} />}
{data.play && <JsonField project={project} area="appinfo" path="console.appContent" saved={obj(data.config.console).appContent ?? null} label={t('ed.appContent')} rows={4} hint={t('ed.appContentHint')} />}
</Card>
);
}

View file

@ -1,6 +1,7 @@
// Store art editor (Store art tab): the order of the screenshot scenes (the store shows them in this order) and the
// raw frame each scene shows (replace a screenshot by pointing the scene at another frame). Saved into the config;
// then render, and upload to each store with the dry run in the window (uploads replace the store's sets).
import { useState } from 'react';
import type { EditableResult, Project } from '../../api';
import { t } from '../../i18n';
import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor';
@ -44,7 +45,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
))}
</ol>
</Card>
<EditBar project={project} area="art" data={data} files={[data.files.config]} onSaved={onSaved} />
{data.shotsCopy && <Captions project={project} data={data} scenes={scenes.map(s => s.id)} />}
<EditBar project={project} area="art" data={data} files={[data.files.config, data.files.shotsCopy ?? null]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyArtSub')}>
<div className="act-grid">
<div className="act-col"><div className="act-head"><Icon name="wand" />{t('act.render')}</div><ApplyPanel project={project} method="shots.render" label={t('act.renderShots')} done={t('act.rendered')} blocked={unsaved} /></div>
@ -55,3 +57,35 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
</div>
);
}
/** Screenshot captions per language and scene (the shots copy file): eyebrow, the two title lines, the subline. */
function Captions({ project, data, scenes }: { project: Project; data: EditableResult; scenes: string[] }) {
const copy = data.shotsCopy ?? {};
const langs = data.languages.map(l => l.id);
const [lang, setLang] = useState(langs[0] ?? '');
const L = langs.includes(lang) ? lang : langs[0] ?? '';
return (
<Card title={t('ed.captionsTitle')} icon="text" sub={t('ed.captionsSub', { file: data.files.shotsCopy ?? '' })} id="captions-editor">
<div className="row" style={{ gap: 8, flexWrap: 'wrap', marginBottom: 12 }} role="tablist" aria-label={t('col.language')}>
{langs.map(x => <button key={x} role="tab" aria-selected={x === L} className={`btn btn-sm${x === L ? ' btn-primary' : ''}`} onClick={() => setLang(x)}><Icon name="globe" />{x}</button>)}
</div>
<div className="ed-langs">
{scenes.map(id => {
const c = copy[L]?.[id] ?? {};
const title = Array.isArray(c.title) ? c.title : [];
return (
<div className="ed-lang" key={id} data-caption={id}>
<div className="small strong mono-id">{id}</div>
<div className="ed-grid">
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'eyebrow' }} saved={typeof c.eyebrow === 'string' ? c.eyebrow : ''} label={t('ed.eyebrow')} limit={40} />
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'title', line: 0 }} saved={title[0] ?? ''} label={t('ed.titleLine', { n: 1 })} limit={30} />
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'title', line: 1 }} saved={title[1] ?? ''} label={t('ed.titleLine', { n: 2 })} limit={30} />
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'sub' }} saved={typeof c.sub === 'string' ? c.sub : ''} label={t('ed.subline')} limit={90} />
</div>
</div>
);
})}
</div>
</Card>
);
}

View file

@ -0,0 +1,89 @@
// The listing editor of a store-only app (no app folder, so no files to save into): the live texts per language
// (listing.live), edited in place with the same counters and checks, and applied straight to the store per language
// (listing.set: the store's dry run in the window, the native dialog, then the real run, which changes only the edited
// fields). URLs, contact and app details stay with an app folder (they come from its config).
import { useState } from 'react';
import type { Project } from '../../api';
import { type Key, t } from '../../i18n';
import { appleLocaleIssues, playLangIssues, type FieldIssue } from '../../../../../cli/lib/listing-rules';
import type { LiveListings } from '../../../../../cli/listing-store';
import type { UnifiedApp } from '../../lib/identity';
import { keyOf } from '../../lib/edits';
import { useRead } from '../../store';
import { ApplyPanel, type FieldIssueView, TextField, useEdits } from '../../ui/Editor';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
const PLAY: { f: 'title' | 'short' | 'full'; label: Key; limit: number; rows?: number }[] = [
{ f: 'title', label: 'lst.title', limit: 30 }, { f: 'short', label: 'lst.short', limit: 80 }, { f: 'full', label: 'lst.full', limit: 4000, rows: 10 },
];
const APPLE: { f: 'name' | 'subtitle' | 'promotionalText' | 'keywords' | 'description' | 'whatsNew'; label: Key; limit: number; rows?: number }[] = [
{ f: 'name', label: 'lst.name', limit: 30 }, { f: 'subtitle', label: 'lst.subtitle', limit: 30 }, { f: 'promotionalText', label: 'lst.promo', limit: 170, rows: 3 },
{ f: 'keywords', label: 'lst.keywords', limit: 100 }, { f: 'description', label: 'lst.description', limit: 4000, rows: 10 }, { f: 'whatsNew', label: 'lst.whatsNew', limit: 4000, rows: 4 },
];
const byField = (issues: FieldIssue[], f: string): FieldIssueView[] => issues.filter(i => i.field === f).map(i => ({ level: i.level, msg: i.msg }));
function Column({ project, store, lang, saved, appName, editable }: { project: Project; store: 'play' | 'apple'; lang: string; saved: Record<string, string>; appName: string; editable: boolean }) {
const e = useEdits();
const defs = store === 'play' ? PLAY : APPLE;
const vals: Record<string, string> = {};
const changed: Record<string, string> = {};
for (const d of defs) {
const p = e.get(project.id, keyOf({ kind: 'listing', store, lang, field: d.f, value: '' }));
vals[d.f] = p && p.op.kind === 'listing' ? p.op.value : saved[d.f] ?? '';
if (p && p.op.kind === 'listing') changed[d.f] = p.op.value;
}
const issues = store === 'play' ? playLangIssues(lang, { title: vals.title || undefined, short: vals.short ?? '', full: vals.full ?? '' }, appName)
: appleLocaleIssues(lang, vals, appName);
const errors = issues.filter(i => i.level === 'error' && changed[i.field] !== undefined).length;
const fields = JSON.stringify(changed);
const n = Object.keys(changed).length;
return (
<div className="ed-col" data-store={store} data-lang={lang}>
<div className="act-head"><StoreGlyph store={store === 'play' ? 'android' : 'ios'} />{store === 'play' ? 'Google Play' : 'App Store'}<span className="mono-id small muted">{lang}</span></div>
{store === 'apple' && !editable && <Notice tone="info" icon="info">{t('ed.noEditableVersion')}</Notice>}
{defs.map(d => (
<TextField key={d.f} project={project} area="listings" op={{ kind: 'listing', store, lang, field: d.f }} saved={saved[d.f] ?? ''} label={t(d.label)} limit={d.limit}
multiline={!!d.rows} rows={d.rows} mono={d.f === 'keywords'} disabled={store === 'apple' && !editable && !['name', 'subtitle', 'promotionalText'].includes(d.f)}
issues={byField(issues, d.f)} />
))}
<ApplyPanel project={project} method="listing.set" args={{ store, lang, fields }} label={t(store === 'play' ? 'ed.storeOnlyApplyPlay' : 'ed.storeOnlyApplyApple', { lang })} done={t('ai.pushed')}
disabled={!n || errors > 0} hint={errors ? t('ed.fixFirst') : undefined} blocked={null}
onApplied={() => { for (const f of Object.keys(changed)) e.set(project.id, { kind: 'listing', store, lang, field: f, value: changed[f]! }, 'listings', changed[f]); }} />
</div>
);
}
export function StoreListingEditor({ app }: { app: UnifiedApp }) {
const r = useRead<LiveListings>(app.target, 'listing.live', { network: true, title: t('tab.listings') });
const [lang, setLang] = useState('');
// the window's edit store is keyed by app: a store-only app is keyed by its store target
const project = { id: app.target, path: '', name: app.name, playAccount: null, appleAccount: null, added: '' } as Project;
if (!r.data) {
if (r.loading && !r.error) return <Card><Skeleton lines={6} block /></Card>;
return <Card pad={false}><Empty illo="text" title={t('ed.liveEmpty')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('tf.read')}</button>}>{r.error ?? t('ed.storeOnlyNote')}</Empty></Card>;
}
const L = r.data;
const langs = [...new Set([...(L.play ?? []).map(x => x.language), ...(L.apple?.locales ?? []).map(x => x.locale)])].sort();
const cur = langs.includes(lang) ? lang : langs[0] ?? '';
const pl = L.play?.find(x => x.language === cur);
const al = L.apple?.locales.find(x => x.locale === cur);
return (
<>
{Object.entries(L.errors).map(([s, e]) => <ErrorNotice key={s} error={`${s === 'play' ? 'Google Play' : 'App Store'}: ${e}`} />)}
<Card title={t('ed.storeOnlyTitle')} icon="edit" sub={t('ed.storeOnlySub')} actions={<ReadControl r={r} />} id="store-listing-editor">
<div className="stack" style={{ gap: 12 }}>
<Notice tone="info" icon="info">{t('ed.storeOnlyNote')}</Notice>
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }} role="tablist" aria-label={t('col.language')}>
{langs.map(x => <button key={x} role="tab" aria-selected={x === cur} className={`btn btn-sm${x === cur ? ' btn-primary' : ''}`} onClick={() => setLang(x)} data-lang-tab={x}><Icon name="globe" />{x}</button>)}
</div>
<div className="ed-cols">
{pl && <Column project={project} store="play" lang={cur} saved={{ title: pl.title, short: pl.short, full: pl.full }} appName={app.name} editable />}
{al && <Column project={project} store="apple" lang={cur} saved={al.values as Record<string, string>} appName={app.name} editable={L.apple!.editable} />}
{!pl && !al && <Notice tone="warn">{t('ed.noStoreForLang', { lang: cur })}</Notice>}
</div>
</div>
</Card>
</>
);
}