desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case
This commit is contained in:
parent
714352ec18
commit
3cea2af4e1
37 changed files with 3020 additions and 54 deletions
152
apps/desktop/src/screens/editors/AppInfoEditor.tsx
Normal file
152
apps/desktop/src/screens/editors/AppInfoEditor.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// App info editors (App info tab): App Store categories, age rating answers, content rights, price, export
|
||||
// compliance, App Review (sign-in, notes, contact; the demo account stays masked and is never read out); Google Play
|
||||
// store settings (category, countries, managed publishing, target ages, delete-account page); Data safety and App
|
||||
// Privacy answers; the App Store promotion blocks (promoted purchases, custom product pages, in-app events). Saved
|
||||
// into the config (and the review contact into the private file in .storebound/), then applied per store with the dry run first.
|
||||
import type { EditableResult, Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
import { AGE_FREQUENCY, AGE_RATING_NONE, APPLE_CATEGORIES, APPLE_GAME_SUBCATEGORIES, KIDS_AGE_BANDS, PLAY_AGE_GROUPS, PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from '../../../../../cli/lib/store-catalogs';
|
||||
import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, TextField, useEdits } from '../../ui/Editor';
|
||||
import { StoreGlyph } from '../../ui/icons';
|
||||
import { Card } from '../../ui/kit';
|
||||
|
||||
type O = Record<string, unknown>;
|
||||
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
const label = (id: string): string => id.replace(/^GAMES_/, '').toLowerCase().replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase());
|
||||
/** "alcoholTobaccoOrDrugUseOrReferences" -> "Alcohol tobacco or drug use or references". */
|
||||
const question = (q: string): string => q.replace(/([A-Z])/g, ' $1').toLowerCase().replace(/^\w/, c => c.toUpperCase());
|
||||
const yesNo = (): [string, string][] => [['true', t('common.yes')], ['false', t('common.no')]];
|
||||
|
||||
function AppleInfo({ project, data }: { project: Project; data: EditableResult }) {
|
||||
const apple = obj(data.config.apple);
|
||||
const info = obj(apple.info);
|
||||
const cats = obj(info.categories);
|
||||
const age = obj(info.ageRating);
|
||||
const ra = obj(data.config.reviewAccess);
|
||||
const games = cats.primary === 'GAMES';
|
||||
const catOpts = APPLE_CATEGORIES.map(c => [c, label(c)] as [string, string]);
|
||||
const subOpts = APPLE_GAME_SUBCATEGORIES.map(c => [c, label(c)] as [string, string]);
|
||||
const contact = data.contact;
|
||||
return (
|
||||
<>
|
||||
<Card title={t('ed.appleInfoTitle')} icon="clipboard" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.appleInfoSub')}</span>}>
|
||||
<div className="ed-grid">
|
||||
<ChoiceField project={project} area="appinfo" path="apple.info.categories.primary" saved={str(cats.primary)} label={t('ed.catPrimary')} options={catOpts} />
|
||||
{games && <ChoiceField project={project} area="appinfo" path="apple.info.categories.primarySubOne" saved={str(cats.primarySubOne)} label={t('ed.catSub1')} options={subOpts} />}
|
||||
{games && <ChoiceField project={project} area="appinfo" path="apple.info.categories.primarySubTwo" saved={str(cats.primarySubTwo)} label={t('ed.catSub2')} options={subOpts} />}
|
||||
<ChoiceField project={project} area="appinfo" path="apple.info.categories.secondary" saved={str(cats.secondary)} label={t('ed.catSecondary')} options={catOpts} />
|
||||
<ChoiceField project={project} area="appinfo" path="apple.info.contentRights" saved={str(info.contentRights)} label={t('ai.rights')}
|
||||
options={[['DOES_NOT_USE_THIRD_PARTY_CONTENT', t('ai.rightsOwn')], ['USES_THIRD_PARTY_CONTENT', t('ai.rightsThird')]]} />
|
||||
<ChoiceField project={project} area="appinfo" path="apple.info.free" saved={info.free === undefined ? null : (info.free as boolean)} label={t('ed.free')} options={[['true', t('ai.free')], ['false', t('ai.paid')]]} hint={t('ed.freeHint')} />
|
||||
<ChoiceField project={project} area="appinfo" path="apple.usesNonExemptEncryption" saved={apple.usesNonExemptEncryption as boolean | undefined} label={t('ai.encTitle')}
|
||||
options={[['false', t('ai.encNo')], ['true', t('ai.encYes')]]} hint={t('ed.encHint')} />
|
||||
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'apple.name' }} saved={str(apple.name)} label={t('ed.appleName')} limit={30} placeholder={data.appName} hint={t('ed.appleNameHint')} />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('ed.ageTitle')} icon="shield" sub={t('ed.ageSub')}>
|
||||
<div className="ed-grid three">
|
||||
{Object.entries(AGE_RATING_NONE).map(([q, none]) => (
|
||||
<ChoiceField key={q} project={project} area="appinfo" path={`apple.info.ageRating.${q}`} saved={age[q] === undefined ? null : (age[q] as string | boolean | null)} label={question(q)}
|
||||
options={q === 'kidsAgeBand' ? KIDS_AGE_BANDS.map(b => [b, label(b)] as [string, string]) : typeof none === 'boolean' ? yesNo() : AGE_FREQUENCY.map(f => [f, label(f)] as [string, string])}
|
||||
hint={age[q] === undefined ? t('ed.ageDefault', { value: none === null ? t('ed.none') : typeof none === 'boolean' ? t('common.no') : label(String(none)) }) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('ai.reviewTitle')} icon="users" sub={t('ed.reviewSub')}>
|
||||
<div className="ed-grid">
|
||||
<ChoiceField project={project} area="appinfo" path="reviewAccess.signInRequired" saved={ra.signInRequired === undefined ? null : (ra.signInRequired as boolean)} label={t('ai.signIn')} options={yesNo()} />
|
||||
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'apple.info.reviewNotes' }} saved={str(info.reviewNotes)} label={t('ai.notes')} multiline rows={5}
|
||||
hint={t('ed.notesHint', { max: Number(data.limits.reviewNotesBytes) })} />
|
||||
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'reviewAccess.instructions' }} saved={str(ra.instructions)} label={t('ed.signInSteps')} multiline rows={3} />
|
||||
{(['firstName', 'lastName', 'phone', 'email'] as const).map(f => (
|
||||
<TextField key={f} project={project} area="appinfo" op={{ kind: 'contact', field: f }} saved="" label={t(`ed.contact_${f}` as Key)} kind={f === 'email' ? 'email' : undefined}
|
||||
placeholder={contact?.[f] || t('ed.notSet')} hint={f === 'email' ? t('ed.contactHint') : undefined} />
|
||||
))}
|
||||
<div className="ed-field"><div className="ed-head"><label>{t('ai.demo')}</label></div><span className="mono-id">{data.demoAccount ? `${data.demoAccount} · ••••••••` : t('ed.notSet')}</span><div className="ed-hint small muted">{t('ed.demoHint')}</div></div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaySettings({ project, data }: { project: Project; data: EditableResult }) {
|
||||
const play = obj(data.config.play);
|
||||
const acc = obj(data.config.accounts);
|
||||
const cats: [string, string][] = [...PLAY_GAME_CATEGORIES.map(c => [c, `${t('ed.game')}: ${c}`] as [string, string]), ...PLAY_APP_CATEGORIES.map(c => [c, `${t('ed.app')}: ${c}`] as [string, string])];
|
||||
return (
|
||||
<Card title={t('ed.playSettingsTitle')} icon="sliders" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('ed.playSettingsSub')}</span>}>
|
||||
<div className="ed-grid">
|
||||
<ChoiceField project={project} area="appinfo" path="play.category" saved={str(play.category)} label={t('ed.playCategory')} options={cats} hint={t('ed.noApiHint')} />
|
||||
<ChoiceField project={project} area="appinfo" path="play.managedPublishing" saved={play.managedPublishing === undefined ? null : (play.managedPublishing as boolean)} label={t('ed.managed')} options={yesNo()} hint={t('ed.noApiHint')} />
|
||||
<ListField project={project} area="appinfo" path="play.countries" saved={(play.countries as string[] | undefined) ?? null} label={t('ed.countries')} upper hint={t('ed.countriesHint')} />
|
||||
<ListField project={project} area="appinfo" path="play.targetAges" saved={(play.targetAges as string[] | undefined) ?? null} label={t('ed.targetAges')} hint={t('ed.targetAgesHint', { groups: PLAY_AGE_GROUPS.join(', ') })} />
|
||||
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'accounts.deletionUrl' }} saved={str(acc.deletionUrl)} label={t('ed.deletionUrl')} kind="url" mono />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Declarations({ project, data }: { project: Project; data: EditableResult }) {
|
||||
const ds = obj(data.config.dataSafety);
|
||||
return (
|
||||
<Card title={t('ed.declTitle')} icon="lock" sub={t('ed.declSub')}>
|
||||
<div className="ed-grid">
|
||||
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.collectsData" saved={ds.collectsData === undefined ? null : (ds.collectsData as boolean)} label={t('ai.dsCollects')} options={yesNo()} />}
|
||||
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.encryptedInTransit" saved={ds.encryptedInTransit === undefined ? null : (ds.encryptedInTransit as boolean)} label={t('ai.dsEncrypted')} options={yesNo()} />}
|
||||
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.dataDeletion" saved={str(ds.dataDeletion)} label={t('ai.dsDeletion')} options={[['yes', t('common.yes')], ['no', t('common.no')], ['auto90', t('ai.dsAuto90')]]} />}
|
||||
</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')} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Promotion({ project, data }: { project: Project; data: EditableResult }) {
|
||||
const apple = obj(data.config.apple);
|
||||
return (
|
||||
<Card title={t('ed.promoTitle')} icon="sparkle" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.promoSub')}</span>}>
|
||||
<JsonField project={project} area="appinfo" path="apple.promoted" saved={apple.promoted ?? null} label={t('ed.promoted')} rows={6} hint={t('ed.promotedHint')} />
|
||||
<JsonField project={project} area="appinfo" path="apple.customProductPages" saved={apple.customProductPages ?? null} label={t('ed.cpp')} rows={8} hint={t('ed.cppHint')} />
|
||||
<JsonField project={project} area="appinfo" path="apple.events" saved={apple.events ?? null} label={t('ed.events')} rows={8} hint={t('ed.eventsHint')} />
|
||||
<div className="act-grid">
|
||||
<ApplyPanel project={project} method="apple.cpp_create" label={t('ed.applyCpp')} done={t('ai.pushed')} blocked={null} />
|
||||
<ApplyPanel project={project} method="apple.events_create" label={t('ed.applyEvents')} done={t('ai.pushed')} blocked={null} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppInfoEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
|
||||
const e = useEdits();
|
||||
const unsaved = e.count(project.id, 'appinfo') ? t('ed.saveFirst') : null;
|
||||
return (
|
||||
<div className="stack" id="appinfo-editor" style={{ gap: 16 }}>
|
||||
{data.apple && <AppleInfo project={project} data={data} />}
|
||||
{data.play && <PlaySettings project={project} data={data} />}
|
||||
<Declarations project={project} data={data} />
|
||||
{data.apple && <Promotion project={project} data={data} />}
|
||||
<EditBar project={project} area="appinfo" data={data} files={[data.files.config, data.files.contact]} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyInfoSub')}>
|
||||
<div className="act-grid">
|
||||
{data.apple && (
|
||||
<div className="act-col">
|
||||
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
|
||||
<ApplyPanel project={project} method="apple.appinfo" label={t('ai.infoPush')} done={t('ai.pushed')} blocked={unsaved} />
|
||||
<ApplyPanel project={project} method="apple.review_details" label={t('ai.reviewPush')} done={t('ai.pushed')} blocked={unsaved} />
|
||||
<ApplyPanel project={project} method="apple.encryption" label={t('ai.encPush')} done={t('ai.pushed')} blocked={unsaved} />
|
||||
</div>
|
||||
)}
|
||||
{data.play && (
|
||||
<div className="act-col">
|
||||
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
|
||||
<ApplyPanel project={project} method="play.data_safety" label={t('ai.dsUpload')} done={t('ai.dsUploaded')} blocked={unsaved} />
|
||||
<ApplyPanel project={project} method="play.listing" label={t('ed.applyPlayDetails')} done={t('act.playListingDone')} blocked={unsaved} />
|
||||
<span className="small muted">{t('ed.playConsoleOnly')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/desktop/src/screens/editors/ArtEditor.tsx
Normal file
57
apps/desktop/src/screens/editors/ArtEditor.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// 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 type { EditableResult, Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Empty } from '../../ui/kit';
|
||||
import { keyOf } from '../../lib/edits';
|
||||
|
||||
type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Record<string, unknown>;
|
||||
|
||||
export function ArtEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
|
||||
const e = useEdits();
|
||||
const saved = ((data.config as unknown as { shots?: { scenes?: Scene[] } | null }).shots?.scenes ?? []) as Scene[];
|
||||
const op = { kind: 'config' as const, path: 'shots.scenes' };
|
||||
const p = e.get(project.id, keyOf(op));
|
||||
const scenes = (p && p.op.kind === 'config' ? p.op.value : saved) as Scene[];
|
||||
const unsaved = e.count(project.id, 'art') ? t('ed.saveFirst') : null;
|
||||
if (!saved.length) return <Card title={t('ed.artTitle')} icon="image"><Empty compact illo="text" title={t('ed.artEmpty')}>{t('ed.artEmptyText')}</Empty></Card>;
|
||||
const move = (i: number, d: -1 | 1) => {
|
||||
const next = [...scenes];
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= next.length) return;
|
||||
[next[i], next[j]] = [next[j]!, next[i]!];
|
||||
e.set(project.id, { ...op, value: next }, 'art', saved);
|
||||
};
|
||||
return (
|
||||
<div className="stack" id="art-editor" style={{ gap: 16 }}>
|
||||
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}>
|
||||
<ol className="ed-scenes">
|
||||
{scenes.map((s, i) => (
|
||||
<li key={s.id} data-scene={s.id}>
|
||||
<span className="num strong">{i + 1}</span>
|
||||
<span className="mono-id strong">{s.id}</span>
|
||||
<div className="ed-scene-frame">
|
||||
<TextField project={project} area="art" op={{ kind: 'config', path: `shots.scenes[id=${s.id}].frames[0].src` }} saved={saved.find(x => x.id === s.id)?.frames?.[0]?.src ?? ''} label={t('ed.frame')} mono />
|
||||
</div>
|
||||
<div className="row" style={{ gap: 4 }}>
|
||||
<button className="btn btn-xs btn-ghost" disabled={i === 0} onClick={() => move(i, -1)} aria-label={t('ed.moveUp', { id: s.id })}><Icon name="arrowLeft" className="rot90" />{t('ed.up')}</button>
|
||||
<button className="btn btn-xs btn-ghost" disabled={i === scenes.length - 1} onClick={() => move(i, 1)} aria-label={t('ed.moveDown', { id: s.id })}><Icon name="arrowRight" className="rot90" />{t('ed.down')}</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Card>
|
||||
<EditBar project={project} area="art" data={data} files={[data.files.config]} 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>
|
||||
{data.play && <div className="act-col"><div className="act-head"><StoreGlyph store="android" />Google Play</div><ApplyPanel project={project} method="play.images" label={t('act.playImages')} done={t('act.uploaded')} blocked={unsaved} /></div>}
|
||||
{data.apple && <div className="act-col"><div className="act-head"><StoreGlyph store="ios" />App Store</div><ApplyPanel project={project} method="apple.screenshots" label={t('act.appleShots')} done={t('act.uploaded')} blocked={unsaved} /><ApplyPanel project={project} method="apple.previews" label={t('act.applePreviews')} done={t('act.uploaded')} blocked={unsaved} /></div>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
apps/desktop/src/screens/editors/CatalogEditor.tsx
Normal file
148
apps/desktop/src/screens/editors/CatalogEditor.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// Catalog editor (Catalog tab): every in-app product and subscription of the config with its names and descriptions
|
||||
// per language (live counters against the store limits), base price in USD, reference name, review note, and the
|
||||
// offer blocks (introductory and promotional offers, offer codes, win-back) as checked JSON. Saved into the config,
|
||||
// then applied per store with the dry run in the window. A price change of a product that is already live goes
|
||||
// through the owner's price decision (the Catalog tab's "Apply the price changes" owner actions).
|
||||
import { useState } from 'react';
|
||||
import type { EditableResult, Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, NumberField, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty } from '../../ui/kit';
|
||||
|
||||
type O = Record<string, unknown>;
|
||||
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
const PERIODS: [string, string][] = [['P1W', '1 week'], ['P1M', '1 month'], ['P2M', '2 months'], ['P3M', '3 months'], ['P6M', '6 months'], ['P1Y', '1 year']];
|
||||
|
||||
function Listings({ project, data, base, listings, limits, benefits }: { project: Project; data: EditableResult; base: string; listings: O; limits: { title: number; description: number }; benefits?: boolean }) {
|
||||
return (
|
||||
<div className="ed-langs">
|
||||
{data.languages.map(l => {
|
||||
const x = obj(listings[l.id]);
|
||||
return (
|
||||
<div className="ed-lang" key={l.id}>
|
||||
<div className="small strong mono-id">{l.id}</div>
|
||||
<div className="ed-grid">
|
||||
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.listings.${l.id}.${'name' in x && !('title' in x) ? 'name' : 'title'}` }} saved={str(x.title ?? x.name)} label={t('ed.itemName')} limit={limits.title} />
|
||||
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.listings.${l.id}.description` }} saved={str(x.description)} label={t('ed.itemDescription')} limit={limits.description} />
|
||||
{benefits && <ListField project={project} area="catalog" path={`${base}.listings.${l.id}.benefits`} saved={(x.benefits as string[] | undefined) ?? null} label={t('ed.benefits')} hint={t('ed.benefitsHint')} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductCard({ project, data, p }: { project: Project; data: EditableResult; p: O }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const base = `products[id=${String(p.id)}]`;
|
||||
const lim = data.limits.iap as Record<string, number>;
|
||||
const e = useEdits();
|
||||
const n = e.list(project.id, 'catalog').filter(x => x.key.startsWith(`config:${base}`)).length;
|
||||
return (
|
||||
<div className="ed-item" data-item={String(p.id)}>
|
||||
<button className="ed-item-head" onClick={() => setOpen(o => !o)} aria-expanded={open}>
|
||||
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="mono-id strong">{String(p.id)}</span><Chip tone="outline">{String(p.type)}</Chip>
|
||||
<span className="num small muted">${String(p.usd)}</span>{n > 0 && <Chip tone="accent" icon="edit">{t('ed.unsaved', { count: n })}</Chip>}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ed-item-body stack">
|
||||
<div className="ed-grid">
|
||||
<NumberField project={project} area="catalog" path={`${base}.usd`} saved={p.usd as number} label={t('ed.usd')} min={0} hint={t('ed.usdHint')} />
|
||||
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.referenceName` }} saved={str(p.referenceName)} label={t('ed.refName')} limit={64} />
|
||||
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.reviewNote` }} saved={str(p.reviewNote)} label={t('ed.reviewNote')} multiline rows={2} limit={4000} />
|
||||
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.reviewScreenshot` }} saved={str(p.reviewScreenshot)} label={t('ed.reviewShot')} mono />
|
||||
<ChoiceField project={project} area="catalog" path={`${base}.familySharable`} saved={p.familySharable === undefined ? null : (p.familySharable as boolean)} label={t('ed.family')} options={[['true', t('common.yes')], ['false', t('common.no')]]} />
|
||||
</div>
|
||||
<Listings project={project} data={data} base={base} listings={obj(p.listings)} limits={{ title: lim.name ?? 30, description: lim.description ?? 45 }} />
|
||||
<JsonField project={project} area="catalog" path={`${base}.offerCodes`} saved={p.offerCodes ?? null} label={t('ed.offerCodes')} rows={5} hint={t('ed.offerCodesHint')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubCard({ project, data, group, s }: { project: Project; data: EditableResult; group: string; s: O }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const base = `subscriptions[name=${group}].subscriptions[id=${String(s.id)}]`;
|
||||
const lim = data.limits.appleSub as Record<string, number>;
|
||||
return (
|
||||
<div className="ed-item" data-item={String(s.id)}>
|
||||
<button className="ed-item-head" onClick={() => setOpen(o => !o)} aria-expanded={open}>
|
||||
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="mono-id strong">{String(s.id)}</span><Chip tone="outline">{String(s.period)}</Chip><span className="num small muted">${String(s.usd)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ed-item-body stack">
|
||||
<div className="ed-grid">
|
||||
<NumberField project={project} area="catalog" path={`${base}.usd`} saved={s.usd as number} label={t('ed.usd')} min={0} hint={t('ed.usdHint')} />
|
||||
<NumberField project={project} area="catalog" path={`${base}.level`} saved={s.level as number} label={t('ed.level')} min={1} step="1" hint={t('ed.levelHint')} />
|
||||
<ChoiceField project={project} area="catalog" path={`${base}.period`} saved={str(s.period)} label={t('ed.period')} options={PERIODS} allowEmpty={false} hint={t('ed.periodHint')} />
|
||||
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.referenceName` }} saved={str(s.referenceName)} label={t('ed.refName')} limit={64} />
|
||||
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.reviewNote` }} saved={str(s.reviewNote)} label={t('ed.reviewNote')} multiline rows={2} limit={4000} />
|
||||
</div>
|
||||
<Listings project={project} data={data} base={base} listings={obj(s.listings)} limits={{ title: lim.name ?? 30, description: lim.description ?? 45 }} benefits />
|
||||
<JsonField project={project} area="catalog" path={`${base}.intro`} saved={s.intro ?? null} label={t('ed.intro')} rows={4} hint={t('ed.introHint')} />
|
||||
<JsonField project={project} area="catalog" path={`${base}.offers`} saved={s.offers ?? null} label={t('ed.offers')} rows={5} />
|
||||
<JsonField project={project} area="catalog" path={`${base}.offerCodes`} saved={s.offerCodes ?? null} label={t('ed.offerCodes')} rows={5} hint={t('ed.offerCodesHint')} />
|
||||
<JsonField project={project} area="catalog" path={`${base}.winBack`} saved={s.winBack ?? null} label={t('ed.winBack')} rows={5} hint={t('ed.winBackHint')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatalogEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
|
||||
const e = useEdits();
|
||||
const products = data.config.products as O[];
|
||||
const groups = data.config.subscriptions as O[];
|
||||
const unsaved = e.count(project.id, 'catalog') ? t('ed.saveFirst') : null;
|
||||
if (!products.length && !groups.length) {
|
||||
return <Card title={t('ed.catalogTitle')} icon="edit"><Empty compact illo="catalog" title={t('ed.catalogEmpty')}>{t('ed.catalogEmptyText')}</Empty></Card>;
|
||||
}
|
||||
return (
|
||||
<div className="stack" id="catalog-editor" style={{ gap: 16 }}>
|
||||
{products.length > 0 && (
|
||||
<Card title={t('ed.productsTitle')} icon="edit" sub={t('ed.productsSub')} pad={false}>
|
||||
<div className="ed-items">{products.map(p => <ProductCard key={String(p.id)} project={project} data={data} p={p} />)}</div>
|
||||
</Card>
|
||||
)}
|
||||
{groups.map(g => (
|
||||
<Card key={String(g.name)} title={t('ed.groupTitle', { name: String(g.name) })} icon="layers" sub={t('ed.groupSub')} pad={false}>
|
||||
<div className="card-body">
|
||||
<div className="ed-langs">
|
||||
{data.languages.map(l => (
|
||||
<div className="ed-lang" key={l.id}><div className="small strong mono-id">{l.id}</div>
|
||||
<TextField project={project} area="catalog" op={{ kind: 'config', path: `subscriptions[name=${String(g.name)}].listings.${l.id}.name` }} saved={str(obj(obj(g.listings)[l.id]).name)} label={t('ed.groupName')} limit={30} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ed-items">{(g.subscriptions as O[]).map(s => <SubCard key={String(s.id)} project={project} data={data} group={String(g.name)} s={s} />)}</div>
|
||||
</Card>
|
||||
))}
|
||||
<EditBar project={project} area="catalog" data={data} files={[data.files.config]} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyCatalogSub')}>
|
||||
<div className="act-grid">
|
||||
{data.play && (
|
||||
<div className="act-col">
|
||||
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
|
||||
{products.length > 0 && <ApplyPanel project={project} method="play.products" label={t('ed.applyPlayProducts')} done={t('ai.pushed')} blocked={unsaved} />}
|
||||
{groups.length > 0 && <ApplyPanel project={project} method="play.subscriptions" label={t('ed.applyPlaySubs')} done={t('ai.pushed')} blocked={unsaved} />}
|
||||
</div>
|
||||
)}
|
||||
{data.apple && (
|
||||
<div className="act-col">
|
||||
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
|
||||
{products.length > 0 && <ApplyPanel project={project} method="apple.iap" label={t('ed.applyAppleIap')} done={t('ai.pushed')} blocked={unsaved} />}
|
||||
{groups.length > 0 && <ApplyPanel project={project} method="apple.subscriptions" label={t('ed.applyAppleSubs')} done={t('ai.pushed')} blocked={unsaved} />}
|
||||
{products.length > 0 && <ApplyPanel project={project} method="apple.iap_review" label={t('ed.applyIapReview')} done={t('act.uploaded')} blocked={unsaved} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="small muted act-note"><Icon name="shield" />{t('ed.priceGate')}</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
apps/desktop/src/screens/editors/ListingEditor.tsx
Normal file
163
apps/desktop/src/screens/editors/ListingEditor.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// The listing editor (Listings tab): per language, the Google Play and App Store texts side by side with live
|
||||
// counters against the store limits and the checks of `check --aso` / `scan` as the owner types; the shared store
|
||||
// details (URLs, contact, copyright, promo video); Save into store/listing.md, store/appstore.md and the config
|
||||
// (project.edit), Pull the live texts into the files (diff first), and Apply to each store (dry run in the window).
|
||||
import { useState } from 'react';
|
||||
import type { EditableListing, EditableResult, Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
import { appleLocaleIssues, playLangIssues, type FieldIssue } from '../../../../../cli/lib/listing-rules';
|
||||
import { keyOf } from '../../lib/edits';
|
||||
import { useData, useWrite } from '../../store';
|
||||
import { ApplyPanel, EditBar, type FieldIssueView, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Notice } from '../../ui/kit';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
||||
const PLAY: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [
|
||||
{ f: 'title', label: 'lst.title' }, { f: 'short', label: 'lst.short' }, { f: 'full', label: 'lst.full', multiline: true, rows: 10 }, { f: 'whatsNew', label: 'lst.whatsNew', multiline: true, rows: 4 },
|
||||
];
|
||||
const APPLE: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [
|
||||
{ f: 'name', label: 'lst.name' }, { f: 'subtitle', label: 'lst.subtitle' }, { f: 'promotionalText', label: 'lst.promo', multiline: true, rows: 3 },
|
||||
{ f: 'keywords', label: 'lst.keywords', mono: true }, { f: 'description', label: 'lst.description', multiline: true, rows: 10 }, { f: 'whatsNew', label: 'lst.whatsNew', multiline: true, rows: 4 },
|
||||
];
|
||||
|
||||
/** The values of one listing language with the unsaved edits on top. */
|
||||
function current(e: ReturnType<typeof useEdits>, project: Project, store: 'play' | 'apple', l: EditableListing, fields: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
const p = e.get(project.id, keyOf({ kind: 'listing', store, lang: l.lang, field: f, value: '' }));
|
||||
out[f] = p && p.op.kind === 'listing' ? p.op.value : l.values[f] ?? '';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const byField = (issues: FieldIssue[], f: string): FieldIssueView[] => issues.filter(i => i.field === f).map(i => ({ level: i.level, msg: i.msg }));
|
||||
|
||||
function StoreColumn({ project, data, store, l }: { project: Project; data: EditableResult; store: 'play' | 'apple'; l: EditableListing }) {
|
||||
const e = useEdits();
|
||||
const defs = store === 'play' ? PLAY : APPLE;
|
||||
const lim = (data.limits[store] ?? {}) as Record<string, number>;
|
||||
const vals = current(e, project, store, l, defs.map(d => d.f));
|
||||
const issues = store === 'play'
|
||||
? playLangIssues(l.lang, { title: vals.title || undefined, short: vals.short, full: vals.full, whatsNew: vals.whatsNew }, data.appName)
|
||||
: appleLocaleIssues(l.lang, Object.fromEntries(Object.entries(vals).filter(([, v]) => v)), data.appleName);
|
||||
// a new section starts empty: say what is required instead of a wall of errors
|
||||
const fresh = !l.exists && Object.values(vals).every(v => !v);
|
||||
return (
|
||||
<div className="ed-col" data-store={store} data-lang={l.lang}>
|
||||
<div className="act-head"><StoreGlyph store={store === 'play' ? 'android' : 'ios'} />{store === 'play' ? 'Google Play' : 'App Store'}<span className="mono-id small muted">{l.lang}</span>
|
||||
{l.status && <Chip tone="warn">{l.status === 'draft' ? t('ed.statusDraft') : t('ed.statusTranslate')}</Chip>}
|
||||
{(l.codes?.length ?? 0) > 1 && <Chip tone="outline">{t('ed.alsoServes', { codes: l.codes!.filter(c => c !== l.lang).join(', ') })}</Chip>}
|
||||
</div>
|
||||
{fresh && <Notice tone="info" icon="info">{t('ed.newSection', { lang: l.lang })}</Notice>}
|
||||
{defs.map(d => (
|
||||
<TextField key={d.f} project={project} area="listings" op={{ kind: 'listing', store, lang: l.lang, field: d.f }} saved={l.values[d.f] ?? ''}
|
||||
label={t(d.label)} limit={lim[d.f]} multiline={d.multiline} rows={d.rows} mono={d.mono}
|
||||
placeholder={d.f === 'title' ? data.appName : d.f === 'name' ? data.appleName : undefined}
|
||||
hint={d.f === 'keywords' ? t('ed.keywordsHint') : d.f === 'whatsNew' && store === 'apple' ? t('ed.whatsNewApple') : d.f === 'title' || d.f === 'name' ? t('ed.nameHint') : undefined}
|
||||
issues={fresh ? [] : byField(issues, d.f === 'title' && store === 'play' ? 'title' : d.f)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DETAILS: { path: string; label: Key; kind?: 'url' | 'email'; store?: 'play' | 'apple'; hint?: Key }[] = [
|
||||
{ path: 'app.supportUrl', label: 'ed.supportUrl', kind: 'url' }, { path: 'app.marketingUrl', label: 'ed.marketingUrl', kind: 'url' },
|
||||
{ path: 'app.privacyPolicyUrl', label: 'ed.privacyUrl', kind: 'url' }, { path: 'app.termsUrl', label: 'ed.termsUrl', kind: 'url', hint: 'ed.termsHint' },
|
||||
{ path: 'app.website', label: 'ed.website', kind: 'url', hint: 'ed.websiteHint' }, { path: 'app.contactEmail', label: 'ed.contactEmail', kind: 'email' },
|
||||
{ path: 'app.copyright', label: 'ed.copyright' }, { path: 'play.video', label: 'ed.video', kind: 'url', store: 'play', hint: 'ed.videoHint' },
|
||||
];
|
||||
|
||||
export function StoreDetails({ project, data }: { project: Project; data: EditableResult }) {
|
||||
const c = data.config;
|
||||
const get = (p: string): string => {
|
||||
const [a, b] = p.split('.') as [string, string];
|
||||
const o = (c as unknown as Record<string, Record<string, unknown> | null>)[a];
|
||||
return o && typeof o[b] === 'string' ? (o[b] as string) : '';
|
||||
};
|
||||
return (
|
||||
<div className="ed-grid">
|
||||
{DETAILS.filter(d => !d.store || (d.store === 'play' ? !!data.play : !!data.apple)).map(d => {
|
||||
const v = get(d.path);
|
||||
return <TextField key={d.path} project={project} area="listings" op={{ kind: 'config', path: d.path }} saved={v} label={t(d.label)} kind={d.kind} mono={!!d.kind}
|
||||
hint={d.hint ? t(d.hint) : undefined} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListingEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
|
||||
const d = useData();
|
||||
const e = useEdits();
|
||||
const w = useWrite(project.id);
|
||||
const live = d.liveOf(project);
|
||||
const [lang, setLang] = useState(data.languages[0]?.id ?? '');
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [version, setVersion] = useState(live?.release.next?.ios?.version ?? live?.release.next?.version ?? '');
|
||||
const L = data.languages.find(x => x.id === lang) ?? data.languages[0];
|
||||
const playL = data.play?.filter(x => x.language === L?.id) ?? [];
|
||||
const pl = playL.find(x => x.lang === code) ?? playL[0];
|
||||
const al = data.apple?.find(x => x.language === L?.id);
|
||||
const unsaved = e.count(project.id, 'listings');
|
||||
const okVersion = /^\d+(\.\d+){0,2}$/.test(version);
|
||||
const files = [data.files.play, data.files.apple, data.files.config];
|
||||
return (
|
||||
<>
|
||||
<Card title={t('ed.listingTitle')} icon="edit" sub={t('ed.listingSub')} id="listing-editor"
|
||||
actions={<>
|
||||
{data.play && <WriteButton run={w} icon="download" w={{ method: 'play.listing_pull', args: {}, title: t('ed.pullPlay'), done: t('ed.pulled'), after: onSaved }} hint={unsaved ? t('ed.pullDirty') : t('ed.pullHint')}>{t('ed.pullPlay')}</WriteButton>}
|
||||
{data.apple && <WriteButton run={w} icon="download" w={{ method: 'apple.listing_pull', args: {}, title: t('ed.pullApple'), done: t('ed.pulled'), after: onSaved }} hint={unsaved ? t('ed.pullDirty') : t('ed.pullHint')}>{t('ed.pullApple')}</WriteButton>}
|
||||
</>}>
|
||||
<div className="stack" style={{ gap: 12 }}>
|
||||
{unsaved > 0 && <Notice tone="info" icon="info">{t('ed.pullNote')}</Notice>}
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }} role="tablist" aria-label={t('col.language')}>
|
||||
{data.languages.map(x => {
|
||||
const n = e.list(project.id, 'listings').filter(p => p.op.kind === 'listing' && (p.op.lang === x.apple || x.play.includes(p.op.lang))).length;
|
||||
return (
|
||||
<button key={x.id} role="tab" aria-selected={x.id === L?.id} className={`btn btn-sm${x.id === L?.id ? ' btn-primary' : ''}`} onClick={() => { setLang(x.id); setCode(null); }} data-lang-tab={x.id}>
|
||||
<Icon name="globe" />{x.id}{n > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: n })} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{playL.length > 1 && (
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<span className="small muted">{t('ed.playCode')}</span>
|
||||
{playL.map(x => <button key={x.lang} className={`btn btn-xs${x.lang === pl?.lang ? ' btn-primary' : ' btn-ghost'}`} onClick={() => setCode(x.lang)}>{x.lang}</button>)}
|
||||
</div>
|
||||
)}
|
||||
{L?.rtl && <Notice tone="info" icon="info">{t('ed.rtl')}</Notice>}
|
||||
<div className="ed-cols" dir={L?.rtl ? 'rtl' : undefined}>
|
||||
{pl && <StoreColumn project={project} data={data} store="play" l={pl} />}
|
||||
{al && <StoreColumn project={project} data={data} store="apple" l={al} />}
|
||||
{!pl && !al && <Notice tone="warn">{t('ed.noStoreForLang', { lang: L?.id ?? '' })}</Notice>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('ed.detailsTitle')} icon="link" sub={t('ed.detailsSub')}>
|
||||
<StoreDetails project={project} data={data} />
|
||||
</Card>
|
||||
<EditBar project={project} area="listings" data={data} files={files} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applySub')}>
|
||||
<div className="act-grid">
|
||||
{data.play && (
|
||||
<div className="act-col">
|
||||
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
|
||||
<ApplyPanel project={project} method="play.listing" label={t('ed.applyPlay')} done={t('act.playListingDone')} blocked={unsaved ? t('ed.saveFirst') : null} />
|
||||
</div>
|
||||
)}
|
||||
{data.apple && (
|
||||
<div className="act-col">
|
||||
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<label className="small muted" htmlFor="ed-version">{t('act.version')}</label>
|
||||
<input id="ed-version" className="input input-sm mono" style={{ width: 90 }} value={version} onChange={ev => setVersion(ev.target.value.trim())} placeholder="2.4.0" />
|
||||
</div>
|
||||
<ApplyPanel project={project} method="apple.listing" args={{ version }} label={t('ed.applyApple', { version: version || '…' })} done={t('act.appleListingDone')} disabled={!okVersion} blocked={unsaved ? t('ed.saveFirst') : null} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="small muted act-note"><Icon name="shield" />{t('ed.applyNote')}</p>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
apps/desktop/src/screens/editors/TabEditor.tsx
Normal file
47
apps/desktop/src/screens/editors/TabEditor.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// The editor of an app tab (console parity: every value the stores' consoles let the owner edit and storebound can
|
||||
// write is edited here): loads what the files hold once (project.editable) and shows the tab's editor.
|
||||
import { useEffect } from 'react';
|
||||
import type { Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { edits } from '../../lib/edits';
|
||||
import type { AppTab } from '../../nav';
|
||||
import { useEditable } from '../../ui/Editor';
|
||||
import { Card, ErrorNotice, Skeleton } from '../../ui/kit';
|
||||
import { AppInfoEditor } from './AppInfoEditor';
|
||||
import { ArtEditor } from './ArtEditor';
|
||||
import { CatalogEditor } from './CatalogEditor';
|
||||
import { ListingEditor } from './ListingEditor';
|
||||
import { TestFlightEditor } from './TestFlightEditor';
|
||||
|
||||
/** Tabs with an editor, and the edit area each one keeps its unsaved edits in. */
|
||||
export const EDIT_TABS: Partial<Record<AppTab, 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art'>> = {
|
||||
listings: 'listings', appinfo: 'appinfo', catalog: 'catalog', testflight: 'testflight', art: 'art',
|
||||
};
|
||||
|
||||
export function TabEditor({ tab, project }: { tab: AppTab; project: Project }) {
|
||||
const r = useEditable(project);
|
||||
if (!EDIT_TABS[tab]) return null;
|
||||
if (!r.data) return r.error ? <ErrorNotice error={t('ed.loadFailed', { error: r.error })} tone="bad" /> : <Card><Skeleton lines={4} block /></Card>;
|
||||
const p = { project, data: r.data, onSaved: () => { r.refresh(); } };
|
||||
switch (tab) {
|
||||
case 'listings': return <ListingEditor {...p} />;
|
||||
case 'appinfo': return <AppInfoEditor {...p} />;
|
||||
case 'catalog': return <CatalogEditor {...p} />;
|
||||
case 'testflight': return <TestFlightEditor {...p} />;
|
||||
case 'art': return <ArtEditor {...p} />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Closing or reloading the window with unsaved edits asks first. */
|
||||
export function useUnsavedGuard(): void {
|
||||
useEffect(() => {
|
||||
const on = (ev: BeforeUnloadEvent) => {
|
||||
if (!edits.dirtyProjects().length) return;
|
||||
ev.preventDefault();
|
||||
ev.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', on);
|
||||
return () => window.removeEventListener('beforeunload', on);
|
||||
}, []);
|
||||
}
|
||||
48
apps/desktop/src/screens/editors/TestFlightEditor.tsx
Normal file
48
apps/desktop/src/screens/editors/TestFlightEditor.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// TestFlight editor (TestFlight tab): What to Test per locale (### whatToTest in the App Store listing file), the
|
||||
// Beta App Description per language, notes for Beta App Review, and the beta groups (checked JSON). Saved into the
|
||||
// files, then applied with the dry run in the window: test information, and creating the missing groups. Sending a
|
||||
// build to external testers and Beta App Review stay the owner's terminal steps.
|
||||
import type { EditableResult, Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ApplyPanel, EditBar, JsonField, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Card } from '../../ui/kit';
|
||||
|
||||
type O = Record<string, unknown>;
|
||||
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
|
||||
|
||||
export function TestFlightEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
|
||||
const e = useEdits();
|
||||
const tf = obj(obj(data.config.apple).testflight);
|
||||
const desc = tf.description;
|
||||
const unsaved = e.count(project.id, 'testflight') ? t('ed.saveFirst') : null;
|
||||
if (!data.apple) return null;
|
||||
return (
|
||||
<div className="stack" id="testflight-editor" style={{ gap: 16 }}>
|
||||
<Card title={t('ed.tfTitle')} icon="edit" sub={t('ed.tfSub')}>
|
||||
<div className="ed-langs">
|
||||
{data.apple.map(l => (
|
||||
<div className="ed-lang" key={l.lang}>
|
||||
<div className="small strong mono-id">{l.lang}</div>
|
||||
<TextField project={project} area="testflight" op={{ kind: 'listing', store: 'apple', lang: l.lang, field: 'whatToTest' }} saved={l.values.whatToTest ?? ''}
|
||||
label={t('ed.whatToTest')} limit={4000} multiline rows={3} placeholder={l.values.whatsNew ?? ''} hint={l.values.whatToTest ? undefined : t('ed.whatToTestHint')} />
|
||||
{typeof desc !== 'string' && (
|
||||
<TextField project={project} area="testflight" op={{ kind: 'config', path: `apple.testflight.description.${l.language}` }} saved={typeof obj(desc)[l.language] === 'string' ? String(obj(desc)[l.language]) : ''}
|
||||
label={t('ed.betaDescription')} limit={4000} multiline rows={3} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{typeof desc === 'string' && <TextField project={project} area="testflight" op={{ kind: 'config', path: 'apple.testflight.description' }} saved={desc} label={t('ed.betaDescriptionAll')} limit={4000} multiline rows={3} />}
|
||||
<TextField project={project} area="testflight" op={{ kind: 'config', path: 'apple.testflight.reviewNotes' }} saved={typeof tf.reviewNotes === 'string' ? tf.reviewNotes : ''} label={t('ed.betaNotes')} limit={4000} multiline rows={3} />
|
||||
<JsonField project={project} area="testflight" path="apple.testflight.groups" saved={tf.groups ?? null} label={t('ed.tfGroups')} rows={6} hint={t('ed.tfGroupsHint')} />
|
||||
</Card>
|
||||
<EditBar project={project} area="testflight" data={data} files={[data.files.apple, data.files.config]} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyTfSub')}>
|
||||
<div className="act-grid">
|
||||
<ApplyPanel project={project} method="apple.testflight_info" label={t('ed.applyTfInfo')} done={t('ai.pushed')} blocked={unsaved} />
|
||||
<ApplyPanel project={project} method="apple.testflight_groups" label={t('tf.groupsSync')} done={t('tf.groupsSynced')} blocked={unsaved} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue