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:
parent
ce939ee063
commit
f131de9df2
47 changed files with 2309 additions and 927 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue