desktop: five sections by job (Release, Store page, Revenue, Players, Health), Game services view, sidebar by attention
- app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the context menu list sections and sub views; old tab ids, section and section/sub links all resolve - Players > Game services (games only): both stores, one row per leaderboard / achievement with its languages and each store's state, Game Center locale map, Play API-disabled state with the console plan - games status --json: items, localeMap, playApiDisabled; inspect summary: games - sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio filter, workspace as a footer icon row - boot check opens places like a person (section, then sub view)
This commit is contained in:
parent
98cd1d3c92
commit
0ef27cf2e4
22 changed files with 918 additions and 115 deletions
|
|
@ -1,4 +1,5 @@
|
|||
// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh),
|
||||
// Shell: sidebar (apps grouped by what they need: attention, in progress, live, not on a store yet; one status line
|
||||
// each; a studio filter; the workspace as a compact footer row), top bar (breadcrumbs, last read, refresh),
|
||||
// the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide.
|
||||
import { DryRunPreview } from './ui/DryRunPreview';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
|
@ -6,8 +7,8 @@ import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api';
|
|||
import { devSwitch } from './dev/switches';
|
||||
import { type Key, t } from './i18n';
|
||||
import { ago, num } from './lib/format';
|
||||
import { groupByStudio, type UnifiedApp } from './lib/identity';
|
||||
import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
|
||||
import type { UnifiedApp } from './lib/identity';
|
||||
import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, SECTION_ICON, SECTION_KEY, sectionOf, sectionsFor, subsOf, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
|
||||
import { usePrefs } from './prefs-ctx';
|
||||
import { Accounts } from './screens/Accounts';
|
||||
import { AppFolders } from './screens/AppFolders';
|
||||
|
|
@ -28,16 +29,18 @@ import { Toasts } from './ui/Toasts';
|
|||
import { useContextMenu } from './ui/ContextMenu';
|
||||
import { appMenu } from './ui/app-menu';
|
||||
import { isEditable, isItem, type MenuInput } from './ui/menu-logic';
|
||||
import { worstTone } from './ui/ds';
|
||||
import { attention } from './lib/model';
|
||||
import { attention, isGameSummary, SIDE_GROUPS, type SideGroup, sideStatus } from './lib/model';
|
||||
|
||||
const WS_ICON: Record<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' };
|
||||
export const WS_KEY: Record<WorkspaceScreen, Key> = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' };
|
||||
|
||||
const SIDE_KEY = 'storebound.side.v1';
|
||||
const STUDIO_KEY = 'storebound.side.studio.v1';
|
||||
function loadCollapsed(): string[] {
|
||||
try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; }
|
||||
}
|
||||
function loadStudio(): string | null { try { return localStorage.getItem(STUDIO_KEY); } catch { return null; } }
|
||||
const GROUP_KEY: Record<SideGroup | 'ignored', Key> = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' };
|
||||
|
||||
export function App({ onTheme }: { onTheme: () => void }) {
|
||||
const d = useData();
|
||||
|
|
@ -47,6 +50,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const [welcome, setWelcome] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState<string[]>(loadCollapsed);
|
||||
const [studioFilter, setStudioFilterState] = useState<string | null>(loadStudio);
|
||||
const setStudioFilter = (v: string | null) => { setStudioFilterState(v); try { if (v) localStorage.setItem(STUDIO_KEY, v); else localStorage.removeItem(STUDIO_KEY); } catch { /* private mode */ } };
|
||||
const exports = useExportItems();
|
||||
const runExport = useRunExport();
|
||||
|
||||
|
|
@ -56,16 +61,27 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const allReads = useReadState({ tags: ['all'] });
|
||||
const [showIgnored, setShowIgnored] = useShowIgnored();
|
||||
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
||||
const listed = showIgnored ? [...visible, ...ignoredApps] : visible;
|
||||
const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }];
|
||||
// Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the tab when the other app has it)
|
||||
const sideOrder = groups.flatMap(x => x.rows.map(r => r.app));
|
||||
const gameOf = useCallback((a: UnifiedApp) => isGameSummary(d.statusFor(a)?.summary), [d]);
|
||||
const tabsOf = useCallback((a: UnifiedApp) => tabsFor(!!a.project, gameOf(a)), [gameOf]);
|
||||
// the sidebar: apps by what they need (attention, in progress, live, not on a store yet), one status line each
|
||||
const studioOn = studioFilter && (studioFilter === 'none' || d.studios.some(s => s.id === studioFilter)) ? studioFilter : null;
|
||||
const inStudio = useCallback((a: UnifiedApp) => !studioOn || (studioOn === 'none' ? !a.studio : a.studio === studioOn), [studioOn]);
|
||||
const sideRows = useMemo(() => visible.filter(inStudio).map(a => {
|
||||
const e = d.statusFor(a), l = d.liveFor(a);
|
||||
return { app: a, st: sideStatus({ att: attention(a.key, a.name, e, l), all: l?.release.all ?? [], read: !!l || e?.release.source === 'live', missing: !!a.project?.missing, storeOnly: !a.project }) };
|
||||
}), [visible, d, inStudio]);
|
||||
const groups: { id: SideGroup | 'ignored'; rows: typeof sideRows }[] = [
|
||||
...SIDE_GROUPS.map(g => ({ id: g, rows: sideRows.filter(r => r.st.group === g) })).filter(g => g.rows.length),
|
||||
...(showIgnored && ignoredApps.length ? [{ id: 'ignored' as const, rows: ignoredApps.filter(inStudio).map(a => ({ app: a, st: { group: 'draft' as SideGroup, text: t('side.ignored'), tone: 'neutral' as const } })) }] : []),
|
||||
];
|
||||
// Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the place when the other app has it)
|
||||
const sideOrder = groups.flatMap(x => (collapsed.includes(`g:${x.id}`) ? [] : x.rows.map(r => r.app)));
|
||||
const stepApp = useCallback((dir: 1 | -1) => {
|
||||
if (!sideOrder.length) return;
|
||||
const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1;
|
||||
const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!;
|
||||
go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsFor(!!next.project).includes(route.tab) ? route.tab : 'releases' });
|
||||
}, [sideOrder, route, go]);
|
||||
go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsOf(next).includes(route.tab) ? route.tab : 'releases' });
|
||||
}, [sideOrder, route, go, tabsOf]);
|
||||
const toggleGroup = (id: string) => setCollapsed(c => {
|
||||
const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id];
|
||||
try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ }
|
||||
|
|
@ -94,9 +110,12 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
}, [go, d.reload]);
|
||||
|
||||
const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined;
|
||||
const tabs = app ? tabsFor(!!app.project) : [];
|
||||
const game = app ? gameOf(app) : false;
|
||||
const tabs = app ? tabsOf(app) : [];
|
||||
const sections = app ? sectionsFor(!!app.project, game) : [];
|
||||
|
||||
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs
|
||||
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, 1-5 (no modifier) the app's sections,
|
||||
// Cmd/Ctrl+Shift+[ ] the previous / next section
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
|
|
@ -109,15 +128,20 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! });
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && tabs.length) {
|
||||
if (!mod && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key) && route.screen === 'app' && app && !isEditable(e.target as HTMLInputElement) && !document.querySelector('.ds-menu, .ds-dialog')) {
|
||||
const sec = sections[Number(e.key) - 1];
|
||||
if (sec) { e.preventDefault(); go({ ...route, tab: subsOf(sec, !!app.project, game)[0]! }); }
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && app && sections.length) {
|
||||
e.preventDefault();
|
||||
const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1);
|
||||
go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! });
|
||||
const i = sections.indexOf(sectionOf(route.tab)) + (e.key === ']' || e.key === '}' ? 1 : -1);
|
||||
go({ ...route, tab: subsOf(sections[(i + sections.length) % sections.length]!, !!app.project, game)[0]! });
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [palette, welcome, route, go, tabs, stepApp]);
|
||||
}, [palette, welcome, route, go, app, sections, game, stepApp]);
|
||||
|
||||
const closeWelcome = useCallback(() => {
|
||||
setWelcome(false);
|
||||
|
|
@ -131,13 +155,18 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') };
|
||||
const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name;
|
||||
for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) },
|
||||
run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) });
|
||||
run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' }) });
|
||||
c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) });
|
||||
c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) });
|
||||
c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) });
|
||||
WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) }));
|
||||
for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) });
|
||||
if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) });
|
||||
// every section of the open app (1-5) and every sub view in it
|
||||
if (app) sections.forEach((sec, i) => {
|
||||
const subs = subsOf(sec, !!app.project, game);
|
||||
c.push({ id: `sec-${sec}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])}`, icon: SECTION_ICON[sec], hint: String(i + 1), keywords: subs.map(x => t(TAB_KEY[x])).join(' '), run: () => go({ screen: 'app', appId: app.key, tab: subs[0]! }) });
|
||||
if (subs.length > 1) for (const tab of subs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) });
|
||||
});
|
||||
// the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is
|
||||
// also one Ctrl+K away; opening a tab is already above
|
||||
if (app) {
|
||||
|
|
@ -166,10 +195,10 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme });
|
||||
c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) });
|
||||
return c;
|
||||
}, [d, visible, app, tabs, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update]);
|
||||
}, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]);
|
||||
|
||||
const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }]
|
||||
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }]
|
||||
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(SECTION_KEY[sectionOf(route.tab)]), to: app ? { screen: 'app', appId: app.key, tab: subsOf(sectionOf(route.tab), !!app.project, game)[0] ?? route.tab } : undefined }, ...(app && subsOf(sectionOf(route.tab), !!app.project, game).length > 1 ? [{ label: t(TAB_KEY[route.tab]) }] : [])]
|
||||
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
|
||||
const lastRead = d.live?.at ?? null;
|
||||
// the app on screen is read first by the background reader
|
||||
|
|
@ -180,26 +209,19 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length;
|
||||
|
||||
const menu = useContextMenu();
|
||||
const appItem = (a: UnifiedApp) => {
|
||||
const e = d.statusFor(a);
|
||||
const missing = !!a.project?.missing;
|
||||
const tone = missing ? 'bad' : (worstTone(attention(a.key, a.name, e, d.liveFor(a))) ?? (e ? 'ok' : null));
|
||||
const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0);
|
||||
return (
|
||||
<div role="listitem" key={a.key}>
|
||||
<button {...menu(() => appMenu(a, d, nav, { showIgnored }))} className={`nav nav-app${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' })}>
|
||||
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{tone && <span className={`ds-sdot ${tone}`} role="img" aria-label={tone === 'bad' ? t('pf.blocker') : tone === 'warn' ? t('pf.warning') : t('pf.note')} />}</span>
|
||||
<span className="grow">{a.name}</span>
|
||||
{a.ignored && <span className="so-dot" title={t('ignored.rowHint')}><Icon name="eyeOff" /></span>}
|
||||
{!a.ignored && a.ignoredStores.length > 0 && <span className="so-dot" title={t('ignored.sideHint', { stores: a.ignoredStores.map(k => (k === 'ios' ? 'App Store' : 'Google Play')).join(', ') })}><Icon name="eyeOff" /></span>}
|
||||
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
|
||||
{missing ? <span className="tag-count bad" title={t('folder.missingShort')} aria-label={t('folder.missingShort')}>!</span>
|
||||
: bad > 0 && <span className="tag-count bad" aria-label={t('nav.problems', { count: bad })}>{bad}</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
|
||||
<div role="listitem" key={a.key}>
|
||||
<button {...menu(() => appMenu(a, d, nav, { showIgnored }))} className={`nav nav-app has-line${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' })}
|
||||
title={`${a.name} · ${st.text}`}>
|
||||
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{(st.tone === 'bad' || st.tone === 'warn') && <span className={`ds-sdot ${st.tone}`} role="img" aria-label={st.tone === 'bad' ? t('pf.blocker') : t('pf.warning')} />}</span>
|
||||
<span className="grow side-t"><span className="side-n">{a.name}</span><span className={`side-s ${st.tone}`} data-testid="side-status">{st.text}</span></span>
|
||||
{!a.ignored && a.ignoredStores.length > 0 && <span className="so-dot" title={t('ignored.sideHint', { stores: a.ignoredStores.map(k => (k === 'ios' ? 'App Store' : 'Google Play')).join(', ') })}><Icon name="eyeOff" /></span>}
|
||||
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE];
|
||||
|
||||
return (
|
||||
<NavCtx.Provider value={nav}>
|
||||
|
|
@ -213,36 +235,41 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
</button>
|
||||
</div>
|
||||
<div className="side-scroll">
|
||||
<button className="nav" aria-current={current({ screen: 'portfolio' })} onClick={() => go({ screen: 'portfolio' })}>
|
||||
<Icon name="portfolio" weight={current({ screen: 'portfolio' }) ? 'fill' : 'regular'} /><span className="grow">{t('nav.portfolio')}</span><span className="count">{visible.length}</span>
|
||||
</button>
|
||||
<div className="side-label" id="apps-label"><span>{d.studios.length ? t('nav.studiosLabel') : t('nav.appsLabel')}</span>
|
||||
<div className="side-label" id="apps-label"><span>{t('nav.appsLabel')} <span className="count">{visible.length}</span></span>
|
||||
<button onClick={() => go({ screen: 'apps' })} aria-label={t('action.addFolder')} title={t('action.addFolder')}><Icon name="plus" /></button>
|
||||
</div>
|
||||
{d.studios.length > 1 && (
|
||||
<div className="side-studios" role="group" aria-label={t('side.studioFilter')}>
|
||||
<button aria-pressed={!studioOn} onClick={() => setStudioFilter(null)}>{t('side.allStudios')}</button>
|
||||
{d.studios.map(s => <button key={s.id} aria-pressed={studioOn === s.id} onClick={() => setStudioFilter(studioOn === s.id ? null : s.id)}><StudioMark studio={s} size="sm" />{s.name}</button>)}
|
||||
</div>
|
||||
)}
|
||||
{groups.map(g => {
|
||||
const s = d.studios.find(x => x.id === g.studio) ?? null;
|
||||
const gid = g.studio ?? 'none';
|
||||
const gid = `g:${g.id}`;
|
||||
const open = !collapsed.includes(gid);
|
||||
if (!d.studios.length) return <div role="list" aria-labelledby="apps-label" key={gid}>{g.rows.map(r => appItem(r.app))}</div>;
|
||||
return (
|
||||
<div className="side-group" key={gid}>
|
||||
<button className="side-group-head" aria-expanded={open} aria-controls={`sg-${gid}`} onClick={() => toggleGroup(gid)}>
|
||||
<Icon name="chevronRight" className="chev" /><StudioMark studio={s} size="sm" /><span className="grow">{s?.name ?? t('studio.none')}</span><span className="count">{g.rows.length}</span>
|
||||
<div className="side-group" key={gid} data-group={g.id}>
|
||||
<button className="side-group-head" aria-expanded={open} aria-controls={`sg-${g.id}`} onClick={() => toggleGroup(gid)}>
|
||||
<Icon name="chevronRight" className="chev" /><span className="grow">{t(GROUP_KEY[g.id])}</span><span className="count">{g.rows.length}</span>
|
||||
</button>
|
||||
{open && <div role="list" id={`sg-${gid}`} aria-label={s?.name ?? t('studio.none')}>{g.rows.map(r => appItem(r.app))}</div>}
|
||||
{open && <div role="list" id={`sg-${g.id}`} aria-label={t(GROUP_KEY[g.id])}>{g.rows.map(r => appItem(r.app, r.st))}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!visible.length && !showIgnored && <p className="side-empty">{t('nav.noApps')}</p>}
|
||||
{ignoredApps.length > 0 && <button className="link side-ignored-toggle" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)} data-testid="toggle-ignored">{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>}
|
||||
<div className="side-label"><span>{t('nav.workspace')}</span></div>
|
||||
{WORKSPACE.map((s, i) => (
|
||||
<button key={s} className="nav" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${t(WS_KEY[s])} (${MOD} ${i + 2})`}>
|
||||
<Icon name={WS_ICON[s]} weight={current({ screen: s }) ? 'fill' : 'regular'} /><span className="grow">{t(WS_KEY[s])}</span>
|
||||
{s === 'accounts' && <span className="count">{d.accounts.length}</span>}
|
||||
{s === 'ignored' && ignoredCount > 0 && <span className="count">{ignoredCount}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="side-ws" role="group" aria-label={t('nav.workspace')}>
|
||||
{WS_ROW.map((s, i) => {
|
||||
const label = s === 'portfolio' ? t('nav.portfolio') : t(WS_KEY[s]);
|
||||
const n = s === 'accounts' ? d.accounts.length : s === 'ignored' ? ignoredCount : 0;
|
||||
return (
|
||||
<button key={s} className="nav-ws" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${label} (${MOD} ${i + 1})`} aria-label={label} data-screen={s}>
|
||||
<Icon name={s === 'portfolio' ? 'portfolio' : WS_ICON[s]} weight={current({ screen: s }) ? 'fill' : 'regular'} />
|
||||
{n > 0 && <span className="ws-n">{n}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="side-foot" aria-live="polite">
|
||||
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
|
||||
|
|
|
|||
|
|
@ -245,3 +245,45 @@
|
|||
.table-wrap .table tbody tr:hover > :first-child, .ds-table-wrap .table tbody tr:hover > :first-child { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--ds-surface, var(--surface))); }
|
||||
/* Settings > Appearance > Tables: compact */
|
||||
:root[data-density="compact"] .table th, :root[data-density="compact"] .table td { padding-top: 5px; padding-bottom: 5px; padding-left: 8px; padding-right: 8px; font-size: 12.5px; }
|
||||
|
||||
/* ---------- App sections (docs IA): section header, next step, sub views ---------- */
|
||||
.ds-tab-k { font: 600 10px/1 var(--mono, ui-monospace, monospace); min-width: 16px; height: 16px; display: inline-grid; place-items: center; border-radius: 4px; color: var(--muted); border: 1px solid var(--ds-stroke); background: var(--ds-surface); margin-left: 2px; opacity: .75; }
|
||||
.ds-tab[aria-selected="true"] .ds-tab-k { opacity: 1; }
|
||||
.sec-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "t next" "subs subs"; gap: 10px 16px; align-items: center; padding: 2px 4px 4px; }
|
||||
.sec-head-t { grid-area: t; min-width: 0; }
|
||||
.sec-head-name { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--ds-t-h2, 18px); font-weight: 700; letter-spacing: -.01em; }
|
||||
.sec-head-name svg { width: 18px; height: 18px; color: var(--tint, var(--accent-solid)); }
|
||||
.sec-head-for { margin: 2px 0 0; color: var(--muted); font-size: var(--t-meta); }
|
||||
.sec-next { grid-area: next; display: inline-flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; min-height: 32px; box-sizing: border-box; border-radius: var(--ds-r); border: 1px solid var(--ds-stroke); background: var(--ds-surface); font-weight: 600; font-size: var(--t-meta); max-width: 560px; }
|
||||
.sec-next > svg { width: 15px; height: 15px; flex: none; }
|
||||
.sec-next-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sec-next:not(:has(.btn)) { padding-right: 12px; }
|
||||
.sec-next.tone-bad { background: var(--bad-bg); color: var(--bad-ink); border-color: transparent; }
|
||||
.sec-next.tone-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: transparent; }
|
||||
.sec-next.tone-ok > svg { color: var(--ok-solid); }
|
||||
.sec-next.tone-info > svg { color: var(--accent-solid); }
|
||||
.sec-next .btn { flex: none; }
|
||||
.sec-subs { grid-area: subs; justify-self: start; }
|
||||
.sec-subs .ed-dot { margin-left: 2px; }
|
||||
@media (max-width: 1100px) { .sec-head { grid-template-columns: 1fr; grid-template-areas: "t" "next" "subs"; } .sec-next { justify-self: start; } }
|
||||
|
||||
/* ---------- Sidebar: attention groups, one status line per app, workspace footer ---------- */
|
||||
.nav-app.has-line { height: auto; min-height: 40px; padding-top: 4px; padding-bottom: 4px; align-items: center; }
|
||||
.nav-app .side-t { display: grid; min-width: 0; line-height: 1.2; }
|
||||
.nav-app .side-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-app .side-s { font-size: 11px; font-weight: 500; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-app .side-s.bad { color: var(--bad-ink); } .nav-app .side-s.warn { color: var(--warn-ink); } .nav-app .side-s.ok { color: var(--ok-ink); }
|
||||
:root[data-theme="dark"] .nav-app .side-s.bad { color: color-mix(in srgb, var(--bad-solid) 70%, white); }
|
||||
:root[data-theme="dark"] .nav-app .side-s.warn { color: color-mix(in srgb, var(--warn-solid) 70%, white); }
|
||||
:root[data-theme="dark"] .nav-app .side-s.ok { color: color-mix(in srgb, var(--ok-solid) 70%, white); }
|
||||
.side-studios { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 6px 2px; }
|
||||
.side-studios button { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 99px; font-size: 11px; font-weight: 600; color: var(--muted); border: 1px solid var(--line); }
|
||||
.side-studios button[aria-pressed="true"] { color: var(--ink); background: var(--surface); box-shadow: var(--shadow-1); }
|
||||
.side-studios button:focus-visible { outline: 2px solid var(--focus); }
|
||||
.side-ws { display: flex; gap: 2px; padding: 6px 8px; border-top: 1px solid var(--line); }
|
||||
.side-ws button { all: unset; cursor: pointer; position: relative; flex: 1; height: 30px; display: grid; place-items: center; border-radius: 7px; color: var(--muted); }
|
||||
.side-ws button:hover { background: var(--hover); color: var(--ink); }
|
||||
.side-ws button[aria-current="page"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1); }
|
||||
.side-ws button:focus-visible { outline: 2px solid var(--focus); }
|
||||
.side-ws svg { width: 17px; height: 17px; }
|
||||
.side-ws .ws-n { position: absolute; top: 2px; right: 4px; font-size: 9.5px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
|
|
|
|||
|
|
@ -430,6 +430,16 @@ const GAMES_PRISM = {
|
|||
play: { appId: '123456789012', resource: '123456789012 (android/app/src/main/res/values/games-ids.xml)', leaderboards: 2, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: [] },
|
||||
findings: [{ store: 'apple', level: 'ok', id: 'gc.entitlement', msg: 'com.apple.developer.game-center in ios/App/App/App.entitlements' }, { store: 'play', level: 'ok', id: 'pgs.app-id', msg: 'games_APP_ID 123456789012 matches the config' }],
|
||||
links: [{ label: 'Play Console: Play Games Services configuration', url: 'https://play.google.com/console/u/0/developers/4444/app/9876/play-games-services/setup' }],
|
||||
items: [
|
||||
{ kind: 'leaderboard', id: 'com.example.prismpuddle.best', name: 'Best score', locales: ['en', 'tr', 'ja', 'ar'], ios: true, android: true,
|
||||
apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }, { locale: 'tr', action: 'keep' }, { locale: 'ja', action: 'keep' }, { locale: 'ar-SA', action: 'keep' }] }, play: { action: 'keep', changed: [] } },
|
||||
{ kind: 'leaderboard', id: 'com.example.prismpuddle.daily', name: 'Daily puddle', locales: ['en', 'tr'], ios: true, android: true,
|
||||
apple: { action: 'create', changed: ['referenceName'], locales: [{ locale: 'en-US', action: 'create' }, { locale: 'tr', action: 'create' }] }, play: { action: 'keep', changed: [] } },
|
||||
...['first-drop', 'ten-levels', 'no-hints', 'rainbow', 'speedrun'].map((k, i) => ({ kind: 'achievement', id: `com.example.prismpuddle.${k}`, name: ['First drop', 'Ten levels', 'No hints', 'Rainbow', 'Speedrun'][i]!, locales: ['en', 'tr', 'ja', 'ar'], points: [10, 20, 30, 20, 20][i], hidden: k === 'speedrun', ios: true, android: true,
|
||||
apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }] }, play: { action: i === 2 ? 'update' : 'keep', changed: i === 2 ? ['draft.description'] : [] } })),
|
||||
],
|
||||
localeMap: { configured: { ar: 'ar-SA' }, suggested: [{ id: 'en', from: 'en-US', to: 'en-GB' }] },
|
||||
playApiDisabled: null,
|
||||
};
|
||||
const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data });
|
||||
|
||||
|
|
|
|||
|
|
@ -2116,4 +2116,106 @@ export const en = {
|
|||
'cm.copyPrincipal': "Service account / key",
|
||||
'cm.copyProfile': "Profile name",
|
||||
'cm.openAccounts': "Open Studios & accounts",
|
||||
"sec.release": "Release",
|
||||
"sec.store": "Store page",
|
||||
"sec.revenue": "Revenue",
|
||||
"sec.players": "Players",
|
||||
"sec.health": "Health",
|
||||
"sec.release.for": "Get a version to players on both stores: builds, TestFlight, review and rollout.",
|
||||
"sec.store.for": "What people see on the store: texts per language, screenshots and app details.",
|
||||
"sec.revenue.for": "What the app sells and for how much: in-app products, subscriptions and prices.",
|
||||
"sec.players.for": "What players say, and for games what they can earn: reviews, Game Center, Play Games.",
|
||||
"sec.health.for": "Is the app well and ready: crashes and ANRs, growth, checks to fix first, more tools.",
|
||||
"sec.views": "{section} views",
|
||||
"tab.games": "Game services",
|
||||
"next.open": "Open {where}",
|
||||
"next.rejected": "{version} was rejected on {store}",
|
||||
"next.halted": "{version} is halted on {store}",
|
||||
"next.blockers": "{count} blockers before submit",
|
||||
"next.blockers_one": "1 blocker before submit",
|
||||
"next.rollout": "{version} is rolling out at {share}",
|
||||
"next.approved": "{version} is approved: release it",
|
||||
"next.submit": "Submit {version} for review",
|
||||
"next.review": "{version} is in review",
|
||||
"next.managed": "{version} waits in Publishing overview (managed publishing)",
|
||||
"next.upload": "Upload a first build",
|
||||
"next.live": "Live: {version}",
|
||||
"next.notRead": "Stores not read yet",
|
||||
"next.storeLive": "The live store page, read from the stores",
|
||||
"next.listingProblems": "{count} listing problems",
|
||||
"next.listingProblems_one": "1 listing problem",
|
||||
"next.unsaved": "{count} unsaved edits",
|
||||
"next.unsaved_one": "1 unsaved edit",
|
||||
"next.listingsUnread": "Listings not checked yet",
|
||||
"next.storeOk": "Store page in step with the config",
|
||||
"next.catalogDiff": "{count} products differ between the stores",
|
||||
"next.catalogDiff_one": "1 product differs between the stores",
|
||||
"next.noProducts": "No products yet: add one",
|
||||
"next.products": "{products} products · {subs} subscriptions",
|
||||
"next.reviews": "{count} reviews without a reply",
|
||||
"next.reviews_one": "1 review without a reply",
|
||||
"next.reviewsUnread": "Reviews not read yet",
|
||||
"next.reviewsOk": "All reviews answered",
|
||||
"next.gamesApiOff": "Play Games Services API is off",
|
||||
"next.gamesBlockers": "{count} game services blockers",
|
||||
"next.gamesBlockers_one": "1 game services blocker",
|
||||
"next.gamesSync": "Game services: {count} changes to sync",
|
||||
"next.gamesSync_one": "Game services: 1 change to sync",
|
||||
"next.fixFirst": "{count} problems to fix first",
|
||||
"next.fixFirst_one": "1 problem to fix first",
|
||||
"next.warnings": "{count} warnings to look at",
|
||||
"next.warnings_one": "1 warning to look at",
|
||||
"next.healthOk": "No problems found",
|
||||
"side.gAttention": "Needs attention",
|
||||
"side.gProgress": "In progress",
|
||||
"side.gLive": "Live",
|
||||
"side.gDraft": "Not on a store yet",
|
||||
"side.missing": "Folder missing",
|
||||
"side.rejected": "Rejected · {what}",
|
||||
"side.halted": "Halted · {what}",
|
||||
"side.blockers": "{count} blockers",
|
||||
"side.blockers_one": "1 blocker",
|
||||
"side.rollout": "Rollout {share} · {what}",
|
||||
"side.review": "In review · {what}",
|
||||
"side.approved": "Approved · {what}",
|
||||
"side.draft": "Draft · {what}",
|
||||
"side.live": "Live · {version}",
|
||||
"side.liveWarn": "Live · {version} · {count} warnings",
|
||||
"side.liveWarn_one": "Live · {version} · 1 warning",
|
||||
"side.storeOnly": "Store only · not read yet",
|
||||
"side.notRead": "Not read yet",
|
||||
"side.warnings": "{count} warnings",
|
||||
"side.warnings_one": "1 warning",
|
||||
"side.notOnStore": "No store version yet",
|
||||
"side.ignored": "Ignored",
|
||||
"side.studioFilter": "Show apps of a studio",
|
||||
"side.allStudios": "All",
|
||||
"games.keep": "In step",
|
||||
"games.update": "Differs",
|
||||
"games.create": "Missing",
|
||||
"games.notOnStore": "Not on this store",
|
||||
"games.notListed": "Not planned",
|
||||
"games.notRead": "Not read",
|
||||
"games.item": "Item",
|
||||
"games.name": "Name",
|
||||
"games.points": "Points",
|
||||
"games.languages": "Languages",
|
||||
"games.leaderboard": "Leaderboard",
|
||||
"games.achievement": "Achievement",
|
||||
"games.hidden": "hidden until earned",
|
||||
"games.localeChanges": "Game Center languages: {list}",
|
||||
"games.checkAgain": "Check again",
|
||||
"games.apiOffTitle": "Play Games Services API is off.",
|
||||
"games.apiOffText": "Turn on {api} in the service account's Google Cloud project, wait a minute, then check again.",
|
||||
"games.itemsTitle": "Leaderboards and achievements",
|
||||
"games.itemsSub": "One list in the config, mapped to both stores: each store's state against the config.",
|
||||
"games.refresh": "Read again",
|
||||
"games.noItems": "No leaderboards or achievements in the config yet (games.leaderboards, games.achievements).",
|
||||
"games.notReadYet": "Reading game services…",
|
||||
"games.images": "Images of leaderboards and achievements are added in the consoles; on Play the existing icon is kept on every sync.",
|
||||
"games.localeMap": "Game Center locale map",
|
||||
"games.localeMapSub": "Config language to the locale the live Game Center uses (games.localeMap).",
|
||||
"games.mapSet": "Set",
|
||||
"games.mapSuggested": "Suggested",
|
||||
"games.mapHint": "Add to games.localeMap: {entries}",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -2118,4 +2118,106 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'cm.copyPrincipal': "Hizmet hesabı / anahtar",
|
||||
'cm.copyProfile': "Profil adı",
|
||||
'cm.openAccounts': "Stüdyolar ve hesapları aç",
|
||||
"sec.release": "Yayın",
|
||||
"sec.store": "Mağaza sayfası",
|
||||
"sec.revenue": "Gelir",
|
||||
"sec.players": "Oyuncular",
|
||||
"sec.health": "Sağlık",
|
||||
"sec.release.for": "Bir sürümü iki mağazada oyunculara ulaştır: build, TestFlight, inceleme ve dağıtım.",
|
||||
"sec.store.for": "Mağazada insanların gördüğü: dile göre metinler, ekran görüntüleri ve uygulama bilgileri.",
|
||||
"sec.revenue.for": "Uygulamanın sattıkları ve fiyatları: uygulama içi ürünler, abonelikler ve fiyatlar.",
|
||||
"sec.players.for": "Oyuncuların söyledikleri ve oyunlarda kazanabilecekleri: yorumlar, Game Center, Play Games.",
|
||||
"sec.health.for": "Uygulama sağlıklı ve hazır mı: çökmeler ve ANR, büyüme, önce düzeltilecek kontroller, diğer araçlar.",
|
||||
"sec.views": "{section} görünümleri",
|
||||
"tab.games": "Oyun servisleri",
|
||||
"next.open": "{where} aç",
|
||||
"next.rejected": "{version}, {store} tarafından reddedildi",
|
||||
"next.halted": "{version}, {store} üzerinde durduruldu",
|
||||
"next.blockers": "Göndermeden önce {count} engel",
|
||||
"next.blockers_one": "Göndermeden önce 1 engel",
|
||||
"next.rollout": "{version} dağıtımı {share} seviyesinde",
|
||||
"next.approved": "{version} onaylandı: yayınla",
|
||||
"next.submit": "{version} sürümünü incelemeye gönder",
|
||||
"next.review": "{version} incelemede",
|
||||
"next.managed": "{version} Yayınlama özeti'nde bekliyor (yönetilen yayınlama)",
|
||||
"next.upload": "İlk build'i yükle",
|
||||
"next.live": "Yayında: {version}",
|
||||
"next.notRead": "Mağazalar henüz okunmadı",
|
||||
"next.storeLive": "Mağazalardan okunan canlı mağaza sayfası",
|
||||
"next.listingProblems": "{count} mağaza metni sorunu",
|
||||
"next.listingProblems_one": "1 mağaza metni sorunu",
|
||||
"next.unsaved": "{count} kaydedilmemiş düzenleme",
|
||||
"next.unsaved_one": "1 kaydedilmemiş düzenleme",
|
||||
"next.listingsUnread": "Mağaza metinleri henüz kontrol edilmedi",
|
||||
"next.storeOk": "Mağaza sayfası yapılandırmayla uyumlu",
|
||||
"next.catalogDiff": "{count} ürün mağazalar arasında farklı",
|
||||
"next.catalogDiff_one": "1 ürün mağazalar arasında farklı",
|
||||
"next.noProducts": "Henüz ürün yok: bir tane ekle",
|
||||
"next.products": "{products} ürün · {subs} abonelik",
|
||||
"next.reviews": "{count} yorum yanıtsız",
|
||||
"next.reviews_one": "1 yorum yanıtsız",
|
||||
"next.reviewsUnread": "Yorumlar henüz okunmadı",
|
||||
"next.reviewsOk": "Tüm yorumlar yanıtlandı",
|
||||
"next.gamesApiOff": "Play Games Services API kapalı",
|
||||
"next.gamesBlockers": "{count} oyun servisi engeli",
|
||||
"next.gamesBlockers_one": "1 oyun servisi engeli",
|
||||
"next.gamesSync": "Oyun servisleri: eşitlenecek {count} değişiklik",
|
||||
"next.gamesSync_one": "Oyun servisleri: eşitlenecek 1 değişiklik",
|
||||
"next.fixFirst": "Önce düzeltilecek {count} sorun",
|
||||
"next.fixFirst_one": "Önce düzeltilecek 1 sorun",
|
||||
"next.warnings": "Bakılacak {count} uyarı",
|
||||
"next.warnings_one": "Bakılacak 1 uyarı",
|
||||
"next.healthOk": "Sorun bulunmadı",
|
||||
"side.gAttention": "İlgi bekliyor",
|
||||
"side.gProgress": "Sürüyor",
|
||||
"side.gLive": "Yayında",
|
||||
"side.gDraft": "Henüz mağazada değil",
|
||||
"side.missing": "Klasör bulunamadı",
|
||||
"side.rejected": "Reddedildi · {what}",
|
||||
"side.halted": "Durduruldu · {what}",
|
||||
"side.blockers": "{count} engel",
|
||||
"side.blockers_one": "1 engel",
|
||||
"side.rollout": "Dağıtım {share} · {what}",
|
||||
"side.review": "İncelemede · {what}",
|
||||
"side.approved": "Onaylandı · {what}",
|
||||
"side.draft": "Taslak · {what}",
|
||||
"side.live": "Yayında · {version}",
|
||||
"side.liveWarn": "Yayında · {version} · {count} uyarı",
|
||||
"side.liveWarn_one": "Yayında · {version} · 1 uyarı",
|
||||
"side.storeOnly": "Yalnız mağaza · henüz okunmadı",
|
||||
"side.notRead": "Henüz okunmadı",
|
||||
"side.warnings": "{count} uyarı",
|
||||
"side.warnings_one": "1 uyarı",
|
||||
"side.notOnStore": "Henüz mağaza sürümü yok",
|
||||
"side.ignored": "Yok sayıldı",
|
||||
"side.studioFilter": "Bir stüdyonun uygulamalarını göster",
|
||||
"side.allStudios": "Tümü",
|
||||
"games.keep": "Uyumlu",
|
||||
"games.update": "Farklı",
|
||||
"games.create": "Eksik",
|
||||
"games.notOnStore": "Bu mağazada değil",
|
||||
"games.notListed": "Planlanmadı",
|
||||
"games.notRead": "Okunmadı",
|
||||
"games.item": "Öğe",
|
||||
"games.name": "Ad",
|
||||
"games.points": "Puan",
|
||||
"games.languages": "Diller",
|
||||
"games.leaderboard": "Skor tablosu",
|
||||
"games.achievement": "Başarım",
|
||||
"games.hidden": "kazanılana kadar gizli",
|
||||
"games.localeChanges": "Game Center dilleri: {list}",
|
||||
"games.checkAgain": "Yeniden kontrol et",
|
||||
"games.apiOffTitle": "Play Games Services API kapalı.",
|
||||
"games.apiOffText": "Hizmet hesabının Google Cloud projesinde {api} açın, bir dakika bekleyin, sonra yeniden kontrol edin.",
|
||||
"games.itemsTitle": "Skor tabloları ve başarımlar",
|
||||
"games.itemsSub": "Yapılandırmada tek liste, iki mağazaya eşlenir: her mağazanın yapılandırmaya göre durumu.",
|
||||
"games.refresh": "Yeniden oku",
|
||||
"games.noItems": "Yapılandırmada henüz skor tablosu veya başarım yok (games.leaderboards, games.achievements).",
|
||||
"games.notReadYet": "Oyun servisleri okunuyor…",
|
||||
"games.images": "Skor tablosu ve başarım görselleri konsollarda eklenir; Play'de mevcut simge her eşitlemede korunur.",
|
||||
"games.localeMap": "Game Center dil eşlemesi",
|
||||
"games.localeMapSub": "Yapılandırma dilinden canlı Game Center'ın kullandığı dile (games.localeMap).",
|
||||
"games.mapSet": "Ayarlı",
|
||||
"games.mapSuggested": "Önerilen",
|
||||
"games.mapHint": "games.localeMap'e ekleyin: {entries}",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// inbox stats, catalog parity matrix, the portfolio's "needs attention" list, and the command palette matcher.
|
||||
// "Unknown, never false": every derivation keeps "not read" apart from "none" / "ok".
|
||||
import { plainError } from '../../../../cli/lib/google-errors';
|
||||
import type { AppStatus, ProjectSummary } from '../api';
|
||||
import type { AppStatus, ProjectSummary, StoreRelease } from '../api';
|
||||
import { t } from '../i18n';
|
||||
|
||||
import type { Tone } from '../../../../cli/lib/train';
|
||||
|
|
@ -138,7 +138,7 @@ export function parityMatrix(s: ProjectSummary | null, diff: CatalogDiff | null)
|
|||
|
||||
// ---- Portfolio: needs attention ----
|
||||
|
||||
export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' }
|
||||
export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' | 'games' }
|
||||
|
||||
export function attention(projectId: string, name: string, e: AppStatus | undefined, live: AppStatus | undefined, health?: HealthSnapshot | null): Attention[] {
|
||||
const out: Attention[] = [];
|
||||
|
|
@ -203,3 +203,46 @@ export function hueOf(name: string): number {
|
|||
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
// ---- App kind and the sidebar ----
|
||||
|
||||
const PLAY_GAME_CATS = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing', 'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'];
|
||||
/** A game: profile game, a games section in the config, or a Play game category. */
|
||||
export function isGameSummary(s: ProjectSummary | null | undefined): boolean {
|
||||
if (!s) return false;
|
||||
return s.profile === 'game' || !!s.games || PLAY_GAME_CATS.includes(s.playRelease?.category ?? '');
|
||||
}
|
||||
|
||||
export type SideGroup = 'attention' | 'progress' | 'live' | 'draft';
|
||||
export interface SideStatus { group: SideGroup; text: string; tone: Tone }
|
||||
|
||||
/** One sidebar row's group and status line: the one thing worth knowing about the app (docs IA). */
|
||||
export function sideStatus(o: { att: Attention[]; all: StoreRelease[]; read: boolean; missing: boolean; storeOnly: boolean }): SideStatus {
|
||||
const sn = (r: StoreRelease) => `${r.store === 'ios' ? 'iOS' : 'Play'} ${r.version ?? ''}`.trim();
|
||||
if (o.missing) return { group: 'attention', text: t('side.missing'), tone: 'bad' };
|
||||
const rej = o.all.find(r => r.phase === 'rejected');
|
||||
if (rej) return { group: 'attention', text: t('side.rejected', { what: sn(rej) }), tone: 'bad' };
|
||||
const halted = o.all.find(r => r.phase === 'halted');
|
||||
if (halted) return { group: 'attention', text: t('side.halted', { what: sn(halted) }), tone: 'bad' };
|
||||
const bad = o.att.filter(a => a.tone === 'bad').length;
|
||||
if (bad) return { group: 'attention', text: t('side.blockers', { count: bad }), tone: 'bad' };
|
||||
const crash = o.att.find(a => a.tab === 'health');
|
||||
if (crash) return { group: 'attention', text: crash.text, tone: crash.tone };
|
||||
const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1);
|
||||
if (rolling) return { group: 'progress', text: t('side.rollout', { share: `${Math.round((rolling.fraction ?? 0) * 100)} %`, what: sn(rolling) }), tone: 'info' };
|
||||
const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent');
|
||||
if (review) return { group: 'progress', text: t('side.review', { what: sn(review) }), tone: 'info' };
|
||||
const approved = o.all.find(r => r.phase === 'approved');
|
||||
if (approved) return { group: 'progress', text: t('side.approved', { what: sn(approved) }), tone: 'info' };
|
||||
const live = o.all.filter(r => r.phase === 'live' || r.phase === 'released' || r.phase === 'rollout');
|
||||
const draft = o.all.find(r => r.phase === 'draft' || r.phase === 'testing');
|
||||
if (draft && !live.length) return { group: 'progress', text: t('side.draft', { what: sn(draft) }), tone: 'neutral' };
|
||||
const warn = o.att.filter(a => a.tone === 'warn').length;
|
||||
if (live.length) {
|
||||
const vs = [...new Set(live.map(r => r.version).filter(Boolean))];
|
||||
return { group: 'live', text: warn ? t('side.liveWarn', { version: vs.join(' / '), count: warn }) : t('side.live', { version: vs.join(' / ') }), tone: warn ? 'warn' : 'ok' };
|
||||
}
|
||||
if (!o.read) return { group: o.storeOnly ? 'live' : 'draft', text: o.storeOnly ? t('side.storeOnly') : t('side.notRead'), tone: 'neutral' };
|
||||
return { group: 'draft', text: warn ? t('side.warnings', { count: warn }) : t('side.notOnStore'), tone: warn ? 'warn' : 'neutral' };
|
||||
}
|
||||
export const SIDE_GROUPS: SideGroup[] = ['attention', 'progress', 'live', 'draft'];
|
||||
|
|
|
|||
94
apps/desktop/src/lib/sections.ts
Normal file
94
apps/desktop/src/lib/sections.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// The "what's next" line of each app section (docs IA): one sentence and the sub view it opens, from state that is
|
||||
// already read (status, live releases, attention, cached reads). Pure, so tests pin the rules; a section never starts
|
||||
// a read to fill it.
|
||||
import type { CatalogParity, ListingsResult, StoreRelease } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import type { AppSection, AppTab } from '../nav';
|
||||
import type { IconName } from '../ui/icons';
|
||||
import { type Attention, isGameSummary } from './model';
|
||||
import { share } from './format';
|
||||
import type { ProjectSummary } from '../api';
|
||||
|
||||
export type NextTone = 'bad' | 'warn' | 'ok' | 'info' | 'neutral';
|
||||
export interface SectionNext { text: string; tone: NextTone; tab: AppTab | null; icon: IconName }
|
||||
|
||||
export interface GamesNext { changes: number; blockers: number; apiOff: boolean }
|
||||
export interface SectionInput {
|
||||
project: boolean;
|
||||
att: Attention[];
|
||||
all: StoreRelease[];
|
||||
next: string | null;
|
||||
summary: ProjectSummary | null;
|
||||
catalog: CatalogParity | null;
|
||||
/** Unsaved edits of the section's editors. */
|
||||
unsaved: number;
|
||||
/** Cached reads (null: not read yet). */
|
||||
listings: ListingsResult | null;
|
||||
reviews: { reply?: unknown }[] | null;
|
||||
games: GamesNext | null;
|
||||
/** Blockers of the last store readiness read (validate), null when never read. */
|
||||
readiness?: number | null;
|
||||
}
|
||||
|
||||
const storeName = (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Google Play');
|
||||
|
||||
/** Problems of a listings read: file problems plus required fields missing or over the limit. */
|
||||
export function listingProblems(l: ListingsResult | null): number | null {
|
||||
if (!l) return null;
|
||||
let n = l.problems.length;
|
||||
for (const loc of l.locales) for (const s of [loc.play, loc.apple]) for (const f of s?.fields ?? []) if (f.state === 'missing' || f.state === 'over') n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function sectionNext(section: AppSection, i: SectionInput): SectionNext {
|
||||
const bad = i.att.filter(a => a.tone === 'bad');
|
||||
if (section === 'release') {
|
||||
const rej = i.all.find(r => r.phase === 'rejected' || r.phase === 'halted');
|
||||
if (rej) return { text: t(rej.phase === 'rejected' ? 'next.rejected' : 'next.halted', { version: rej.version ?? '', store: storeName(rej.store) }), tone: 'bad', tab: 'releases', icon: 'alertCircle' };
|
||||
const checks = bad.filter(a => a.tab === 'checks').length + (i.readiness ?? 0);
|
||||
if (checks) return { text: t('next.blockers', { count: checks }), tone: 'bad', tab: 'checks', icon: 'alertCircle' };
|
||||
const rolling = i.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1);
|
||||
if (rolling) return { text: t('next.rollout', { share: share(rolling.fraction ?? 0), version: rolling.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' };
|
||||
const approved = i.all.find(r => r.phase === 'approved');
|
||||
if (approved) return { text: t('next.approved', { version: approved.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' };
|
||||
const ready = i.all.find(r => r.phase === 'draft' || r.phase === 'testing');
|
||||
if (ready) return { text: t('next.submit', { version: ready.version ?? '' }), tone: 'info', tab: 'releases', icon: 'send' };
|
||||
const review = i.all.find(r => r.phase === 'review' || r.phase === 'sent');
|
||||
if (review) return { text: review.managed ? t('next.managed', { version: review.version ?? '' }) : t('next.review', { version: review.version ?? '' }), tone: 'neutral', tab: 'releases', icon: 'clock' };
|
||||
if (i.project && !i.next && !i.all.length) return { text: t('next.upload'), tone: 'neutral', tab: 'releases', icon: 'upload' };
|
||||
const live = i.all.find(r => r.phase === 'live' || r.phase === 'released');
|
||||
return live ? { text: t('next.live', { version: live.version ?? '' }), tone: 'ok', tab: null, icon: 'check' } : { text: t('next.notRead'), tone: 'neutral', tab: null, icon: 'clock' };
|
||||
}
|
||||
if (section === 'store') {
|
||||
if (!i.project) return { text: t('next.storeLive'), tone: 'neutral', tab: null, icon: 'cloud' };
|
||||
const n = listingProblems(i.listings);
|
||||
if (n) return { text: t('next.listingProblems', { count: n }), tone: 'warn', tab: 'listings', icon: 'alert' };
|
||||
if (i.unsaved) return { text: t('next.unsaved', { count: i.unsaved }), tone: 'warn', tab: null, icon: 'edit' };
|
||||
return n === null ? { text: t('next.listingsUnread'), tone: 'neutral', tab: 'listings', icon: 'text' } : { text: t('next.storeOk'), tone: 'ok', tab: null, icon: 'check' };
|
||||
}
|
||||
if (section === 'revenue') {
|
||||
const c = i.catalog;
|
||||
if (c && c.checked === 'live' && !c.ok) return { text: t('next.catalogDiff', { count: c.missingIos.length + c.missingAndroid.length + c.onlyIos.length + c.onlyAndroid.length }), tone: 'warn', tab: 'catalog', icon: 'alert' };
|
||||
const p = i.summary?.products ?? 0, s = i.summary?.subscriptions ?? 0;
|
||||
if (!p && !s) return { text: t('next.noProducts'), tone: 'neutral', tab: 'catalog', icon: 'plus' };
|
||||
return { text: t('next.products', { products: p, subs: s }), tone: 'ok', tab: null, icon: 'check' };
|
||||
}
|
||||
if (section === 'players') {
|
||||
const open = i.reviews ? i.reviews.filter(r => !r.reply).length : null;
|
||||
if (open) return { text: t('next.reviews', { count: open }), tone: 'warn', tab: 'reviews', icon: 'message' };
|
||||
if (i.games?.apiOff) return { text: t('next.gamesApiOff'), tone: 'bad', tab: 'games', icon: 'alertCircle' };
|
||||
if (i.games?.blockers) return { text: t('next.gamesBlockers', { count: i.games.blockers }), tone: 'bad', tab: 'games', icon: 'alertCircle' };
|
||||
if (i.games?.changes) return { text: t('next.gamesSync', { count: i.games.changes }), tone: 'warn', tab: 'games', icon: 'upload' };
|
||||
return open === null ? { text: t('next.reviewsUnread'), tone: 'neutral', tab: 'reviews', icon: 'message' } : { text: t('next.reviewsOk'), tone: 'ok', tab: null, icon: 'check' };
|
||||
}
|
||||
const crash = i.att.find(a => a.tab === 'health');
|
||||
if (crash) return { text: crash.text, tone: crash.tone === 'bad' ? 'bad' : 'warn', tab: 'health', icon: 'activity' };
|
||||
const problems = i.att.filter(a => a.tab === 'checks');
|
||||
const fix = problems.filter(a => a.tone === 'bad').length + (i.readiness ?? 0);
|
||||
if (fix) return { text: t('next.fixFirst', { count: fix }), tone: 'bad', tab: 'checks', icon: 'shield' };
|
||||
if (problems.length) return { text: t('next.warnings', { count: problems.length }), tone: 'warn', tab: 'checks', icon: 'shield' };
|
||||
return { text: t('next.healthOk'), tone: 'ok', tab: null, icon: 'check' };
|
||||
}
|
||||
|
||||
/** A game: profile game, a games section in the config, or a Play game category. */
|
||||
export const isGame = (s: ProjectSummary | null | undefined): boolean => isGameSummary(s);
|
||||
|
|
@ -4,31 +4,58 @@ import { createContext, useContext } from 'react';
|
|||
import type { Key } from './i18n';
|
||||
import type { IconName } from './ui/icons';
|
||||
|
||||
export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks', 'tools'] as const;
|
||||
export const APP_TABS = ['releases', 'testflight', 'listings', 'art', 'appinfo', 'catalog', 'prices', 'reviews', 'games', 'health', 'growth', 'checks', 'tools'] as const;
|
||||
export type AppTab = (typeof APP_TABS)[number];
|
||||
/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and
|
||||
* store art are the live ones; Growth shows the Product Page Optimization tests only). */
|
||||
export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth'];
|
||||
export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS);
|
||||
|
||||
/** The five sections of an app, by job (docs IA): each holds sub views (the old tabs). The route keeps the sub id. */
|
||||
export const SECTIONS = ['release', 'store', 'revenue', 'players', 'health'] as const;
|
||||
export type AppSection = (typeof SECTIONS)[number];
|
||||
export const SECTION_TABS: Record<AppSection, readonly AppTab[]> = {
|
||||
release: ['releases', 'testflight'], store: ['listings', 'art', 'appinfo'], revenue: ['catalog', 'prices'], players: ['reviews', 'games'], health: ['health', 'growth', 'checks', 'tools'],
|
||||
};
|
||||
export const sectionOf = (tab: AppTab): AppSection => SECTIONS.find(s => SECTION_TABS[s].includes(tab)) ?? 'release';
|
||||
export const SECTION_KEY: Record<AppSection, Key> = { release: 'sec.release', store: 'sec.store', revenue: 'sec.revenue', players: 'sec.players', health: 'sec.health' };
|
||||
export const SECTION_ICON: Record<AppSection, IconName> = { release: 'rocket', store: 'image', revenue: 'coins', players: 'users', health: 'activity' };
|
||||
|
||||
/** Sub views of a store-only app: store reads only (catalog, prices, app info, checks and tools need the app folder;
|
||||
* listings and store art are the live ones; Growth shows the Product Page Optimization tests only). */
|
||||
export const STORE_TABS: readonly AppTab[] = ['releases', 'testflight', 'listings', 'art', 'reviews', 'health', 'growth'];
|
||||
/** The sub views an app has: the folder decides most; Game services only for games. */
|
||||
export const tabsFor = (hasFolder: boolean, game = false): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS).filter(x => x !== 'games' || (game && hasFolder));
|
||||
/** The sections an app has (a section with no sub view is left out: Revenue for a store-only app). */
|
||||
export const sectionsFor = (hasFolder: boolean, game = false): AppSection[] => { const tabs = tabsFor(hasFolder, game); return SECTIONS.filter(s => SECTION_TABS[s].some(x => tabs.includes(x))); };
|
||||
export const subsOf = (s: AppSection, hasFolder: boolean, game = false): AppTab[] => { const tabs = tabsFor(hasFolder, game); return SECTION_TABS[s].filter(x => tabs.includes(x)); };
|
||||
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',
|
||||
testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools',
|
||||
testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', games: 'tab.games',
|
||||
};
|
||||
export const TAB_ICON: Record<AppTab, IconName> = {
|
||||
releases: 'rocket', catalog: 'tag', prices: 'coins', listings: 'text', art: 'grid', health: 'activity', reviews: 'message', testflight: 'plane', growth: 'chart',
|
||||
appinfo: 'clipboard', checks: 'shield', tools: 'sliders',
|
||||
appinfo: 'clipboard', checks: 'shield', tools: 'sliders', games: 'sparkle',
|
||||
};
|
||||
|
||||
/** A place in an app from a link: an old tab id ("prices"), a section ("revenue", its first sub view) or
|
||||
* "section/sub" ("store/art"). Unknown: Release. */
|
||||
export function placeOf(v: string | undefined | null): AppTab {
|
||||
if (!v) return 'releases';
|
||||
const [a, b] = v.split('/');
|
||||
if (b && (SECTIONS as readonly string[]).includes(a!) && SECTION_TABS[a as AppSection].includes(b as AppTab)) return b as AppTab;
|
||||
if (APP_TABS.includes(a as AppTab)) return a as AppTab;
|
||||
if ((SECTIONS as readonly string[]).includes(a!)) return SECTION_TABS[a as AppSection][0]!;
|
||||
return 'releases';
|
||||
}
|
||||
|
||||
export const WORKSPACE = ['apps', 'accounts', 'ignored', 'dependencies', 'settings'] as const;
|
||||
export type WorkspaceScreen = (typeof WORKSPACE)[number];
|
||||
|
||||
export type Route = { screen: 'portfolio' } | { screen: 'app'; appId: string; tab: AppTab } | { screen: WorkspaceScreen; section?: string };
|
||||
|
||||
/** storebound://<screen> deep links and the dev-only start switch ("app:health" = first app, Health tab;
|
||||
* "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */
|
||||
/** storebound://<screen> deep links and the dev-only start switch ("app:health" = first app, Health section;
|
||||
* "app:store/art" = a sub view; "app:prices" = an old tab id; "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */
|
||||
export function parseStart(v: string, apps: string[]): Route | null {
|
||||
const [screen, tab, n] = v.split(':');
|
||||
const app = apps[Math.max(0, (Number(n) || 1) - 1)];
|
||||
if (screen === 'app' && app) return { screen: 'app', appId: app, tab: APP_TABS.includes(tab as AppTab) ? tab as AppTab : 'releases' };
|
||||
if (screen === 'app' && app) return { screen: 'app', appId: app, tab: placeOf(tab) };
|
||||
if (screen === 'portfolio') return { screen: 'portfolio' };
|
||||
if (!(WORKSPACE as readonly string[]).includes(screen ?? '')) return null;
|
||||
return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen };
|
||||
|
|
@ -41,7 +68,7 @@ export function parseRouteJson(v: string): Route | null {
|
|||
if (!o || typeof o !== 'object') return null;
|
||||
const r = o as Record<string, unknown>;
|
||||
if (r.screen === 'portfolio') return { screen: 'portfolio' };
|
||||
if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: APP_TABS.includes(r.tab as AppTab) ? r.tab as AppTab : 'releases' } : null;
|
||||
if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: placeOf(typeof r.tab === 'string' ? r.tab : null) } : null;
|
||||
if (typeof r.screen === 'string' && (WORKSPACE as readonly string[]).includes(r.screen)) return typeof r.section === 'string' ? { screen: r.screen as WorkspaceScreen, section: r.section } : { screen: r.screen as WorkspaceScreen };
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no
|
||||
// One app across both stores: header (icon, studio, ids, store state, pairing), then five sections by job (Release,
|
||||
// Store page, Revenue, Players, Health; docs IA), each with a header (what it is for, the one next step) and its sub views. A store-only app (no
|
||||
// app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and
|
||||
// "Start an app folder here" to make it a full app.
|
||||
import { useContextMenu, useMenuButton } from '../ui/ContextMenu';
|
||||
import { appMenu, playUrlFor } from '../ui/app-menu';
|
||||
import { useShowIgnored } from '../lib/show-ignored';
|
||||
import { useState } from 'react';
|
||||
import { GamesTab } from './tabs/Games';
|
||||
import { SectionHead } from './SectionHead';
|
||||
import { api, errorText } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { pairCandidates, type UnifiedApp, withLink, withPair, withStudio } from '../lib/identity';
|
||||
import { attention, storeNow } from '../lib/model';
|
||||
import { Hero, nextStep, Tabs } from '../ui/ds';
|
||||
import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav';
|
||||
import { isGameSummary } from '../lib/model';
|
||||
import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, SECTION_TABS, sectionOf, sectionsFor, subsOf, useNav } from '../nav';
|
||||
import { useData } from '../store';
|
||||
import { Icon } from '../ui/icons';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
|
|
@ -107,11 +111,15 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
const ios = e?.app?.platforms.ios ?? (app.ios ? { bundleId: app.ios.app.bundleId, ascAppId: app.ios.app.id } : null);
|
||||
const android = e?.app?.platforms.android ?? (app.android ? { packageName: app.android.app.id } : null);
|
||||
const name = (app.project && e?.app?.name) || app.name;
|
||||
const tabs = tabsFor(!!app.project);
|
||||
const game = isGameSummary(e?.summary);
|
||||
const sections = sectionsFor(!!app.project, game);
|
||||
const section = sectionOf(tab);
|
||||
const ed = useEdits();
|
||||
useUnsavedGuard();
|
||||
const studio = d.studios.find(s => s.id === app.studio) ?? null;
|
||||
const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x });
|
||||
const setSection = (x: AppSection) => setTab(subsOf(x, !!app.project, game)[0] ?? SECTION_TABS[x][0]!);
|
||||
const editCount = (x: AppTab) => (EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0);
|
||||
const all = l?.release.all ?? [];
|
||||
const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null;
|
||||
const att = attention(app.key, app.name, e, l);
|
||||
|
|
@ -137,19 +145,20 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
{android && <button className="btn btn-sm btn-ghost" onClick={() => open(playUrlFor(d, app))} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
|
||||
</>}
|
||||
all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null}
|
||||
compact={tab !== 'releases'}
|
||||
compact={section !== 'release' || tab !== 'releases'}
|
||||
cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} />
|
||||
<AppControls app={app} />
|
||||
{!app.project && <StoreOnlyBanner app={app} />}
|
||||
{app.project?.storeboundPin && <Notice tone="info" icon="info">{t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}</Notice>}
|
||||
{app.project?.missing ? <FolderMissing project={app.project} /> : <>
|
||||
<div className="ds-app-sticky">
|
||||
<Tabs label={t('app.sections', { name })} value={tab} onChange={setTab} items={tabs.map(x => {
|
||||
const n = EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0;
|
||||
return { id: x, label: t(TAB_KEY[x]), icon: TAB_ICON[x], count: att.filter(a => a.tab === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined };
|
||||
<Tabs label={t('app.sections', { name })} value={section} onChange={setSection} items={sections.map((x, i) => {
|
||||
const n = subsOf(x, !!app.project, game).reduce((a, s2) => a + editCount(s2), 0);
|
||||
return { id: x, label: t(SECTION_KEY[x]), icon: SECTION_ICON[x], hint: String(i + 1), count: att.filter(a => a.tab && sectionOf(a.tab) === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined };
|
||||
})} />
|
||||
</div>
|
||||
<div role="tabpanel" id="tabpanel" aria-labelledby={`tab-${tab}`} className="stack" style={{ gap: 16 }}>
|
||||
<SectionHead app={app} section={section} tab={tab} subs={subsOf(section, !!app.project, game)} att={att} all={all} next={l?.release.next?.version ?? null} unsaved={subsOf(section, !!app.project, game).reduce((a, s2) => a + editCount(s2), 0)} editCount={editCount} onTab={setTab} />
|
||||
<div role="tabpanel" id="tabpanel" aria-labelledby={`tab-${section}`} className="stack" style={{ gap: 16 }}>
|
||||
<ErrorBoundary key={tab} where={`tab ${tab}`}>
|
||||
{tab === 'releases' && <ReleasesTab app={app} />}
|
||||
{tab === 'catalog' && app.project && <CatalogTab project={app.project} />}
|
||||
|
|
@ -160,6 +169,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
{tab === 'art' && !app.project && <LiveArt target={app.target} />}
|
||||
{tab === 'health' && <HealthTab app={app} />}
|
||||
{tab === 'reviews' && <ReviewsTab app={app} />}
|
||||
{tab === 'games' && app.project && <GamesTab project={app.project} />}
|
||||
{tab === 'testflight' && <TestFlightTab app={app} />}
|
||||
{tab === 'growth' && <GrowthTab app={app} />}
|
||||
{tab === 'appinfo' && app.project && <AppInfoTab project={app.project} />}
|
||||
|
|
|
|||
60
apps/desktop/src/screens/SectionHead.tsx
Normal file
60
apps/desktop/src/screens/SectionHead.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// The header of one app section (docs IA): the section's name, one line on what it is for, the single next step
|
||||
// derived from state that is already read (lib/sections.ts), and the sub views as a segmented control (only when the
|
||||
// section has more than one).
|
||||
import type { ListingsResult, StoreRelease } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import type { UnifiedApp } from '../lib/identity';
|
||||
import type { Attention } from '../lib/model';
|
||||
import { sectionNext } from '../lib/sections';
|
||||
import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, TAB_ICON, TAB_KEY } from '../nav';
|
||||
import { useCachedRead, useData } from '../store';
|
||||
import type { GameServicesStatus } from '../ui/GameServicesCard';
|
||||
import { Icon } from '../ui/icons';
|
||||
|
||||
const PURPOSE: Record<AppSection, 'sec.release.for' | 'sec.store.for' | 'sec.revenue.for' | 'sec.players.for' | 'sec.health.for'> = {
|
||||
release: 'sec.release.for', store: 'sec.store.for', revenue: 'sec.revenue.for', players: 'sec.players.for', health: 'sec.health.for',
|
||||
};
|
||||
|
||||
export function SectionHead({ app, section, tab, subs, att, all, next, unsaved, editCount, onTab }: {
|
||||
app: UnifiedApp; section: AppSection; tab: AppTab; subs: AppTab[]; att: Attention[]; all: StoreRelease[]; next: string | null; unsaved: number;
|
||||
editCount: (t: AppTab) => number; onTab: (t: AppTab) => void;
|
||||
}) {
|
||||
const d = useData();
|
||||
const e = d.statusFor(app);
|
||||
const pid = app.project && !app.project.missing ? app.project.id : null;
|
||||
// cached reads only: the header never starts a store call
|
||||
const listings = useCachedRead<ListingsResult>(section === 'store' ? pid : null, 'project.listings');
|
||||
const reviews = useCachedRead<{ reply?: unknown }[]>(section === 'players' ? app.target : null, 'reviews.list', { days: 30 });
|
||||
const games = useCachedRead<GameServicesStatus>(section === 'players' && subs.includes('games') ? pid : null, 'games.status');
|
||||
const validate = useCachedRead<{ findings?: { level: string }[] }>(section === 'health' || section === 'release' ? pid : null, 'validate');
|
||||
const n = sectionNext(section, {
|
||||
project: !!app.project, att, all, next, summary: e?.summary ?? null, catalog: (d.liveFor(app) ?? e)?.catalog ?? null, unsaved, listings,
|
||||
reviews: Array.isArray(reviews) ? reviews : null, readiness: validate?.findings ? validate.findings.filter(f => f.level === 'blocker').length : null,
|
||||
games: games ? { changes: (games.apple?.changes.length ?? 0) + (games.play?.changes.length ?? 0), blockers: games.findings.filter(f => f.level === 'error').length, apiOff: !!games.playApiDisabled } : null,
|
||||
});
|
||||
return (
|
||||
<div className="sec-head" data-testid="section-head">
|
||||
<div className="sec-head-t">
|
||||
<h2 className="sec-head-name"><Icon name={SECTION_ICON[section]} />{t(SECTION_KEY[section])}</h2>
|
||||
<p className="sec-head-for">{t(PURPOSE[section])}</p>
|
||||
</div>
|
||||
<div className={`sec-next tone-${n.tone}`} role="status" data-testid="section-next">
|
||||
<Icon name={n.icon} />
|
||||
<span className="sec-next-t">{n.text}</span>
|
||||
{n.tab && n.tab !== tab && <button className="btn btn-sm" onClick={() => onTab(n.tab!)}>{t('next.open', { where: t(TAB_KEY[n.tab]) })}<Icon name="arrowRight" /></button>}
|
||||
</div>
|
||||
{subs.length > 1 && (
|
||||
<div className="seg sec-subs" role="group" aria-label={t('sec.views', { section: t(SECTION_KEY[section]) })}>
|
||||
{subs.map(x => {
|
||||
const c = editCount(x);
|
||||
return (
|
||||
<button key={x} id={`sub-${x}`} type="button" aria-pressed={tab === x} aria-controls="tabpanel" onClick={() => onTab(x)}>
|
||||
<Icon name={TAB_ICON[x]} />{t(TAB_KEY[x])}{c > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: c })} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ import { Bar, Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit';
|
|||
import { EmptyState, Section } from '../../ui/ds';
|
||||
import '../../styles/appinfo.css';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { GameServicesCard } from '../../ui/GameServicesCard';
|
||||
import { ascUrl } from '../AppView';
|
||||
|
||||
const AREA: Record<DeclAsk['area'], Key> = { dataSafety: 'ai.aDataSafety', appPrivacy: 'ai.aAppPrivacy', appInfo: 'ai.aAppInfo', encryption: 'ai.aEncryption', review: 'ai.aReview' };
|
||||
|
|
@ -258,7 +257,6 @@ export function AppInfoTab({ project }: { project: Project }) {
|
|||
</Section>
|
||||
</div>
|
||||
)}
|
||||
<GameServicesCard project={project} />
|
||||
<ConsoleSteps project={project} stores={d.stores} />
|
||||
|
||||
<p className="ai-foot">{t('ai.foot', { when: ago(r.at) })}</p>
|
||||
|
|
|
|||
90
apps/desktop/src/screens/tabs/Games.tsx
Normal file
90
apps/desktop/src/screens/tabs/Games.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Players > Game services: Game Center (App Store) and Play Games Services (Google Play) for a game, from
|
||||
// `games status --json` (cli/games-status.ts). The two store panels (switches, ids, counts, sync writes with dry run +
|
||||
// confirm) are the GameServicesCard; below it one row per leaderboard / achievement with its languages and each
|
||||
// store's state against the config, the Game Center locale map with suggestions, and the Play API-disabled state with
|
||||
// the enable link and the console plan.
|
||||
import { Fragment } from 'react';
|
||||
import type { Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { useRead } from '../../store';
|
||||
import { ConsolePlanButton } from '../../ui/ConsolePlan';
|
||||
import { EmptyState, Section } from '../../ui/ds';
|
||||
import { type GameAction, type GameItemRow, GameServicesCard, type GameServicesStatus } from '../../ui/GameServicesCard';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Chip, Notice } from '../../ui/kit';
|
||||
import { OpenLink } from '../../ui/OpenLink';
|
||||
|
||||
const ACTION_TONE: Record<GameAction, 'ok' | 'warn' | 'info'> = { keep: 'ok', update: 'warn', create: 'info' };
|
||||
const ACTION_KEY = { keep: 'games.keep', update: 'games.update', create: 'games.create' } as const;
|
||||
|
||||
function StoreState({ on, plan, read }: { on: boolean; plan: { action: GameAction; changed: string[] } | null; read: boolean }) {
|
||||
if (!on) return <span className="muted small">{t('games.notOnStore')}</span>;
|
||||
if (!plan) return <span className="muted small">{read ? t('games.notListed') : t('games.notRead')}</span>;
|
||||
return <Chip tone={ACTION_TONE[plan.action]} title={plan.changed.length && plan.action !== 'keep' ? plan.changed.join(', ') : undefined}>{t(ACTION_KEY[plan.action])}</Chip>;
|
||||
}
|
||||
|
||||
function ItemsTable({ rows, appleRead, playRead }: { rows: GameItemRow[]; appleRead: boolean; playRead: boolean }) {
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr>
|
||||
<th>{t('games.item')}</th><th>{t('games.name')}</th><th className="num">{t('games.points')}</th><th>{t('games.languages')}</th>
|
||||
<th><span className="row" style={{ gap: 4 }}><StoreGlyph store="ios" className="ic13" />{t('gs.gameCenter')}</span></th>
|
||||
<th><span className="row" style={{ gap: 4 }}><StoreGlyph store="android" className="ic13" />{t('gs.playGames')}</span></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => {
|
||||
const locs = r.apple?.locales.filter(l => l.action !== 'keep') ?? [];
|
||||
return (
|
||||
<tr key={`${r.kind}:${r.id}`}>
|
||||
<td><span className="row" style={{ gap: 6 }}><Icon name={r.kind === 'leaderboard' ? 'chart' : 'sparkle'} className="ic13" /><span className="mono-id small">{r.id}</span></span>
|
||||
<div className="small muted">{t(r.kind === 'leaderboard' ? 'games.leaderboard' : 'games.achievement')}{r.hidden ? ` · ${t('games.hidden')}` : ''}</div></td>
|
||||
<td>{r.name}</td>
|
||||
<td className="num">{r.points ?? '–'}</td>
|
||||
<td className="small">{r.locales.join(', ')}{locs.length > 0 && <div className="small muted">{t('games.localeChanges', { list: locs.map(l => `${l.locale} ${t(ACTION_KEY[l.action])}`).join(', ') })}</div>}</td>
|
||||
<td><StoreState on={r.ios} plan={r.apple} read={appleRead} /></td>
|
||||
<td><StoreState on={r.android} plan={r.play} read={playRead} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GamesTab({ project }: { project: Project }) {
|
||||
const r = useRead<GameServicesStatus>(project.id, 'games.status', { network: true, title: t('gs.title') });
|
||||
const s = r.data;
|
||||
const rows = s?.items ?? [];
|
||||
const appleRead = !!s?.apple && !s.apple.error, playRead = !!s?.play && !s.play.error;
|
||||
const map = s?.localeMap;
|
||||
return (
|
||||
<>
|
||||
{s?.playApiDisabled && (
|
||||
<Notice tone="bad" icon="alertCircle" action={<div className="actions">
|
||||
<button className="btn btn-sm btn-primary" onClick={() => r.refresh()} disabled={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{t('games.checkAgain')}</button>
|
||||
<ConsolePlanButton project={project} form="play-games-services" label={t('gs.setup')} />
|
||||
</div>}>
|
||||
<b>{t('games.apiOffTitle')}</b> {t('games.apiOffText', { api: s.playApiDisabled.title })} <OpenLink url={s.playApiDisabled.url} />
|
||||
</Notice>
|
||||
)}
|
||||
<GameServicesCard project={project} read={r} />
|
||||
<Section title={t('games.itemsTitle')} icon="chart" sub={t('games.itemsSub')}
|
||||
actions={<button className="btn btn-sm" onClick={() => r.refresh()} disabled={r.loading} aria-busy={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{t('games.refresh')}</button>}>
|
||||
{rows.length ? <ItemsTable rows={rows} appleRead={appleRead} playRead={playRead} />
|
||||
: <EmptyState icon="sparkle">{s ? t('games.noItems') : t('games.notReadYet')}</EmptyState>}
|
||||
<p className="small muted" style={{ marginTop: 10 }}>{t('games.images')}</p>
|
||||
</Section>
|
||||
{map && (Object.keys(map.configured).length > 0 || map.suggested.length > 0) && (
|
||||
<Section title={t('games.localeMap')} icon="globe" sub={t('games.localeMapSub')}>
|
||||
<dl className="kv">
|
||||
{Object.entries(map.configured).map(([k, v]) => <Fragment key={`k${k}`}><dt>{k}</dt><dd><span className="mono-id small">{v}</span> <Chip tone="ok">{t('games.mapSet')}</Chip></dd></Fragment>)}
|
||||
{map.suggested.map(x => <Fragment key={`s${x.id}`}><dt>{x.id}</dt><dd><span className="mono-id small">{x.from} → {x.to}</span> <Chip tone="warn">{t('games.mapSuggested')}</Chip></dd></Fragment>)}
|
||||
</dl>
|
||||
{map.suggested.length > 0 && <p className="small muted" style={{ marginTop: 8 }}>{t('games.mapHint', { entries: map.suggested.map(x => `${x.id}: '${x.to}'`).join(', ') })}</p>}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -750,3 +750,21 @@ export function useRead<T>(target: string, method: string, o: { network: boolean
|
|||
|
||||
return { ...s, loading: !o.skip && (s.loading || st.busy), justNow: st.justNow, pausedUntil: st.pausedUntil, refresh };
|
||||
}
|
||||
|
||||
/** The cached value of one read (and every later result of it), without ever starting a read: for summaries such as a
|
||||
* section's "what's next" line that must not cost a store call. Null until the read ran once. */
|
||||
export function useCachedRead<T>(target: string | null, method: string, args: Record<string, unknown> = {}): T | null {
|
||||
const d = useData();
|
||||
const argsKey = JSON.stringify(args);
|
||||
const [v, set] = useState<T | null>(null);
|
||||
useEffect(() => {
|
||||
set(null);
|
||||
if (!target) return;
|
||||
let alive = true;
|
||||
const a = JSON.parse(argsKey) as Record<string, unknown>;
|
||||
api.cached<ToolResult>(method, target, a).then(c => { if (alive && c && c.value.data !== undefined && usable(method, c.value)) set(c.value.data as T); }, () => {});
|
||||
const un = d.onRead(readKey(target, method, a), e => { if (e.kind === 'done' && usable(method, e.result)) set(e.result.data as T); });
|
||||
return () => { alive = false; un(); };
|
||||
}, [target, method, argsKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return v;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// (dry run -> native dialog -> apply) of `apple game-center` and `play games`; the PGS setup is a console plan.
|
||||
import type { Project } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { useRead, useWrite } from '../store';
|
||||
import { type ReadState, useRead, useWrite } from '../store';
|
||||
import { ConsolePlanButton } from './ConsolePlan';
|
||||
import { StoreGlyph } from './icons';
|
||||
import { Card, Chip, ErrorNotice, Skeleton } from './kit';
|
||||
|
|
@ -18,13 +18,23 @@ export interface GameServicesStatus {
|
|||
play: null | { appId: string | null; resource: string | null; config: Counts; changes: string[]; error?: string } & Counts;
|
||||
findings: Finding[];
|
||||
links: { label: string; url: string }[];
|
||||
items?: GameItemRow[];
|
||||
localeMap?: { configured: Record<string, string>; suggested: { id: string; from: string; to: string }[] };
|
||||
playApiDisabled?: { service: string; title: string; url: string } | null;
|
||||
}
|
||||
export type GameAction = 'create' | 'update' | 'keep';
|
||||
export interface GameItemRow {
|
||||
kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean; ios: boolean; android: boolean;
|
||||
apple: { action: GameAction; changed: string[]; locales: { locale: string; action: GameAction }[] } | null;
|
||||
play: { action: GameAction; changed: string[] } | null;
|
||||
}
|
||||
|
||||
const tone = (l: Finding['level']) => (l === 'error' ? 'bad' : l === 'warn' ? 'warn' : 'ok');
|
||||
const yesNo = (v: boolean | null | undefined) => (v === null || v === undefined ? <Chip tone="neutral">{t('gs.unknown')}</Chip> : <Chip tone={v ? 'ok' : 'bad'}>{v ? t('gs.yes') : t('gs.no')}</Chip>);
|
||||
|
||||
export function GameServicesCard({ project }: { project: Project }) {
|
||||
const r = useRead<GameServicesStatus>(project.id, 'games.status', { network: true, title: t('gs.title') });
|
||||
export function GameServicesCard({ project, read }: { project: Project; read?: ReadState<GameServicesStatus> }) {
|
||||
const own = useRead<GameServicesStatus>(project.id, 'games.status', { network: true, title: t('gs.title'), skip: !!read });
|
||||
const r = read ?? own;
|
||||
const w = useWrite(project.id);
|
||||
if (!r.data) return r.error ? <ErrorNotice error={r.error} tone="warn" /> : <Card title={t('gs.title')} icon="sparkle"><Skeleton lines={3} block /></Card>;
|
||||
const s = r.data;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { t } from '../i18n';
|
|||
import { copyText } from '../lib/crash';
|
||||
import { withIgnored, withSideIgnored, withUnpair, type UnifiedApp } from '../lib/identity';
|
||||
import { setShowIgnored } from '../lib/show-ignored';
|
||||
import { type AppTab, type Nav, TAB_ICON, TAB_KEY, tabsFor } from '../nav';
|
||||
import { type AppTab, type Nav, SECTION_ICON, SECTION_KEY, sectionsFor, subsOf, TAB_ICON, TAB_KEY } from '../nav';
|
||||
import { isGameSummary } from '../lib/model';
|
||||
import type { Data } from '../store';
|
||||
import type { MenuInput, MenuItem } from './menu-logic';
|
||||
import { pickConsoleId, playAppUrl } from '../../../../cli/lib/console-links';
|
||||
|
|
@ -36,7 +37,12 @@ export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored:
|
|||
const storeName = (k: 'ios' | 'android') => (k === 'ios' ? 'App Store' : 'Google Play');
|
||||
return [
|
||||
{ label: t('cm.open'), icon: 'arrowRight', onSelect: () => go('releases') },
|
||||
{ label: t('cm.openTab'), icon: 'grid', submenu: tabsFor(!!p).map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) },
|
||||
{ label: t('cm.openTab'), icon: 'grid', submenu: sectionsFor(!!p, isGameSummary(d.statusFor(app)?.summary)).map((sec, i) => {
|
||||
const subs = subsOf(sec, !!p, isGameSummary(d.statusFor(app)?.summary));
|
||||
return subs.length > 1
|
||||
? { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), submenu: subs.map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) }
|
||||
: { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), onSelect: () => go(subs[0]!) };
|
||||
}) },
|
||||
'sep',
|
||||
(ios || android) && { label: t('cm.openConsole'), icon: 'external', submenu: [
|
||||
ios && { label: 'App Store Connect', icon: 'external', onSelect: () => openUrl(d, ASC_APP(ios.id)) },
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ export function Toolbar({ label, children }: { label: string; children: ReactNod
|
|||
return <div className="ds-toolbar" role="toolbar" aria-label={label}>{children}</div>;
|
||||
}
|
||||
|
||||
export interface TabItem<T extends string> { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string }
|
||||
export interface TabItem<T extends string> { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string; /** A keyboard hint shown in the tab ("1"). */ hint?: string }
|
||||
|
||||
/** Pill tabs with icons and counts; arrows / Home / End move between them (roving tabindex). One row always: tabs that
|
||||
* do not fit go into a "More" menu at the end, the active tab always stays in the row. */
|
||||
|
|
@ -323,11 +323,12 @@ export function Tabs<T extends string>({ items, value, onChange, label, panelId
|
|||
return (
|
||||
<div className="ds-tabs" ref={row} role="tablist" aria-label={label} onKeyDown={onKey}>
|
||||
{items.map((x, i) => (
|
||||
<button key={x.id} ref={el => { refs.current[i] = el; }} role="tab" id={`tab-${x.id}`} aria-selected={value === x.id} aria-controls={panelId}
|
||||
<button key={x.id} ref={el => { refs.current[i] = el; }} role="tab" id={`tab-${x.id}`} aria-selected={value === x.id} aria-controls={panelId} aria-keyshortcuts={x.hint}
|
||||
tabIndex={value === x.id ? 0 : -1} className="ds-tab" onClick={() => onChange(x.id)} hidden={!visible(i)}>
|
||||
{x.icon && <Icon name={x.icon} weight={value === x.id ? 'fill' : 'regular'} />}{x.label}
|
||||
{x.count != null && x.count > 0 && <span className="ds-tab-n num">{x.count}</span>}
|
||||
{x.dot && <span className="ed-dot" title={x.dotTitle} />}
|
||||
{x.hint && <kbd className="ds-tab-k" aria-hidden="true">{x.hint}</kbd>}
|
||||
</button>
|
||||
))}
|
||||
{hidden.length > 0 && (
|
||||
|
|
|
|||
79
apps/desktop/tests/sections.test.ts
Normal file
79
apps/desktop/tests/sections.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// App sections (docs IA): old tab ids and section links resolve to one place, the sections an app has, the "what's
|
||||
// next" line of each section and the sidebar's status line.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { StoreRelease } from '../src/api';
|
||||
import type { Attention } from '../src/lib/model';
|
||||
import { isGameSummary, sideStatus } from '../src/lib/model';
|
||||
import { listingProblems, type SectionInput, sectionNext } from '../src/lib/sections';
|
||||
import { APP_TABS, parseRouteJson, parseStart, placeOf, SECTION_TABS, SECTIONS, sectionOf, sectionsFor, subsOf } from '../src/nav';
|
||||
|
||||
const rel = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase'], o: Partial<StoreRelease> = {}): StoreRelease =>
|
||||
({ store, version, phase, fraction: null, track: null, ...o }) as StoreRelease;
|
||||
const base: SectionInput = { project: true, att: [], all: [], next: null, summary: null, catalog: null, unsaved: 0, listings: null, reviews: null, games: null };
|
||||
const att = (tone: Attention['tone'], tab: Attention['tab'], text = 'x'): Attention => ({ app: 'A', projectId: 'a', tone, text, tab });
|
||||
|
||||
describe('places', () => {
|
||||
test('every old tab id is in exactly one section', () => {
|
||||
for (const tab of APP_TABS) expect(SECTIONS.filter(s => SECTION_TABS[s].includes(tab))).toHaveLength(1);
|
||||
});
|
||||
test('old tab ids, sections and section/sub links', () => {
|
||||
expect(placeOf('prices')).toBe('prices');
|
||||
expect(placeOf('revenue')).toBe('catalog');
|
||||
expect(placeOf('store/art')).toBe('art');
|
||||
expect(placeOf('store/prices')).toBe('listings');
|
||||
expect(placeOf('health')).toBe('health');
|
||||
expect(placeOf('nope')).toBe('releases');
|
||||
expect(parseStart('app:players/games:2', ['a', 'b'])).toEqual({ screen: 'app', appId: 'b', tab: 'games' });
|
||||
expect(parseStart('app:release', ['a'])).toEqual({ screen: 'app', appId: 'a', tab: 'releases' });
|
||||
expect(parseRouteJson('{"screen":"app","appId":"k","tab":"store/appinfo"}')).toEqual({ screen: 'app', appId: 'k', tab: 'appinfo' });
|
||||
expect(sectionOf('testflight')).toBe('release');
|
||||
});
|
||||
test('store-only apps have no Revenue; Game services only for games with a folder', () => {
|
||||
expect(sectionsFor(false)).toEqual(['release', 'store', 'players', 'health']);
|
||||
expect(subsOf('players', true, false)).toEqual(['reviews']);
|
||||
expect(subsOf('players', true, true)).toEqual(['reviews', 'games']);
|
||||
expect(subsOf('players', false, true)).toEqual(['reviews']);
|
||||
expect(subsOf('store', false)).toEqual(['listings', 'art']);
|
||||
});
|
||||
test('a game: profile, games config or a Play game category', () => {
|
||||
expect(isGameSummary({ profile: 'app' } as never)).toBe(false);
|
||||
expect(isGameSummary({ profile: 'game' } as never)).toBe(true);
|
||||
expect(isGameSummary({ profile: 'app', games: { gameCenter: true, playGames: false, leaderboards: 0, achievements: 0 } } as never)).toBe(true);
|
||||
expect(isGameSummary({ profile: 'app', playRelease: { managedPublishing: null, countries: null, category: 'Puzzle' } } as never)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('next step per section', () => {
|
||||
test('release: rejected first, then blockers, rollout, review (managed), upload, live', () => {
|
||||
expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'rejected')], att: [att('bad', 'checks')] })).toMatchObject({ tone: 'bad', tab: 'releases' });
|
||||
expect(sectionNext('release', { ...base, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ text: '2 blockers before submit', tab: 'checks' });
|
||||
expect(sectionNext('release', { ...base, all: [rel('android', '3.0', 'rollout', { fraction: 0.2 })] }).text).toContain('20');
|
||||
expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'review', { managed: true })] }).text).toContain('Publishing overview');
|
||||
expect(sectionNext('release', base)).toMatchObject({ tab: 'releases', text: 'Upload a first build' });
|
||||
expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'live')] })).toMatchObject({ tone: 'ok', tab: null });
|
||||
});
|
||||
test('store, revenue, players, health', () => {
|
||||
const listings = { files: { play: null, apple: null }, problems: [], locales: [{ lang: 'en', rtl: false, screenshots: null, complete: 0.5, apple: null, play: { code: 'en-US', fields: [{ field: 'title', length: 0, limit: 30, required: true, state: 'missing' }] } }] } as never;
|
||||
expect(listingProblems(listings)).toBe(1);
|
||||
expect(sectionNext('store', { ...base, listings })).toMatchObject({ text: '1 listing problem', tab: 'listings' });
|
||||
expect(sectionNext('revenue', base).tab).toBe('catalog');
|
||||
expect(sectionNext('revenue', { ...base, catalog: { checked: 'live', ok: false, products: 2, subscriptions: 0, onlyIos: ['a'], onlyAndroid: [], missingIos: [], missingAndroid: ['b'] } })).toMatchObject({ tone: 'warn', text: '2 products differ between the stores' });
|
||||
expect(sectionNext('players', { ...base, reviews: [{}, { reply: { text: 'hi' } }, {}] })).toMatchObject({ text: '2 reviews without a reply', tab: 'reviews' });
|
||||
expect(sectionNext('players', { ...base, reviews: [], games: { changes: 0, blockers: 0, apiOff: true } })).toMatchObject({ tone: 'bad', tab: 'games' });
|
||||
expect(sectionNext('players', { ...base, reviews: [] }).tone).toBe('ok');
|
||||
expect(sectionNext('health', { ...base, att: [att('bad', 'health', 'Crash rate over the line')] })).toMatchObject({ text: 'Crash rate over the line', tab: 'health' });
|
||||
expect(sectionNext('health', { ...base, att: [att('bad', 'checks')] })).toMatchObject({ text: '1 problem to fix first', tab: 'checks' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebar status line', () => {
|
||||
const o = { att: [] as Attention[], all: [] as StoreRelease[], read: true, missing: false, storeOnly: false };
|
||||
test('groups and lines', () => {
|
||||
expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'rejected')] })).toEqual({ group: 'attention', text: 'Rejected · iOS 2.1', tone: 'bad' });
|
||||
expect(sideStatus({ ...o, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ group: 'attention', text: '2 blockers' });
|
||||
expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'review'), rel('android', '2.0', 'live')] })).toMatchObject({ group: 'progress', text: 'In review · iOS 2.1' });
|
||||
expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'live'), rel('android', '2.1', 'live')] })).toEqual({ group: 'live', text: 'Live · 2.1', tone: 'ok' });
|
||||
expect(sideStatus({ ...o, read: false })).toMatchObject({ group: 'draft', text: 'Not read yet' });
|
||||
expect(sideStatus({ ...o, missing: true })).toMatchObject({ group: 'attention', text: 'Folder missing' });
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,8 @@ import { ascClient } from './apple/asc';
|
|||
import { readProjectEntitlements } from './apple/signing';
|
||||
import type { Loaded } from './lib/config';
|
||||
import { consoleUrl } from './lib/console-links';
|
||||
import { type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, planGameCenter, planPlayGames, pgsFindings, readPgsProject, usesPlayGames } from './lib/game-services';
|
||||
import { type Action, type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, type ItemPlan, liveGcLocales, onStore, planGameCenter, planPlayGames, pgsFindings, readPgsProject, suggestLocaleMap, usesPlayGames } from './lib/game-services';
|
||||
import { parseGoogleError } from './lib/google-errors';
|
||||
import type { Finding as VFinding } from './lib/validate-rules';
|
||||
import { gamesConfigClient, playDefaultLocale, readPlayGames } from './play/games';
|
||||
|
||||
|
|
@ -18,6 +19,33 @@ export interface GameServicesStatus {
|
|||
config: { leaderboards: number; achievements: number }; changes: string[]; error?: string };
|
||||
findings: (Finding & { store: 'apple' | 'play' })[];
|
||||
links: { label: string; url: string }[];
|
||||
/** One row per configured leaderboard / achievement: its texts and where each store stands against the config
|
||||
* (create / update / keep; null = not on that store or that store was not read). */
|
||||
items?: GameItemRow[];
|
||||
/** Game Center locale map: the configured entries and the suggestions from the live locales. */
|
||||
localeMap?: { configured: Record<string, string>; suggested: { id: string; from: string; to: string }[] };
|
||||
/** Play: the Games Configuration API is off in the service account's Cloud project. */
|
||||
playApiDisabled?: { service: string; title: string; url: string } | null;
|
||||
}
|
||||
|
||||
export interface GameItemRow {
|
||||
kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean;
|
||||
ios: boolean; android: boolean;
|
||||
apple: { action: Action; changed: string[]; locales: { locale: string; action: Action }[] } | null;
|
||||
play: { action: Action; changed: string[] } | null;
|
||||
}
|
||||
|
||||
/** The per-item rows of the status (pure; plans are null when that store was not read). */
|
||||
export function gameItemRows(g: NonNullable<Loaded['config']['games']>, applePlan: ItemPlan[] | null, playPlan: ItemPlan[] | null): GameItemRow[] {
|
||||
const find = (plan: ItemPlan[] | null, kind: ItemPlan['kind'], id: string) => plan?.find(p => p.kind === kind && p.id === id) ?? null;
|
||||
const row = (kind: ItemPlan['kind'], x: { id: string; locales: Record<string, { name: string }>; platforms?: ('ios' | 'android')[] }, extra: Partial<GameItemRow>): GameItemRow => {
|
||||
const a = find(applePlan, kind, x.id), p = find(playPlan, kind, x.id);
|
||||
return { kind, id: x.id, name: Object.values(x.locales)[0]?.name ?? x.id, locales: Object.keys(x.locales), ...extra,
|
||||
ios: onStore(x, 'apple'), android: onStore(x, 'play'),
|
||||
apple: a ? { action: a.action, changed: a.changed, locales: a.locales.map(l => ({ locale: l.locale, action: l.action })) } : null,
|
||||
play: p ? { action: p.action, changed: p.changed } : null };
|
||||
};
|
||||
return [...(g.leaderboards ?? []).map(l => row('leaderboard', l, {})), ...(g.achievements ?? []).map(a => row('achievement', a, { points: a.points, hidden: !!a.hidden }))];
|
||||
}
|
||||
|
||||
/** Offline findings (check, scan-like): entitlement, APP_ID resource, declarations reminders. */
|
||||
|
|
@ -37,7 +65,8 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p
|
|||
const { config, root } = loaded;
|
||||
const g = config.games ?? {};
|
||||
const findings = localGameFindings(loaded);
|
||||
const out: GameServicesStatus = { apple: null, play: null, findings, links: [] };
|
||||
const out: GameServicesStatus = { apple: null, play: null, findings, links: [], localeMap: { configured: { ...(g.localeMap ?? {}) }, suggested: [] }, playApiDisabled: null };
|
||||
let applePlan: ItemPlan[] | null = null, playPlan: ItemPlan[] | null = null;
|
||||
const count = (store: 'apple' | 'play') => ({ leaderboards: (g.leaderboards ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length,
|
||||
achievements: (g.achievements ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length });
|
||||
const gcUse = config.apple ? gameCenterInUse(config, root) : { inUse: false, why: null };
|
||||
|
|
@ -46,8 +75,10 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p
|
|||
try {
|
||||
const c = await ascClient(root, config.apple.bundleId, config.apple.appId);
|
||||
const s = await readGameCenter(c, config.apple.bundleId);
|
||||
applePlan = planGameCenter(g, config.languages, s, g.localeMap);
|
||||
out.localeMap!.suggested = suggestLocaleMap(config.languages, liveGcLocales(s), g.localeMap ?? {});
|
||||
out.apple = { capability: s.capability, enabled: !!s.detail, version: s.version?.versionString ?? null, versionEnabled: s.version ? s.version.enabled : null,
|
||||
leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s, g.localeMap)), why: gcUse.why ?? undefined };
|
||||
leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(applePlan), why: gcUse.why ?? undefined };
|
||||
} catch (e) { out.apple = { capability: null, enabled: false, version: null, versionEnabled: null, leaderboards: 0, achievements: 0, config: count('apple'), changes: [], error: (e as Error).message.slice(0, 300) }; }
|
||||
}
|
||||
if (config.play && usesPlayGames(config) && o.play !== false) {
|
||||
|
|
@ -58,9 +89,14 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p
|
|||
if (!appId) out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: 'no games project id yet (storebound console plan play-games-services)' };
|
||||
else try {
|
||||
const have = await readPlayGames(gamesConfigClient(root), appId);
|
||||
out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(planPlayGames(g, config.languages, playDefaultLocale(config), have)) };
|
||||
} catch (e) { out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; }
|
||||
playPlan = planPlayGames(g, config.languages, playDefaultLocale(config), have);
|
||||
out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(playPlan) };
|
||||
} catch (e) {
|
||||
const f = parseGoogleError((e as Error).message);
|
||||
if (f?.kind === 'api-disabled') out.playApiDisabled = { service: f.service, title: f.serviceTitle, url: f.activationUrl };
|
||||
out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; }
|
||||
}
|
||||
out.items = gameItemRows(g, applePlan, playPlan);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export function summarize(ctx: Ctx): ProjectSummary {
|
|||
variants: Object.keys(config.shots?.variants ?? {}),
|
||||
playReports: !!config.play?.reportsBucket,
|
||||
playRelease: config.play ? { managedPublishing: config.play.managedPublishing ?? null, countries: config.play.countries?.length ?? null, category: playCategoryOf(config)?.category ?? null } : null,
|
||||
games: config.games ? { gameCenter: config.games.gameCenter === true, playGames: !!config.games.playGames, leaderboards: config.games.leaderboards?.length ?? 0, achievements: config.games.achievements?.length ?? 0 } : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ export interface ProjectSummary {
|
|||
/** Play production settings from the config: play.managedPublishing (null: not set, Play's default off), the number
|
||||
* of play.countries (null: all countries / regions) and the App category (play.category or the App Store match). */
|
||||
playRelease?: { managedPublishing: boolean | null; countries: number | null; category: string | null } | null;
|
||||
/** The games section of the config (Game Center / Play Games Services and the item counts), null without one. */
|
||||
games?: { gameCenter: boolean; playGames: boolean; leaderboards: number; achievements: number } | null;
|
||||
}
|
||||
|
||||
// ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ----
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { capabilitySpecs, planCapabilities } from '../cli/lib/signing';
|
|||
import { gamesPrivacyFindings, gcFindings, gcLocaleMap, liveGcLocales, suggestLocaleMap, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services';
|
||||
import { consolePlan } from '../cli/lib/console-plan';
|
||||
import { verifyText } from '../cli/lib/console-verify';
|
||||
import { gameServicesFindings, type GameServicesStatus } from '../cli/games-status';
|
||||
import { gameItemRows, gameServicesFindings, type GameServicesStatus } from '../cli/games-status';
|
||||
import { run as gameCenter } from '../cli/apple/game-center';
|
||||
import { run as playGames } from '../cli/play/games';
|
||||
import { run as appleSubmit } from '../cli/apple/submit';
|
||||
|
|
@ -298,3 +298,15 @@ describe('apple submit with a Game Center build (live 409 STATE_ERROR.BUILD_INDI
|
|||
expect(s.asc.all('gameCenterAppVersions')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('games status rows (desktop Players section)', () => {
|
||||
test('one row per item, store states from the plans, null when a store was not read', () => {
|
||||
const g = { leaderboards: [{ id: 'com.example.lb', locales: { en: { name: 'Best' } } }], achievements: [{ id: 'com.example.a1', points: 10, hidden: true, platforms: ['android' as const], locales: { en: { name: 'First', description: 'd' } } }] };
|
||||
const plan = [{ kind: 'leaderboard' as const, id: 'com.example.lb', action: 'update' as const, attributes: {}, changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' as const, attributes: {}, changed: [] }] }];
|
||||
const rows = gameItemRows(g, plan, null);
|
||||
expect(rows.map(r => [r.kind, r.id, r.name, r.ios, r.android])).toEqual([['leaderboard', 'com.example.lb', 'Best', true, true], ['achievement', 'com.example.a1', 'First', false, true]]);
|
||||
expect(rows[0]!.apple).toEqual({ action: 'update', changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' }] });
|
||||
expect(rows[0]!.play).toBeNull();
|
||||
expect(rows[1]).toMatchObject({ points: 10, hidden: true, apple: null });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -244,20 +244,51 @@ async function newPage(ctx: { newPage(): Promise<import('playwright').Page> }):
|
|||
return page;
|
||||
}
|
||||
|
||||
/** Opens an app tab like a person: the tab itself when it is in the row, else through the row's "More" menu (tabs
|
||||
* that do not fit the window go there). */
|
||||
/** The app's places (docs IA): five sections, each with sub views in a segmented control. A place is named by its
|
||||
* sub view's old tab id ("tab-prices"); the section of each, for opening it like a person. */
|
||||
const SECTION_OF: Record<string, string> = {
|
||||
releases: 'release', testflight: 'release', listings: 'store', art: 'store', appinfo: 'store', catalog: 'revenue', prices: 'revenue',
|
||||
reviews: 'players', games: 'players', health: 'health', growth: 'health', checks: 'health', tools: 'health',
|
||||
};
|
||||
|
||||
/** Opens a place in an app like a person: its section in the section row (or through the row's "More" menu when it
|
||||
* does not fit), then the sub view in the section's segmented control when the section has more than one. */
|
||||
async function clickTab(page: Page, id: string, o: { timeout?: number } = {}): Promise<void> {
|
||||
const tab = page.locator(`[role="tab"]#${id}`);
|
||||
const sub = id.replace(/^tab-/, '');
|
||||
const sec = SECTION_OF[sub];
|
||||
if (!sec) throw new Error(`unknown place ${id}`);
|
||||
const tab = page.locator(`[role="tab"]#tab-${sec}`);
|
||||
await tab.waitFor({ state: 'attached', timeout: o.timeout });
|
||||
if (await tab.isVisible()) { await tab.click(o); return; }
|
||||
const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim();
|
||||
try {
|
||||
await page.locator('.ds-tab-more').click(o);
|
||||
await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o);
|
||||
} catch (e) {
|
||||
const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth }));
|
||||
throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`);
|
||||
if (await tab.isVisible()) await tab.click(o);
|
||||
else {
|
||||
const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim();
|
||||
try {
|
||||
await page.locator('.ds-tab-more').click(o);
|
||||
await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o);
|
||||
} catch (e) {
|
||||
const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth }));
|
||||
throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`);
|
||||
}
|
||||
}
|
||||
const btn = page.locator(`.sec-subs #sub-${sub}`);
|
||||
if (await btn.count()) { await btn.click(o); return; }
|
||||
// a section with one sub view has no segmented control: the section itself is the place
|
||||
if (await page.locator('.sec-subs button').count()) throw new Error(`${id}: not in the ${sec} section's views`);
|
||||
}
|
||||
|
||||
/** Every place of the open app, in order: each section, then each of its sub views (as clickTab ids). */
|
||||
async function places(page: Page, o: { timeout?: number } = {}): Promise<string[]> {
|
||||
const secs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id.replace(/^tab-/, '')));
|
||||
const out: string[] = [];
|
||||
for (const sec of secs) {
|
||||
await page.locator(`[role="tab"]#tab-${sec}`).click(o).catch(() => {});
|
||||
await page.locator('[data-testid="section-head"]').waitFor({ state: 'visible', timeout: o.timeout }).catch(() => {});
|
||||
const subs = await page.locator('.sec-subs button').evaluateAll(bs => bs.map(b => b.id.replace(/^sub-/, '')));
|
||||
const first = Object.entries(SECTION_OF).find(([, s]) => s === sec)?.[0];
|
||||
// a one-view section: its only view (Players without games is Reviews)
|
||||
out.push(...(subs.length ? subs : first ? [first] : []).map(x => `tab-${x}`));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function state(page: Page) {
|
||||
|
|
@ -342,10 +373,12 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
|
|||
if (await wizard.count()) problems.push('the welcome guide does not close with Escape');
|
||||
}
|
||||
// every sidebar entry: the portfolio, each app (folders that are gone included), each workspace screen
|
||||
const count = await page.locator('nav.side button.nav').count();
|
||||
// (apps are .nav rows; the workspace is the footer's icon row, named by aria-label)
|
||||
const SIDE = 'nav.side button.nav, nav.side button.nav-ws';
|
||||
const count = await page.locator(SIDE).count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const b = page.locator('nav.side button.nav').nth(i);
|
||||
const label = ((await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim();
|
||||
const b = page.locator(SIDE).nth(i);
|
||||
const label = ((await b.getAttribute('aria-label').catch(() => null)) || (await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim();
|
||||
await b.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`));
|
||||
await settle(page);
|
||||
await check(label);
|
||||
|
|
@ -365,7 +398,7 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
|
|||
// shared with the CLI: each account names its credential profile (or that its old keychain key has not moved)
|
||||
const accts = fx.accounts_list as { label?: string; profile?: string | null }[];
|
||||
if (accts.length) {
|
||||
const nav = page.locator('nav.side button.nav', { hasText: 'Accounts' }).first();
|
||||
const nav = page.locator('nav.side [data-screen="accounts"]').first();
|
||||
if (await nav.count()) {
|
||||
await nav.click({ timeout: WAIT_MS });
|
||||
await settle(page);
|
||||
|
|
@ -510,8 +543,8 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full'
|
|||
await gone(page, '.cp-panel');
|
||||
await crashCheck('Releases');
|
||||
visited++;
|
||||
// every tab of the app, then App info's console steps and the Dependencies screen
|
||||
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
|
||||
// every place of the app (each section and its sub views), then App info's console steps and Dependencies
|
||||
const tabs = await places(page, { timeout: WAIT_MS });
|
||||
for (const id of tabs) {
|
||||
await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`));
|
||||
await settle(page);
|
||||
|
|
@ -524,7 +557,7 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full'
|
|||
if (id === 'tab-testflight' && !(await text()).length) problems.push('TestFlight: empty');
|
||||
}
|
||||
notes.push(`tabs: ${tabs.join(', ')}`);
|
||||
const deps = page.locator('nav.side button.nav', { hasText: 'Dependencies' }).first();
|
||||
const deps = page.locator('nav.side [data-screen="dependencies"]').first();
|
||||
await deps.click({ timeout: WAIT_MS }).catch(() => {});
|
||||
await shows(page, '#content', 'Android SDK');
|
||||
await settle(page);
|
||||
|
|
@ -710,7 +743,7 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
|
|||
await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`));
|
||||
await settle(page);
|
||||
const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim();
|
||||
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
|
||||
const tabs = await places(page, { timeout: WAIT_MS });
|
||||
for (const id of tabs) {
|
||||
await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`${appName} ${id}: ${String(e).split('\n')[0]}`));
|
||||
await settle(page);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue