Desktop v2 screens: releases (store cards, stage stepper, rollout ring), submit checklist, TestFlight cards; listings as store pages with completeness, framed screenshots with issue badges, product cards, price matrix with gap shading; health tiles and issue cards, store-style review feed with rating distribution, growth funnel, readiness score with fix-first, tools grid, store-info sheet; settings, accounts, folders, dependencies and onboarding in the v2 language

This commit is contained in:
CRTkafa 2026-10-22 23:31:22 +08:00
parent ce939ee063
commit f131de9df2
47 changed files with 2309 additions and 927 deletions

View file

@ -8,7 +8,8 @@ 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';
import { Section } from '../../ui/ds';
import '../../styles/appinfo.css';
type O = Record<string, unknown>;
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
@ -30,7 +31,7 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult }
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>}>
<Section 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} />}
@ -43,8 +44,8 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult }
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')}>
</Section>
<Section 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)}
@ -52,8 +53,8 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult }
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')}>
</Section>
<Section 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}
@ -65,7 +66,7 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult }
))}
<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>
</Section>
</>
);
}
@ -75,7 +76,7 @@ function PlaySettings({ project, data }: { project: Project; data: EditableResul
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>}>
<Section 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')} />
@ -83,14 +84,14 @@ function PlaySettings({ project, data }: { project: Project; data: EditableResul
<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>
</Section>
);
}
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')}>
<Section 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()} />}
@ -101,14 +102,14 @@ function Declarations({ project, data }: { project: Project; data: EditableResul
{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>
</Section>
);
}
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>}>
<Section 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')} />
@ -116,7 +117,7 @@ function Promotion({ project, data }: { project: Project; data: EditableResult }
<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>
</Section>
);
}
@ -124,13 +125,13 @@ export function AppInfoEditor({ project, data, onSaved }: { project: Project; da
const e = useEdits();
const unsaved = e.count(project.id, 'appinfo') ? t('ed.saveFirst') : null;
return (
<div className="stack" id="appinfo-editor" style={{ gap: 16 }}>
<div className="stack scr-appinfo-ed" id="appinfo-editor" style={{ gap: 20 }}>
{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')}>
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyInfoSub')}>
<div className="act-grid">
{data.apple && (
<div className="act-col">
@ -149,7 +150,7 @@ export function AppInfoEditor({ project, data, onSaved }: { project: Project; da
</div>
)}
</div>
</Card>
</Section>
</div>
);
}

View file

@ -1,13 +1,15 @@
// 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 '../../styles/art.css';
import { useState } from 'react';
import { api, errorText, type EditableResult, type Project } from '../../api';
import { useData, useWrite } from '../../store';
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 { Empty } from '../../ui/kit';
import { Section } from '../../ui/ds';
import { keyOf } from '../../lib/edits';
type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Record<string, unknown>;
@ -33,7 +35,7 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
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>;
if (!saved.length) return <Section className="scr-art" title={t('ed.artTitle')} icon="image"><Empty compact illo="text" title={t('ed.artEmpty')}>{t('ed.artEmptyText')}</Empty></Section>;
const move = (i: number, d: -1 | 1) => {
const next = [...scenes];
const j = i + d;
@ -42,8 +44,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
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}
<div className="stack scr-art art-editor" id="art-editor" style={{ gap: 16 }}>
<Section title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}
actions={langs.length > 1 ? <label className="row small" style={{ gap: 6 }}>{t('ed.frameLang')}<select value={fl} onChange={ev => setFrameLang(ev.target.value)} data-frame-lang>{langs.map(l => <option key={l} value={l}>{l}</option>)}</select></label> : undefined}>
<ol className="ed-scenes">
{scenes.map((s, i) => (
@ -61,16 +63,16 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
</li>
))}
</ol>
</Card>
</Section>
{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')}>
<Section 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>
</Section>
</div>
);
}
@ -82,7 +84,7 @@ function Captions({ project, data, scenes }: { project: Project; data: EditableR
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">
<Section 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>
@ -103,6 +105,6 @@ function Captions({ project, data, scenes }: { project: Project; data: EditableR
);
})}
</div>
</Card>
</Section>
);
}

View file

@ -3,12 +3,14 @@
// 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 '../../styles/catalog.css';
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';
import { Chip, Empty } from '../../ui/kit';
import { Section } from '../../ui/ds';
type O = Record<string, unknown>;
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
@ -44,8 +46,8 @@ function ProductCard({ project, data, p }: { project: Project; data: EditableRes
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>}
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="cat-ic sm" aria-hidden="true"><Icon name="tag" /></span><span className="mono-id strong">{String(p.id)}</span><Chip tone="outline">{String(p.type)}</Chip>
<span className="num cat-price">${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">
@ -71,7 +73,7 @@ function SubCard({ project, data, group, s }: { project: Project; data: Editable
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>
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="cat-ic sm" aria-hidden="true"><Icon name="refresh" /></span><span className="mono-id strong">{String(s.id)}</span><Chip tone="outline">{String(s.period)}</Chip><span className="num cat-price">${String(s.usd)}</span>
</button>
{open && (
<div className="ed-item-body stack">
@ -99,17 +101,17 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da
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 <Section className="scr-catalog" title={t('ed.catalogTitle')} icon="edit"><Empty compact illo="catalog" title={t('ed.catalogEmpty')}>{t('ed.catalogEmptyText')}</Empty></Section>;
}
return (
<div className="stack" id="catalog-editor" style={{ gap: 16 }}>
<div className="stack scr-catalog cat-editor" id="catalog-editor" style={{ gap: 16 }}>
{products.length > 0 && (
<Card title={t('ed.productsTitle')} icon="edit" sub={t('ed.productsSub')} pad={false}>
<Section 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>
</Section>
)}
{groups.map(g => (
<Card key={String(g.name)} title={t('ed.groupTitle', { name: String(g.name) })} icon="layers" sub={t('ed.groupSub')} pad={false}>
<Section 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 => (
@ -120,10 +122,10 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da
</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>
</Section>
))}
<EditBar project={project} area="catalog" data={data} files={[data.files.config]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyCatalogSub')}>
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyCatalogSub')}>
<div className="act-grid">
{data.play && (
<div className="act-col">
@ -142,7 +144,7 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da
)}
</div>
<p className="small muted act-note"><Icon name="shield" />{t('ed.priceGate')}</p>
</Card>
</Section>
</div>
);
}

View file

@ -2,6 +2,7 @@
// 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 '../../styles/listings.css';
import { useState } from 'react';
import type { EditableListing, EditableResult, Project } from '../../api';
import { type Key, t } from '../../i18n';
@ -10,7 +11,8 @@ 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 { Chip, Notice } from '../../ui/kit';
import { Section } from '../../ui/ds';
import { WriteButton } from '../../ui/WriteButton';
const PLAY: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [
@ -30,6 +32,30 @@ function current(e: ReturnType<typeof useEdits>, project: Project, store: 'play'
}
return out;
}
/** The texts of one language as the store page shows them: icon, title, subtitle / short description, promo text and
* the first lines of the description (live, with the unsaved edits). */
export function StorePreview({ store, vals, name }: { store: 'play' | 'apple'; vals: Record<string, string>; name: string }) {
const title = (store === 'play' ? vals.title : vals.name) || name;
const sub = store === 'play' ? vals.short : vals.subtitle;
const promo = store === 'apple' ? vals.promotionalText : '';
const desc = store === 'play' ? vals.full : vals.description;
return (
<div className={`lst-preview ${store}`} role="group" aria-label={t('v2lst.preview')} data-preview={store}>
<span className="lst-pv-eyebrow">{t('v2lst.preview')}</span>
<div className="lst-pv-top">
<span className="lst-pv-icon" aria-hidden="true">{(title || '?').trim().slice(0, 1).toUpperCase()}</span>
<div className="lst-pv-names">
<div className="lst-pv-title">{title || '–'}</div>
{sub ? <div className="lst-pv-sub">{sub}</div> : <div className="lst-pv-sub muted">{t('v2lst.noSub')}</div>}
</div>
</div>
{promo && <p className="lst-pv-promo">{promo}</p>}
{desc ? <p className="lst-pv-desc">{desc}</p> : <p className="lst-pv-desc muted">{t('v2lst.noDesc')}</p>}
{desc && desc.length > 180 && <span className="lst-pv-more" aria-hidden="true">{t('v2lst.more')}</span>}
</div>
);
}
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 }) {
@ -48,6 +74,7 @@ function StoreColumn({ project, data, store, l }: { project: Project; data: Edit
{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>
<StorePreview store={store} vals={vals} name={store === 'play' ? data.appName : data.appleName} />
{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] ?? ''}
@ -101,15 +128,15 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da
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"
<div className="scr-listings lst-editor">
<Section 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')}>
<div className="row lst-langbar" 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 (
@ -132,12 +159,12 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da
{!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')}>
</Section>
<Section title={t('ed.detailsTitle')} icon="link" sub={t('ed.detailsSub')}>
<StoreDetails project={project} data={data} />
</Card>
</Section>
<EditBar project={project} area="listings" data={data} files={files} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applySub')}>
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applySub')}>
<div className="act-grid">
{data.play && (
<div className="act-col">
@ -157,7 +184,7 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da
)}
</div>
<p className="small muted act-note"><Icon name="shield" />{t('ed.applyNote')}</p>
</Card>
</>
</Section>
</div>
);
}

View file

@ -2,6 +2,7 @@
// (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 '../../styles/listings.css';
import { useState } from 'react';
import type { Project } from '../../api';
import { type Key, t } from '../../i18n';
@ -12,7 +13,9 @@ 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';
import { Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
import { Section } from '../../ui/ds';
import { StorePreview } from './ListingEditor';
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 },
@ -41,6 +44,7 @@ function Column({ project, store, lang, saved, appName, editable }: { project: P
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>
<StorePreview store={store} vals={vals} name={appName} />
{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}
@ -60,8 +64,8 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) {
// 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>;
if (r.loading && !r.error) return <div className="scr-listings"><Section><Skeleton lines={6} block /></Section></div>;
return <div className="scr-listings"><Section 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></Section></div>;
}
const L = r.data;
const langs = [...new Set([...(L.play ?? []).map(x => x.language), ...(L.apple?.locales ?? []).map(x => x.locale)])].sort();
@ -69,12 +73,12 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) {
const pl = L.play?.find(x => x.language === cur);
const al = L.apple?.locales.find(x => x.locale === cur);
return (
<>
<div className="scr-listings lst-editor">
{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">
<Section 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')}>
<div className="row lst-langbar" 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">
@ -83,7 +87,7 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) {
{!pl && !al && <Notice tone="warn">{t('ed.noStoreForLang', { lang: cur })}</Notice>}
</div>
</div>
</Card>
</>
</Section>
</div>
);
}

View file

@ -6,7 +6,8 @@ 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 { ErrorNotice, Skeleton } from '../../ui/kit';
import { Section } from '../../ui/ds';
import { AppInfoEditor } from './AppInfoEditor';
import { ArtEditor } from './ArtEditor';
import { CatalogEditor } from './CatalogEditor';
@ -21,7 +22,7 @@ export const EDIT_TABS: Partial<Record<AppTab, 'listings' | 'appinfo' | 'catalog
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>;
if (!r.data) return r.error ? <ErrorNotice error={t('ed.loadFailed', { error: r.error })} tone="bad" /> : <Section><Skeleton lines={4} block /></Section>;
const p = { project, data: r.data, onSaved: () => { r.refresh(); } };
switch (tab) {
case 'listings': return <ListingEditor {...p} />;

View file

@ -5,7 +5,8 @@
import type { EditableResult, Project } from '../../api';
import { t } from '../../i18n';
import { ApplyPanel, EditBar, JsonField, TextField, useEdits } from '../../ui/Editor';
import { Card } from '../../ui/kit';
import { Section } from '../../ui/ds';
import '../../styles/testflight.css';
type O = Record<string, unknown>;
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
@ -17,8 +18,8 @@ export function TestFlightEditor({ project, data, onSaved }: { project: Project;
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="stack scr-testflight-ed" id="testflight-editor" style={{ gap: 16 }}>
<Section 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}>
@ -35,14 +36,14 @@ export function TestFlightEditor({ project, data, onSaved }: { project: Project;
{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>
</Section>
<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">
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyTfSub')}>
<div className="act-grid tf-apply">
<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>
</Section>
</div>
);
}