store-only apps edit their listing on the store (storebound listing live / set: old -> new per field with the checks, a PATCH of only the edited fields; serve listing.set is the one store-only write, its token bound to the store ids; policy.rs STORE_ONLY_WRITES, write_with_confirm for store targets); screenshot captions per language and scene edited as a text edit of the shots copy JSON (JSON files keep their layout); Play content rating, target audience and App content answers in App info; docs/console-parity.md after the editors (73 rows: 55 complete, 18 with the reason) and tests/console-parity.test.ts keeping it honest
This commit is contained in:
parent
3cea2af4e1
commit
7ac028421a
33 changed files with 852 additions and 157 deletions
48
apps/desktop/src/dev/editor-fixtures.ts
Normal file
48
apps/desktop/src/dev/editor-fixtures.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Dev fixtures of the editors (made up): project.editable of the fixture folders and listing.live of store-only apps.
|
||||
import type { EditableResult, ProjectSummary } from '../api';
|
||||
import type { LiveListings } from '../../../../cli/listing-store';
|
||||
|
||||
const PLAY_LIMITS = { title: 30, short: 80, full: 4000, whatsNew: 500 };
|
||||
const APPLE_LIMITS = { name: 30, subtitle: 30, promotionalText: 170, keywords: 100, description: 4000, whatsNew: 4000 };
|
||||
const PLAY_CODE: Record<string, string> = { en: 'en-US', tr: 'tr-TR', ja: 'ja-JP', ar: 'ar' };
|
||||
const APPLE_CODE: Record<string, string> = { en: 'en-US', tr: 'tr', ja: 'ja', ar: 'ar-SA' };
|
||||
|
||||
export function editableFixture(s: ProjectSummary | undefined): EditableResult | undefined {
|
||||
if (!s) return undefined;
|
||||
const langs = s.languages.map(id => ({ id, play: s.play ? [PLAY_CODE[id] ?? id] : [], apple: s.apple ? APPLE_CODE[id] ?? id : null, rtl: id === 'ar' }));
|
||||
const text = (lang: string) => ({
|
||||
title: s.name, short: `${s.name}: a calm, short promise (${lang})`, full: `${s.name} in ${lang}. What it does and why it feels good.\n\nFEATURES\n• One-thumb controls\n• Works offline`,
|
||||
whatsNew: 'Smoother sync and a darker dark mode.',
|
||||
});
|
||||
return {
|
||||
root: s.dir, files: { config: 'storebound.config.ts', play: s.play ? 'store/listing.md' : null, apple: s.apple ? 'store/appstore.md' : null, contact: '.storebound/' },
|
||||
format: 'ts', git: { repo: true, uncommitted: ['store/appstore.md'] }, appName: s.name, appleName: s.name,
|
||||
languages: langs,
|
||||
play: s.play ? langs.map(l => ({ lang: l.play[0]!, language: l.id, codes: l.play, status: null, exists: l.id !== 'ar', values: l.id === 'ar' ? {} : text(l.play[0]!) })) : null,
|
||||
apple: s.apple ? langs.map(l => ({ lang: l.apple!, language: l.id, status: l.id === 'ja' ? 'draft' : null, exists: true, values: {
|
||||
subtitle: 'Tidy lists, calm days', promotionalText: 'New: shared lists.', keywords: 'lists,todo,planner,groceries,checklist,tasks,organize,shopping,reminders,family',
|
||||
description: `${s.name} keeps what matters in order.`, whatsNew: 'Smoother sync.', whatToTest: 'Shared lists on two devices.',
|
||||
} })) : null,
|
||||
config: {
|
||||
app: { name: s.name, privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com', copyright: '2026 Example Studio' },
|
||||
apple: s.apple ? { bundleId: s.apple.bundleId, info: { categories: { primary: s.profile === 'game' ? 'GAMES' : 'PRODUCTIVITY', ...(s.profile === 'game' ? { primarySubOne: 'GAMES_PUZZLE' } : {}) }, ageRating: {}, contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT', free: true, reviewNotes: 'Sign in with the demo account.' }, testflight: { groups: [{ name: 'Studio team', internal: true }], description: { en: 'Thanks for testing!' } } } : null,
|
||||
play: s.play ? { packageName: s.play.packageName, category: s.profile === 'game' ? 'Puzzle' : 'Productivity' } : null,
|
||||
products: s.productIds.map(id => ({ id, type: 'consumable', usd: 0.99, listings: Object.fromEntries(s.languages.map(l => [l, { title: id.replace(/_/g, ' '), description: 'A handful of coins.' }])) })),
|
||||
subscriptions: s.subscriptionIds.length ? [{ name: `${s.name} VIP`, listings: { en: { name: 'VIP' } }, subscriptions: s.subscriptionIds.map(id => ({ id, period: 'P1M', level: 1, usd: 4.99, listings: { en: { title: 'VIP monthly', description: 'Every level, no ads.' } } })) }] : [],
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] } } },
|
||||
appPrivacy: null, reviewAccess: { signInRequired: false }, accounts: null, social: null,
|
||||
shots: s.shots ? { scenes: [{ id: 'hero', frames: [{ src: 'hero.png' }] }, { id: 'levels', frames: [{ src: 'levels.png' }] }, { id: 'shop', frames: [{ src: 'shop.png' }] }], feature: null } : null,
|
||||
},
|
||||
contact: { firstName: 'A***', lastName: 'L***', phone: '+15***', email: 'a***@example.com' }, demoAccount: null,
|
||||
limits: { play: PLAY_LIMITS, apple: APPLE_LIMITS, iap: { name: 30, description: 45 }, appleSub: { name: 30, description: 45 }, playSub: { description: 200, benefits: 4, benefit: 40 }, reviewNotesBytes: 4000 },
|
||||
};
|
||||
}
|
||||
|
||||
export function liveFixture(target: string): LiveListings {
|
||||
const play = /android:/.test(target), ios = /ios:/.test(target);
|
||||
return {
|
||||
play: play ? [{ language: 'en-US', title: 'Store Only App', short: 'A store-only app read live', full: 'Read straight from Google Play.' }, { language: 'tr-TR', title: 'Mağaza Uygulaması', short: 'Canlı okunan bir uygulama', full: 'Doğrudan Google Play\'den okundu.' }] : null,
|
||||
apple: ios ? { version: '1.4.0', state: 'READY_FOR_SALE', editable: false, locales: [{ locale: 'en-US', values: { name: 'Store Only App', subtitle: 'Read live', promotionalText: 'Fresh from App Store Connect.', keywords: 'store,live', description: 'Read straight from App Store Connect.', whatsNew: 'Fixes.' } }] } : null,
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { type Account, type AccountCheck, type ConsoleFillResult, type ConsoleFo
|
|||
type PortfolioResult, type Project, type ProjectSummary, type Settings, type StoreApp, type StoreRelease, type Studio, type ToolResult } from '../api';
|
||||
import { buildApps, type Overrides } from '../lib/identity';
|
||||
import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model';
|
||||
import { editableFixture, liveFixture } from './editor-fixtures';
|
||||
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures';
|
||||
import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more';
|
||||
|
||||
|
|
@ -436,12 +437,14 @@ function read(method: string, id: string, args: Record<string, unknown> = {}): T
|
|||
case 'reviews.list': return ok(method, STORE_REVIEWS.filter(r => (play || r.store === 'apple') && (/ios:/.test(id) || r.store === 'play')));
|
||||
case 'apple.testflight_status': return ok(method, TF['p-tidy']);
|
||||
case 'apple.ppo_status': return ok(method, /ios:/.test(id) ? PPO_STORE : { tests: [] });
|
||||
case 'listing.live': return ok(method, liveFixture(id));
|
||||
default: return { method, ok: false, exitCode: 1, dryRun: false, command: `storebound ${method}`, output: `fixture: ${method} needs an app folder` };
|
||||
}
|
||||
}
|
||||
switch (method) {
|
||||
case 'project.icon': return ok(method, { path: SUMMARY[id]?.icon ?? null, dataUrl: ICONS[id] ?? null });
|
||||
case 'project.listings': return ok(method, LISTINGS[id]);
|
||||
case 'project.editable': return ok(method, editableFixture(SUMMARY[id]));
|
||||
case 'reviews.drafts': return ok(method, DRAFTS[id]);
|
||||
case 'health': return ok(method, HEALTH[id]);
|
||||
case 'reviews.list': return ok(method, REVIEWS[id]);
|
||||
|
|
|
|||
|
|
@ -1723,4 +1723,21 @@ export const en = {
|
|||
'ed.moveDown': 'Move {id} down',
|
||||
'ed.applyArtSub': 'Render, then upload: an upload replaces the store\'s screenshot set (dry run first).',
|
||||
'ed.loadFailed': 'Could not read the app folder\'s files: {error}',
|
||||
'ed.storeOnlyTitle': 'Edit the listing on the stores',
|
||||
'ed.storeOnlySub': 'No app folder: the live texts, changed straight on each store per language.',
|
||||
'ed.storeOnlyNote': 'This app has no folder, so the texts go straight to the store: each Apply shows the store\'s dry run, then asks. URLs, contact and screenshots need an app folder (Link a folder).',
|
||||
'ed.storeOnlyApplyPlay': 'Google Play {lang}: preview',
|
||||
'ed.storeOnlyApplyApple': 'App Store {lang}: preview',
|
||||
'ed.liveEmpty': 'The live texts are not read yet',
|
||||
'ed.noEditableVersion': 'No App Store version is being prepared: name, subtitle and promotional text can change now; description, keywords and what\'s new change with a new version.',
|
||||
'ed.captionsTitle': 'Screenshot captions',
|
||||
'ed.captionsSub': 'Per language and scene ({file}). Render again after saving; long lines are checked by shots check.',
|
||||
'ed.eyebrow': 'Eyebrow',
|
||||
'ed.titleLine': 'Title line {n}',
|
||||
'ed.subline': 'Subline',
|
||||
'ed.contentRating': 'Content rating answers (Play IARC)',
|
||||
'ed.contentRatingHint': '{ "category": "game", "answers": { ... } }. No API: Fill in console types them; you press Submit.',
|
||||
'ed.targetAudience': 'Target audience and ads (Play)',
|
||||
'ed.appContent': 'App content declarations (Play)',
|
||||
'ed.appContentHint': 'Government, financial and health features, advertising ID. No API: the console plan step uses them.',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1725,4 +1725,21 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'ed.moveDown': '{id} sahnesini aşağı taşı',
|
||||
'ed.applyArtSub': 'Oluştur, sonra yükle: yükleme mağazadaki ekran görüntüsü setinin yerini alır (önce deneme çalıştırması).',
|
||||
'ed.loadFailed': 'Uygulama klasörünün dosyaları okunamadı: {error}',
|
||||
'ed.storeOnlyTitle': 'Listelemeyi mağazalarda düzenle',
|
||||
'ed.storeOnlySub': 'Uygulama klasörü yok: canlı metinler, her mağazada dil başına doğrudan değiştirilir.',
|
||||
'ed.storeOnlyNote': 'Bu uygulamanın klasörü yok, metinler doğrudan mağazaya gider: her Uygula önce mağazanın deneme çalıştırmasını gösterir, sonra sorar. URL\'ler, iletişim ve ekran görüntüleri için uygulama klasörü gerekir (Klasör bağla).',
|
||||
'ed.storeOnlyApplyPlay': 'Google Play {lang}: önizle',
|
||||
'ed.storeOnlyApplyApple': 'App Store {lang}: önizle',
|
||||
'ed.liveEmpty': 'Canlı metinler henüz okunmadı',
|
||||
'ed.noEditableVersion': 'Hazırlanan bir App Store sürümü yok: ad, alt başlık ve tanıtım metni şimdi değişebilir; açıklama, anahtar kelimeler ve yenilikler yeni sürümle değişir.',
|
||||
'ed.captionsTitle': 'Ekran görüntüsü başlıkları',
|
||||
'ed.captionsSub': 'Dil ve sahne başına ({file}). Kaydettikten sonra yeniden oluşturun; uzun satırları shots check denetler.',
|
||||
'ed.eyebrow': 'Üst satır',
|
||||
'ed.titleLine': 'Başlık satırı {n}',
|
||||
'ed.subline': 'Alt satır',
|
||||
'ed.contentRating': 'İçerik derecelendirme yanıtları (Play IARC)',
|
||||
'ed.contentRatingHint': '{ "category": "game", "answers": { ... } }. API yok: Konsolda doldur bunları yazar; Gönder\'e siz basarsınız.',
|
||||
'ed.targetAudience': 'Hedef kitle ve reklamlar (Play)',
|
||||
'ed.appContent': 'Uygulama içeriği beyanları (Play)',
|
||||
'ed.appContentHint': 'Devlet, finans ve sağlık özellikleri, reklam kimliği. API yok: konsol planı adımı bunları kullanır.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
export type EditOp =
|
||||
| { kind: 'config'; path: string; value?: unknown; remove?: boolean }
|
||||
| { kind: 'listing'; store: 'play' | 'apple'; lang: string; field: string; value: string }
|
||||
| { kind: 'contact'; field: 'firstName' | 'lastName' | 'phone' | 'email'; value: string };
|
||||
| { kind: 'contact'; field: 'firstName' | 'lastName' | 'phone' | 'email'; value: string }
|
||||
| { kind: 'shotsCopy'; lang: string; scene: string; field: 'eyebrow' | 'title' | 'sub'; line?: 0 | 1; value: string };
|
||||
|
||||
/** An edit without its value: what a field edits. */
|
||||
export type FieldOp = { kind: 'config'; path: string } | Omit<Extract<EditOp, { kind: 'listing' }>, 'value'> | Omit<Extract<EditOp, { kind: 'contact' }>, 'value'> | Omit<Extract<EditOp, { kind: 'shotsCopy' }>, 'value'>;
|
||||
|
||||
/** Which editor (tab) a pending edit belongs to, for the unsaved marks. */
|
||||
export type EditArea = 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art';
|
||||
|
|
@ -15,7 +19,8 @@ export type EditArea = 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art'
|
|||
export interface Pending { key: string; op: EditOp; area: EditArea; /** The saved value, to show "was" and to drop an edit typed back to it. */ saved: unknown }
|
||||
|
||||
export const keyOf = (op: EditOp): string =>
|
||||
op.kind === 'config' ? `config:${op.path}` : op.kind === 'listing' ? `listing:${op.store}:${op.lang}:${op.field}` : `contact:${op.field}`;
|
||||
op.kind === 'config' ? `config:${op.path}` : op.kind === 'listing' ? `listing:${op.store}:${op.lang}:${op.field}`
|
||||
: op.kind === 'shotsCopy' ? `shotsCopy:${op.lang}:${op.scene}:${op.field}${op.line === undefined ? '' : `:${op.line}`}` : `contact:${op.field}`;
|
||||
|
||||
const valueOf = (op: EditOp): unknown => (op.kind === 'config' ? (op.remove ? undefined : op.value) : op.value);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const TTL: Record<string, number> = {
|
|||
'prices.current': 30 * MIN,
|
||||
'project.listings': 30 * MIN,
|
||||
'project.editable': 30 * MIN,
|
||||
'listing.live': 30 * MIN,
|
||||
'apple.ppo_status': 30 * MIN,
|
||||
validate: 30 * MIN,
|
||||
scan: 30 * MIN,
|
||||
|
|
@ -56,11 +57,13 @@ export function readsFor(o: { folder: boolean; ios: boolean; android: boolean })
|
|||
if (o.android) add('play.status');
|
||||
if (o.ios || o.android) { add('reviews.list', { days: 30 }); add('health'); }
|
||||
if (o.folder) {
|
||||
add('scan'); add('project.listings');
|
||||
add('scan'); add('project.listings'); add('project.editable');
|
||||
if (o.ios || o.android) { add('catalog.diff'); add('prices.current'); add('validate'); }
|
||||
add('analytics.compare'); add('aso.audit');
|
||||
}
|
||||
if (o.ios) add('apple.ppo_status');
|
||||
// store-only apps: the listing editor's live texts
|
||||
if (!o.folder && (o.ios || o.android)) add('listing.live');
|
||||
return r;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'he
|
|||
export type AppTab = (typeof APP_TABS)[number];
|
||||
/** Tabs of a store-only app: store reads only (catalog, prices, listings, art, app info and checks need the app folder;
|
||||
* Growth shows the Product Page Optimization tests only). */
|
||||
export const STORE_TABS: readonly AppTab[] = ['releases', 'health', 'reviews', 'testflight', 'growth'];
|
||||
export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'health', 'reviews', 'testflight', 'growth'];
|
||||
export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS);
|
||||
export const TAB_KEY: Record<AppTab, Key> = {
|
||||
releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { ReleasesTab } from './tabs/Releases';
|
|||
import { ReviewsTab } from './tabs/Reviews';
|
||||
import { TestFlightTab } from './tabs/TestFlight';
|
||||
import { EDIT_TABS, TabEditor, useUnsavedGuard } from './editors/TabEditor';
|
||||
import { StoreListingEditor } from './editors/StoreListingEditor';
|
||||
import { useEdits } from '../ui/Editor';
|
||||
|
||||
export const ascUrl = (appId: string | null | undefined): string => (appId ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(appId)}/distribution` : 'https://appstoreconnect.apple.com/apps');
|
||||
|
|
@ -150,7 +151,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
{tabs.map((x, i) => (
|
||||
<button key={x} ref={el => { tabRefs.current[i] = el; }} role="tab" id={`tab-${x}`} aria-selected={tab === x} aria-controls="tabpanel" tabIndex={tab === x ? 0 : -1} className="tab" onClick={() => setTab(x)}>
|
||||
<Icon name={TAB_ICON[x]} />{t(TAB_KEY[x])}
|
||||
{app.project && EDIT_TABS[x] && ed.count(app.project.id, EDIT_TABS[x]) > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: ed.count(app.project.id, EDIT_TABS[x]) })} />}
|
||||
{EDIT_TABS[x] && ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) })} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -160,6 +161,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
{tab === 'catalog' && app.project && <CatalogTab project={app.project} />}
|
||||
{tab === 'prices' && app.project && <PricesTab project={app.project} />}
|
||||
{tab === 'listings' && app.project && <><TabEditor tab={tab} project={app.project} /><ListingsTab project={app.project} /></>}
|
||||
{tab === 'listings' && !app.project && <StoreListingEditor app={app} />}
|
||||
{tab === 'art' && app.project && <ArtTab project={app.project} />}
|
||||
{tab === 'health' && <HealthTab app={app} />}
|
||||
{tab === 'reviews' && <ReviewsTab app={app} />}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ function Declarations({ project, data }: { project: Project; data: EditableResul
|
|||
</div>
|
||||
{data.play && <JsonField project={project} area="appinfo" path="dataSafety.data" saved={ds.data ?? null} label={t('ed.dsTypes')} hint={t('ed.dsTypesHint')} />}
|
||||
{data.apple && <JsonField project={project} area="appinfo" path="appPrivacy" saved={data.config.appPrivacy} label={t('ed.apAnswers')} hint={t('ed.apHint')} />}
|
||||
{data.play && <JsonField project={project} area="appinfo" path="console.contentRating" saved={obj(data.config.console).contentRating ?? null} label={t('ed.contentRating')} rows={6} hint={t('ed.contentRatingHint')} />}
|
||||
{data.play && <JsonField project={project} area="appinfo" path="console.targetAudience" saved={obj(data.config.console).targetAudience ?? null} label={t('ed.targetAudience')} rows={4} hint={t('ed.noApiHint')} />}
|
||||
{data.play && <JsonField project={project} area="appinfo" path="console.appContent" saved={obj(data.config.console).appContent ?? null} label={t('ed.appContent')} rows={4} hint={t('ed.appContentHint')} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Store art editor (Store art tab): the order of the screenshot scenes (the store shows them in this order) and the
|
||||
// raw frame each scene shows (replace a screenshot by pointing the scene at another frame). Saved into the config;
|
||||
// then render, and upload to each store with the dry run in the window (uploads replace the store's sets).
|
||||
import { useState } from 'react';
|
||||
import type { EditableResult, Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor';
|
||||
|
|
@ -44,7 +45,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
|
|||
))}
|
||||
</ol>
|
||||
</Card>
|
||||
<EditBar project={project} area="art" data={data} files={[data.files.config]} onSaved={onSaved} />
|
||||
{data.shotsCopy && <Captions project={project} data={data} scenes={scenes.map(s => s.id)} />}
|
||||
<EditBar project={project} area="art" data={data} files={[data.files.config, data.files.shotsCopy ?? null]} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyArtSub')}>
|
||||
<div className="act-grid">
|
||||
<div className="act-col"><div className="act-head"><Icon name="wand" />{t('act.render')}</div><ApplyPanel project={project} method="shots.render" label={t('act.renderShots')} done={t('act.rendered')} blocked={unsaved} /></div>
|
||||
|
|
@ -55,3 +57,35 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Screenshot captions per language and scene (the shots copy file): eyebrow, the two title lines, the subline. */
|
||||
function Captions({ project, data, scenes }: { project: Project; data: EditableResult; scenes: string[] }) {
|
||||
const copy = data.shotsCopy ?? {};
|
||||
const langs = data.languages.map(l => l.id);
|
||||
const [lang, setLang] = useState(langs[0] ?? '');
|
||||
const L = langs.includes(lang) ? lang : langs[0] ?? '';
|
||||
return (
|
||||
<Card title={t('ed.captionsTitle')} icon="text" sub={t('ed.captionsSub', { file: data.files.shotsCopy ?? '' })} id="captions-editor">
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap', marginBottom: 12 }} role="tablist" aria-label={t('col.language')}>
|
||||
{langs.map(x => <button key={x} role="tab" aria-selected={x === L} className={`btn btn-sm${x === L ? ' btn-primary' : ''}`} onClick={() => setLang(x)}><Icon name="globe" />{x}</button>)}
|
||||
</div>
|
||||
<div className="ed-langs">
|
||||
{scenes.map(id => {
|
||||
const c = copy[L]?.[id] ?? {};
|
||||
const title = Array.isArray(c.title) ? c.title : [];
|
||||
return (
|
||||
<div className="ed-lang" key={id} data-caption={id}>
|
||||
<div className="small strong mono-id">{id}</div>
|
||||
<div className="ed-grid">
|
||||
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'eyebrow' }} saved={typeof c.eyebrow === 'string' ? c.eyebrow : ''} label={t('ed.eyebrow')} limit={40} />
|
||||
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'title', line: 0 }} saved={title[0] ?? ''} label={t('ed.titleLine', { n: 1 })} limit={30} />
|
||||
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'title', line: 1 }} saved={title[1] ?? ''} label={t('ed.titleLine', { n: 2 })} limit={30} />
|
||||
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'sub' }} saved={typeof c.sub === 'string' ? c.sub : ''} label={t('ed.subline')} limit={90} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
89
apps/desktop/src/screens/editors/StoreListingEditor.tsx
Normal file
89
apps/desktop/src/screens/editors/StoreListingEditor.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// The listing editor of a store-only app (no app folder, so no files to save into): the live texts per language
|
||||
// (listing.live), edited in place with the same counters and checks, and applied straight to the store per language
|
||||
// (listing.set: the store's dry run in the window, the native dialog, then the real run, which changes only the edited
|
||||
// fields). URLs, contact and app details stay with an app folder (they come from its config).
|
||||
import { useState } from 'react';
|
||||
import type { Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
import { appleLocaleIssues, playLangIssues, type FieldIssue } from '../../../../../cli/lib/listing-rules';
|
||||
import type { LiveListings } from '../../../../../cli/listing-store';
|
||||
import type { UnifiedApp } from '../../lib/identity';
|
||||
import { keyOf } from '../../lib/edits';
|
||||
import { useRead } from '../../store';
|
||||
import { ApplyPanel, type FieldIssueView, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
|
||||
const PLAY: { f: 'title' | 'short' | 'full'; label: Key; limit: number; rows?: number }[] = [
|
||||
{ f: 'title', label: 'lst.title', limit: 30 }, { f: 'short', label: 'lst.short', limit: 80 }, { f: 'full', label: 'lst.full', limit: 4000, rows: 10 },
|
||||
];
|
||||
const APPLE: { f: 'name' | 'subtitle' | 'promotionalText' | 'keywords' | 'description' | 'whatsNew'; label: Key; limit: number; rows?: number }[] = [
|
||||
{ f: 'name', label: 'lst.name', limit: 30 }, { f: 'subtitle', label: 'lst.subtitle', limit: 30 }, { f: 'promotionalText', label: 'lst.promo', limit: 170, rows: 3 },
|
||||
{ f: 'keywords', label: 'lst.keywords', limit: 100 }, { f: 'description', label: 'lst.description', limit: 4000, rows: 10 }, { f: 'whatsNew', label: 'lst.whatsNew', limit: 4000, rows: 4 },
|
||||
];
|
||||
const byField = (issues: FieldIssue[], f: string): FieldIssueView[] => issues.filter(i => i.field === f).map(i => ({ level: i.level, msg: i.msg }));
|
||||
|
||||
function Column({ project, store, lang, saved, appName, editable }: { project: Project; store: 'play' | 'apple'; lang: string; saved: Record<string, string>; appName: string; editable: boolean }) {
|
||||
const e = useEdits();
|
||||
const defs = store === 'play' ? PLAY : APPLE;
|
||||
const vals: Record<string, string> = {};
|
||||
const changed: Record<string, string> = {};
|
||||
for (const d of defs) {
|
||||
const p = e.get(project.id, keyOf({ kind: 'listing', store, lang, field: d.f, value: '' }));
|
||||
vals[d.f] = p && p.op.kind === 'listing' ? p.op.value : saved[d.f] ?? '';
|
||||
if (p && p.op.kind === 'listing') changed[d.f] = p.op.value;
|
||||
}
|
||||
const issues = store === 'play' ? playLangIssues(lang, { title: vals.title || undefined, short: vals.short ?? '', full: vals.full ?? '' }, appName)
|
||||
: appleLocaleIssues(lang, vals, appName);
|
||||
const errors = issues.filter(i => i.level === 'error' && changed[i.field] !== undefined).length;
|
||||
const fields = JSON.stringify(changed);
|
||||
const n = Object.keys(changed).length;
|
||||
return (
|
||||
<div className="ed-col" data-store={store} data-lang={lang}>
|
||||
<div className="act-head"><StoreGlyph store={store === 'play' ? 'android' : 'ios'} />{store === 'play' ? 'Google Play' : 'App Store'}<span className="mono-id small muted">{lang}</span></div>
|
||||
{store === 'apple' && !editable && <Notice tone="info" icon="info">{t('ed.noEditableVersion')}</Notice>}
|
||||
{defs.map(d => (
|
||||
<TextField key={d.f} project={project} area="listings" op={{ kind: 'listing', store, lang, field: d.f }} saved={saved[d.f] ?? ''} label={t(d.label)} limit={d.limit}
|
||||
multiline={!!d.rows} rows={d.rows} mono={d.f === 'keywords'} disabled={store === 'apple' && !editable && !['name', 'subtitle', 'promotionalText'].includes(d.f)}
|
||||
issues={byField(issues, d.f)} />
|
||||
))}
|
||||
<ApplyPanel project={project} method="listing.set" args={{ store, lang, fields }} label={t(store === 'play' ? 'ed.storeOnlyApplyPlay' : 'ed.storeOnlyApplyApple', { lang })} done={t('ai.pushed')}
|
||||
disabled={!n || errors > 0} hint={errors ? t('ed.fixFirst') : undefined} blocked={null}
|
||||
onApplied={() => { for (const f of Object.keys(changed)) e.set(project.id, { kind: 'listing', store, lang, field: f, value: changed[f]! }, 'listings', changed[f]); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoreListingEditor({ app }: { app: UnifiedApp }) {
|
||||
const r = useRead<LiveListings>(app.target, 'listing.live', { network: true, title: t('tab.listings') });
|
||||
const [lang, setLang] = useState('');
|
||||
// the window's edit store is keyed by app: a store-only app is keyed by its store target
|
||||
const project = { id: app.target, path: '', name: app.name, playAccount: null, appleAccount: null, added: '' } as Project;
|
||||
if (!r.data) {
|
||||
if (r.loading && !r.error) return <Card><Skeleton lines={6} block /></Card>;
|
||||
return <Card pad={false}><Empty illo="text" title={t('ed.liveEmpty')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('tf.read')}</button>}>{r.error ?? t('ed.storeOnlyNote')}</Empty></Card>;
|
||||
}
|
||||
const L = r.data;
|
||||
const langs = [...new Set([...(L.play ?? []).map(x => x.language), ...(L.apple?.locales ?? []).map(x => x.locale)])].sort();
|
||||
const cur = langs.includes(lang) ? lang : langs[0] ?? '';
|
||||
const pl = L.play?.find(x => x.language === cur);
|
||||
const al = L.apple?.locales.find(x => x.locale === cur);
|
||||
return (
|
||||
<>
|
||||
{Object.entries(L.errors).map(([s, e]) => <ErrorNotice key={s} error={`${s === 'play' ? 'Google Play' : 'App Store'}: ${e}`} />)}
|
||||
<Card title={t('ed.storeOnlyTitle')} icon="edit" sub={t('ed.storeOnlySub')} actions={<ReadControl r={r} />} id="store-listing-editor">
|
||||
<div className="stack" style={{ gap: 12 }}>
|
||||
<Notice tone="info" icon="info">{t('ed.storeOnlyNote')}</Notice>
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }} role="tablist" aria-label={t('col.language')}>
|
||||
{langs.map(x => <button key={x} role="tab" aria-selected={x === cur} className={`btn btn-sm${x === cur ? ' btn-primary' : ''}`} onClick={() => setLang(x)} data-lang-tab={x}><Icon name="globe" />{x}</button>)}
|
||||
</div>
|
||||
<div className="ed-cols">
|
||||
{pl && <Column project={project} store="play" lang={cur} saved={{ title: pl.title, short: pl.short, full: pl.full }} appName={app.name} editable />}
|
||||
{al && <Column project={project} store="apple" lang={cur} saved={al.values as Record<string, string>} appName={app.name} editable={L.apple!.editable} />}
|
||||
{!pl && !al && <Notice tone="warn">{t('ed.noStoreForLang', { lang: cur })}</Notice>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
import { useCallback, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import { api, type EditableResult, type EditResult, type Project, type ToolResult } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { type EditArea, type EditOp, edits, keyOf } from '../lib/edits';
|
||||
import { type EditArea, type EditOp, edits, type FieldOp, keyOf } from '../lib/edits';
|
||||
import { failureText } from '../lib/results';
|
||||
import { useRead, useWrite } from '../store';
|
||||
import { Icon, type IconName } from './icons';
|
||||
|
|
@ -61,7 +61,7 @@ interface FieldBase { project: Project; area: EditArea; label: string; hint?: Re
|
|||
/** A text field: one line or several, a live character counter against the store limit. */
|
||||
export function TextField({ project, area, op, saved, label, limit, multiline, rows = 3, placeholder, hint, issues = [], mono, id, disabled, kind }: FieldBase & {
|
||||
/** The edit without its value: { kind: 'listing', store, lang, field } or { kind: 'config', path } or contact. */
|
||||
op: Omit<Extract<EditOp, { kind: 'listing' }>, 'value'> | { kind: 'config'; path: string } | { kind: 'contact'; field: Extract<EditOp, { kind: 'contact' }>['field'] };
|
||||
op: FieldOp;
|
||||
saved: string; limit?: number; multiline?: boolean; rows?: number; placeholder?: string; mono?: boolean; kind?: 'url' | 'email' | 'text';
|
||||
}) {
|
||||
const e = useEdits();
|
||||
|
|
@ -263,9 +263,10 @@ export function EditBar({ project, area, data, files, onSaved }: { project: Proj
|
|||
}
|
||||
|
||||
/** A store write with its dry run in the window first: the plan (old -> new per store and language), then Apply. */
|
||||
export function ApplyPanel({ project, method, args = {}, label, icon = 'upload', done, disabled, hint, blocked }: {
|
||||
export function ApplyPanel({ project, method, args = {}, label, icon = 'upload', done, disabled, hint, blocked, onApplied }: {
|
||||
project: Project; method: string; args?: Record<string, unknown>; label: string; icon?: IconName; done: string; disabled?: boolean; hint?: string;
|
||||
/** Unsaved edits that the store write would not include. */ blocked?: string | null;
|
||||
/** After the real run went through. */ onApplied?: () => void;
|
||||
}) {
|
||||
const w = useWrite(project.id);
|
||||
const [st, setSt] = useState<{ loading: boolean; text: string; ok: boolean } | null>(null);
|
||||
|
|
@ -289,7 +290,7 @@ export function ApplyPanel({ project, method, args = {}, label, icon = 'upload',
|
|||
<pre className={`selectable ed-dry${st.ok ? '' : ' bad'}`}>{st.text}</pre>
|
||||
<div className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => setSt(null)}>{t('ed.close')}</button>
|
||||
{st.ok && <WriteButton run={w} primary icon={icon} disabled={!!blocked} hint={blocked ?? undefined} w={{ method, args, title: label, done, after: () => setSt(null) }}>{t('ed.applyNow')}</WriteButton>}
|
||||
{st.ok && <WriteButton run={w} primary icon={icon} disabled={!!blocked} hint={blocked ?? undefined} w={{ method, args, title: label, done, after: () => { setSt(null); onApplied?.(); } }}>{t('ed.applyNow')}</WriteButton>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue