Desktop v2 screens: releases (store cards, stage stepper, rollout ring), submit checklist, TestFlight cards; listings as store pages with completeness, framed screenshots with issue badges, product cards, price matrix with gap shading; health tiles and issue cards, store-style review feed with rating distribution, growth funnel, readiness score with fix-first, tools grid, store-info sheet; settings, accounts, folders, dependencies and onboarding in the v2 language
This commit is contained in:
parent
ce939ee063
commit
f131de9df2
47 changed files with 2309 additions and 927 deletions
|
|
@ -817,6 +817,19 @@ export const en = {
|
|||
'set.unmute': 'Unmute ({until})',
|
||||
'set.muted': 'Muted until {until}',
|
||||
'set.clickNote': "Clicking a notification does not open the app on desktop; the next time you open the window from the tray, it goes to that app's Releases tab.",
|
||||
'v2cfg.workspace': 'Workspace',
|
||||
'v2cfg.studios': 'Studios',
|
||||
'v2cfg.accounts': 'Store accounts',
|
||||
'v2cfg.perStore': '{play} Google Play · {apple} App Store',
|
||||
'v2cfg.keysWork': 'Keys working',
|
||||
'v2cfg.keysFailing': 'Keys failing',
|
||||
'v2cfg.depsFound': 'Tools found',
|
||||
'v2cfg.depsNeeds': 'Need attention',
|
||||
'v2cfg.depsInApp': 'In app data',
|
||||
'v2cfg.depsInAppSub': 'downloaded by storebound',
|
||||
'v2cfg.needs': 'needs {version}',
|
||||
'v2cfg.stepOf': 'Step {n} of {total}',
|
||||
'v2cfg.progress': 'Setup progress',
|
||||
'set.lead': 'Studios, accounts, store reads, the offline cache, language, theme, notifications and more. Everything stays on this computer.',
|
||||
'set.sections': 'Settings sections',
|
||||
'set.studios': 'Studios & accounts',
|
||||
|
|
@ -1163,6 +1176,14 @@ export const en = {
|
|||
'art.render': 'Render {lang}',
|
||||
'art.notThere': 'Not there yet',
|
||||
'art.count': '{count} files',
|
||||
'v2lst.preview': 'Store preview',
|
||||
'v2lst.noSub': 'No subtitle yet',
|
||||
'v2lst.noDesc': 'No description yet',
|
||||
'v2lst.more': 'more',
|
||||
'v2lst.ready': 'Ready',
|
||||
'v2lst.toFix': '{count} to fix',
|
||||
'v2lst.issues': '{count} issues',
|
||||
'v2lst.issues_one': '1 issue',
|
||||
'art.count_one': '1 file',
|
||||
'art.open': 'Open {name}',
|
||||
'art.tooBig': 'too big to preview',
|
||||
|
|
@ -1852,6 +1873,26 @@ export const en = {
|
|||
'tools.g.ads': 'Apple Ads',
|
||||
'tools.g.flows': 'Workflows and AI tools',
|
||||
'tools.count': '{count} commands',
|
||||
'v2ins.hStartup': 'Launch time',
|
||||
'v2ins.hStartupSub': 'App Store diagnostics · version {version}',
|
||||
'v2ins.rvOpen': 'Unanswered',
|
||||
'v2ins.gFunnel': 'Store funnel',
|
||||
'v2ins.gVsPrev': '% vs the {count} days before',
|
||||
'v2ins.gPts': 'points vs the {count} days before',
|
||||
'v2ins.cScore': 'Store readiness',
|
||||
'v2ins.cScoreSub': '{ok} of {total} checks pass',
|
||||
'v2ins.cReady': 'Ready to submit',
|
||||
'v2ins.cBlocked': '{count} blockers to fix',
|
||||
'v2ins.cNotRun': 'Not checked yet',
|
||||
'v2ins.cBlockers': 'Blockers',
|
||||
'v2ins.cWarnings': 'Warnings',
|
||||
'v2ins.cPassedN': 'Passed',
|
||||
'v2ins.cFixFirst': 'Fix first',
|
||||
'v2ins.cPassed': 'Passed checks ({count})',
|
||||
'v2ins.cAccepted': 'Accepted risks ({count})',
|
||||
'v2ins.aiSheet': 'Store info',
|
||||
'v2ins.aiOpen': 'Open questions',
|
||||
'v2ins.aiNone': 'None',
|
||||
'tools.count_one': '1 command',
|
||||
'tools.show': 'Show',
|
||||
'tools.hide': 'Hide',
|
||||
|
|
@ -1947,4 +1988,24 @@ export const en = {
|
|||
'pf.note': "Note",
|
||||
'pf.fixIn': "Fix in {tab}",
|
||||
'pf.openTab': "Open {tab}",
|
||||
'v2rel.version': "Version",
|
||||
'v2rel.stages': "{store} release stages",
|
||||
'v2rel.ofUsers': "of users get this version",
|
||||
'v2rel.versions': "{count} versions",
|
||||
'v2rel.ready': "Ready",
|
||||
'v2rel.blocked': "Blocked",
|
||||
'v2rel.todo': "To do",
|
||||
'v2rel.attention': "Check",
|
||||
'v2rel.readyCount': "{done} of {total} steps ready",
|
||||
'v2rel.steps': "Steps to send for review",
|
||||
'v2rel.stepRead': "Store state",
|
||||
'v2rel.stepNoOpen': "Nothing else in review",
|
||||
'v2rel.stepBuild': "Pick the build",
|
||||
'v2rel.stepVersion': "Version to send",
|
||||
'v2rel.stepSend': "Submit for review",
|
||||
'v2rel.stepCode': "Pick the versionCode",
|
||||
'v2rel.tfGroups': "Beta groups",
|
||||
'v2rel.tfInGroups': "in {count} groups",
|
||||
'v2rel.tfSplit': "{internal} internal · {external} external",
|
||||
'v2rel.tfUsable': "Ready to test",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -819,6 +819,19 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'set.unmute': 'Sesi aç ({until})',
|
||||
'set.muted': '{until} saatine kadar sessiz',
|
||||
'set.clickNote': 'Masaüstünde bildirime tıklamak uygulamayı açmaz; pencereyi bir sonraki sefer tepsiden açtığınızda o uygulamanın Yayınlar sekmesine gider.',
|
||||
'v2cfg.workspace': 'Çalışma alanı',
|
||||
'v2cfg.studios': 'Stüdyolar',
|
||||
'v2cfg.accounts': 'Mağaza hesapları',
|
||||
'v2cfg.perStore': '{play} Google Play · {apple} App Store',
|
||||
'v2cfg.keysWork': 'Çalışan anahtarlar',
|
||||
'v2cfg.keysFailing': 'Sorunlu anahtarlar',
|
||||
'v2cfg.depsFound': 'Bulunan araçlar',
|
||||
'v2cfg.depsNeeds': 'İlgi bekleyen',
|
||||
'v2cfg.depsInApp': 'Uygulama verisinde',
|
||||
'v2cfg.depsInAppSub': 'storebound indirdi',
|
||||
'v2cfg.needs': '{version} gerekli',
|
||||
'v2cfg.stepOf': 'Adım {n} / {total}',
|
||||
'v2cfg.progress': 'Kurulum ilerlemesi',
|
||||
'set.lead': 'Stüdyolar, hesaplar, mağaza okumaları, çevrimdışı önbellek, dil, tema, bildirimler ve dahası. Hepsi bu bilgisayarda kalır.',
|
||||
'set.sections': 'Ayar bölümleri',
|
||||
'set.studios': 'Stüdyolar ve hesaplar',
|
||||
|
|
@ -1165,6 +1178,14 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'art.render': '{lang} oluştur',
|
||||
'art.notThere': 'Henüz yok',
|
||||
'art.count': '{count} dosya',
|
||||
'v2lst.preview': 'Mağaza önizlemesi',
|
||||
'v2lst.noSub': 'Henüz alt başlık yok',
|
||||
'v2lst.noDesc': 'Henüz açıklama yok',
|
||||
'v2lst.more': 'devamı',
|
||||
'v2lst.ready': 'Hazır',
|
||||
'v2lst.toFix': '{count} düzeltilecek',
|
||||
'v2lst.issues': '{count} sorun',
|
||||
'v2lst.issues_one': '1 sorun',
|
||||
'art.count_one': '1 dosya',
|
||||
'art.open': '{name} aç',
|
||||
'art.tooBig': 'önizleme için çok büyük',
|
||||
|
|
@ -1854,6 +1875,26 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'tools.g.ads': 'Apple Ads',
|
||||
'tools.g.flows': 'İş akışları ve yapay zekâ araçları',
|
||||
'tools.count': '{count} komut',
|
||||
'v2ins.hStartup': 'Açılış süresi',
|
||||
'v2ins.hStartupSub': 'App Store tanılamaları · sürüm {version}',
|
||||
'v2ins.rvOpen': 'Yanıtsız',
|
||||
'v2ins.gFunnel': 'Mağaza hunisi',
|
||||
'v2ins.gVsPrev': 'önceki {count} güne göre %',
|
||||
'v2ins.gPts': 'önceki {count} güne göre puan',
|
||||
'v2ins.cScore': 'Mağaza hazırlığı',
|
||||
'v2ins.cScoreSub': '{total} kontrolden {ok} tanesi geçiyor',
|
||||
'v2ins.cReady': 'Göndermeye hazır',
|
||||
'v2ins.cBlocked': 'Düzeltilecek {count} engel',
|
||||
'v2ins.cNotRun': 'Henüz kontrol edilmedi',
|
||||
'v2ins.cBlockers': 'Engeller',
|
||||
'v2ins.cWarnings': 'Uyarılar',
|
||||
'v2ins.cPassedN': 'Geçti',
|
||||
'v2ins.cFixFirst': 'Önce bunları düzelt',
|
||||
'v2ins.cPassed': 'Geçen kontroller ({count})',
|
||||
'v2ins.cAccepted': 'Kabul edilen riskler ({count})',
|
||||
'v2ins.aiSheet': 'Mağaza bilgileri',
|
||||
'v2ins.aiOpen': 'Açık sorular',
|
||||
'v2ins.aiNone': 'Yok',
|
||||
'tools.count_one': '1 komut',
|
||||
'tools.show': 'Göster',
|
||||
'tools.hide': 'Gizle',
|
||||
|
|
@ -1949,4 +1990,24 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'pf.note': "Not",
|
||||
'pf.fixIn': "{tab} sekmesinde düzelt",
|
||||
'pf.openTab': "{tab} sekmesini aç",
|
||||
'v2rel.version': "Sürüm",
|
||||
'v2rel.stages': "{store} yayın aşamaları",
|
||||
'v2rel.ofUsers': "kullanıcı bu sürümü alıyor",
|
||||
'v2rel.versions': "{count} sürüm",
|
||||
'v2rel.ready': "Hazır",
|
||||
'v2rel.blocked': "Engelli",
|
||||
'v2rel.todo': "Yapılacak",
|
||||
'v2rel.attention': "Kontrol et",
|
||||
'v2rel.readyCount': "{total} adımın {done} tanesi hazır",
|
||||
'v2rel.steps': "İncelemeye gönderme adımları",
|
||||
'v2rel.stepRead': "Mağaza durumu",
|
||||
'v2rel.stepNoOpen': "İncelemede başka gönderim yok",
|
||||
'v2rel.stepBuild': "Build'i seç",
|
||||
'v2rel.stepVersion': "Gönderilecek sürüm",
|
||||
'v2rel.stepSend': "İncelemeye gönder",
|
||||
'v2rel.stepCode': "versionCode'u seç",
|
||||
'v2rel.tfGroups': "Beta grupları",
|
||||
'v2rel.tfInGroups': "{count} grupta",
|
||||
'v2rel.tfSplit': "{internal} dahili · {external} harici",
|
||||
'v2rel.tfUsable': "Teste hazır",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import { ago } from '../lib/format';
|
|||
import { withIgnored } from '../lib/identity';
|
||||
import { type AccountHealth, useData } from '../store';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { Card, Chip, Empty, Notice, StudioMark } from '../ui/kit';
|
||||
import { Chip, Notice, StudioMark } from '../ui/kit';
|
||||
import { EmptyState, PageHeader, Section, StatTile } from '../ui/ds';
|
||||
import '../styles/accounts.css';
|
||||
import type { Tone } from '../lib/model';
|
||||
|
||||
const STATUS: Record<AccountCheck['status'], [Tone, Key]> = {
|
||||
|
|
@ -110,7 +112,7 @@ function StudioCard({ s, accounts, focus }: { s: Studio | null; accounts: Accoun
|
|||
const id = s ? `studio-${s.id}` : 'studio-none';
|
||||
useEffect(() => { if (focus) document.getElementById(id)?.scrollIntoView({ block: 'start' }); }, [focus, id]);
|
||||
return (
|
||||
<section className={`card studio-card${focus ? ' focus' : ''}`} id={id} aria-labelledby={`${id}-h`}>
|
||||
<section className={`ds-sec studio-card${focus ? ' focus' : ''}`} id={id} aria-labelledby={`${id}-h`}>
|
||||
<div className="card-head">
|
||||
<div className="row" style={{ gap: 12, minWidth: 0 }}>
|
||||
<StudioMark studio={s} size="lg" />
|
||||
|
|
@ -178,28 +180,31 @@ export function Accounts({ focus }: { focus?: string }) {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div><h1>{t('nav.accounts')}</h1><p>{t('accounts.leadStudios')}</p></div>
|
||||
<div className="actions">
|
||||
<button className="btn" onClick={testAll} disabled={!!busy || !d.accounts.length}><Icon name="shield" className={busy === 'all' ? 'spin' : undefined} />{t('acct.testAll')}</button>
|
||||
<button className="btn btn-primary" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page scr-accounts">
|
||||
<PageHeader icon="key" eyebrow={t('v2cfg.workspace')} title={t('nav.accounts')} subtitle={t('accounts.leadStudios')} actions={<>
|
||||
<button className="btn" onClick={testAll} disabled={!!busy || !d.accounts.length}><Icon name="shield" className={busy === 'all' ? 'spin' : undefined} />{t('acct.testAll')}</button>
|
||||
<button className="btn btn-primary" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>
|
||||
</>} />
|
||||
{d.accounts.length > 0 && <div className="ds-grid acct-kpis">
|
||||
<StatTile icon="building" label={t('v2cfg.studios')} value={d.studios.length} />
|
||||
<StatTile icon="key" label={t('v2cfg.accounts')} value={d.accounts.length} sub={t('v2cfg.perStore', { play: d.accounts.filter(a => a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length })} />
|
||||
<StatTile icon="check" tone="ok" label={t('v2cfg.keysWork')} value={d.accounts.filter(a => d.accountHealth[a.id]?.check?.status === 'ok').length} />
|
||||
<StatTile icon="alert" tone={d.accounts.some(a => { const h = d.accountHealth[a.id]; return !!h && (!!h.error || (!!h.check && h.check.status !== 'ok')); }) ? 'bad' : undefined} label={t('v2cfg.keysFailing')} value={d.accounts.filter(a => { const h = d.accountHealth[a.id]; return !!h && (!!h.error || (!!h.check && h.check.status !== 'ok')); }).length} />
|
||||
</div>}
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
{!d.accounts.length && !d.studios.length && <Card pad={false}><Empty illo="key" title={t('accounts.emptyTitle')}>{t('accounts.emptyTextStudios')}</Empty></Card>}
|
||||
{!d.accounts.length && !d.studios.length && <Section><EmptyState icon="key"><b>{t('accounts.emptyTitle')}</b> {t('accounts.emptyTextStudios')}</EmptyState></Section>}
|
||||
{d.studios.map(s => <StudioCard key={s.id} s={s} accounts={d.accounts.filter(a => a.studio === s.id)} focus={focus === s.id} />)}
|
||||
{(unassigned.length > 0 || !d.studios.length) && d.accounts.length > 0 && <StudioCard s={null} accounts={unassigned} focus={focus === 'none'} />}
|
||||
|
||||
<div className="grid g3" style={{ alignItems: 'start' }}>
|
||||
<Card title={t('studio.new')} icon="building" sub={t('studio.newSub')}>
|
||||
<div className="acct-add">
|
||||
<Section title={t('studio.new')} icon="building" sub={t('studio.newSub')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addStudio(); }}>
|
||||
<div className="field"><label htmlFor="studio-name">{t('studio.name')}</label><input id="studio-name" className="input" value={studioName} onChange={e => setStudioName(e.target.value)} maxLength={48} placeholder={t('studio.namePlaceholder')} autoComplete="off" /></div>
|
||||
<div className="field"><span className="legend">{t('studio.color')}</span><Swatches value={studioColor} onChange={setStudioColor} name={studioName} /></div>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!studioName.trim() || !!busy}><Icon name="plus" />{t('studio.add')}</button></div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card title="Google Play" icon="key" sub={t('accounts.playSubMany')}>
|
||||
</Section>
|
||||
<Section title="Google Play" icon="key" sub={t('accounts.playSubMany')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addPlay(); }}>
|
||||
<div className="field"><label htmlFor="play-label">{t('accounts.label')}</label>
|
||||
<input id="play-label" className="input" type="text" value={playLabel} onChange={e => setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} maxLength={60} autoComplete="off" /></div>
|
||||
|
|
@ -207,8 +212,8 @@ export function Accounts({ focus }: { focus?: string }) {
|
|||
<p className="small muted">{t('accounts.playHow')}</p>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!playLabel.trim() || !!busy}><Icon name="key" />{t('accounts.choosePlay')}</button></div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card title="App Store Connect" icon="key" sub={t('accounts.appleSubMany')}>
|
||||
</Section>
|
||||
<Section title="App Store Connect" icon="key" sub={t('accounts.appleSubMany')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addApple(); }}>
|
||||
<div className="field"><label htmlFor="apple-label">{t('accounts.label')}</label>
|
||||
<input id="apple-label" className="input" type="text" value={appleLabel} onChange={e => setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} maxLength={60} autoComplete="off" /></div>
|
||||
|
|
@ -221,7 +226,7 @@ export function Accounts({ focus }: { focus?: string }) {
|
|||
</div>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!appleLabel.trim() || keyId.length !== 10 || issuerId.length < 36 || !!busy}><Icon name="key" />{t('accounts.chooseApple')}</button></div>
|
||||
</form>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
<Notice tone="info" icon="shield">{t('accounts.safetyStudios')}</Notice>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new
|
||||
// folder from the templates (`storebound init` with a profile, confirmed in a native dialog).
|
||||
import { failureText } from '../lib/results';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api';
|
||||
import { devSwitch } from '../dev/switches';
|
||||
import { t } from '../i18n';
|
||||
|
|
@ -10,12 +10,20 @@ import { useNav } from '../nav';
|
|||
import { useData } from '../store';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { useFolderActions } from '../ui/FolderMissing';
|
||||
import { AppIcon, Card, Chip, Empty, Notice, Seg, StoreBadge } from '../ui/kit';
|
||||
import { AppIcon, Empty, Notice, Seg, StoreBadge } from '../ui/kit';
|
||||
import { PageHeader, Section, StatusPill, tintStyle, useTint } from '../ui/ds';
|
||||
import '../styles/folders.css';
|
||||
import { checkState } from './Portfolio';
|
||||
|
||||
type Profile = 'app' | 'game' | 'subscription-app';
|
||||
interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string }
|
||||
|
||||
/** Folder card wrapper carrying the app's tint (hooks cannot run inside the list map). */
|
||||
function Tinted({ src, name, children }: { src: string | null | undefined; name: string; children: ReactNode }) {
|
||||
const hue = useTint(src, name);
|
||||
return <div className="li fold-card" style={tintStyle(hue)}>{children}</div>;
|
||||
}
|
||||
|
||||
function LocateButton({ project }: { project: Project }) {
|
||||
const { busy, locate } = useFolderActions(project);
|
||||
return <button className="btn btn-sm btn-primary" onClick={locate} disabled={busy} title={t('folder.locateHint')}><Icon name="search" />{t('folder.locate')}</button>;
|
||||
|
|
@ -73,41 +81,36 @@ export function AppFolders() {
|
|||
: run.result.ok ? t('run.done') : failureText(run.result);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div><h1>{t('nav.apps')}</h1><p>{t('folders.lead')}</p></div>
|
||||
<div className="actions">
|
||||
<button className="btn" onClick={fastlane} disabled={!!busy} title={t('folders.fastlaneHint')}><Icon name="download" />{t('folders.fastlane')}</button>
|
||||
<div className="page scr-folders">
|
||||
<PageHeader icon="folder" eyebrow={t('v2cfg.workspace')} title={t('nav.apps')} subtitle={t('folders.lead')} actions={<><button className="btn" onClick={fastlane} disabled={!!busy} title={t('folders.fastlaneHint')}><Icon name="download" />{t('folders.fastlane')}</button>
|
||||
<button className="btn" onClick={init} disabled={!!busy}><Icon name="sparkle" />{t('folders.startShort')}</button>
|
||||
<button className="btn btn-primary" onClick={add} disabled={!!busy}><Icon name="folderPlus" />{t('action.addFolder')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={add} disabled={!!busy}><Icon name="folderPlus" />{t('action.addFolder')}</button></>} />
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
|
||||
{!d.projects.length ? (
|
||||
<Card pad={false}><Empty illo="apps" title={t('folders.emptyTitle')} actions={<>
|
||||
<Section pad={false}><Empty illo="apps" title={t('folders.emptyTitle')} actions={<>
|
||||
<button className="btn" onClick={fastlane} disabled={!!busy}><Icon name="download" />{t('folders.fastlane')}</button>
|
||||
<button className="btn" onClick={init} disabled={!!busy}><Icon name="sparkle" />{t('folders.startShort')}</button>
|
||||
<button className="btn btn-primary" onClick={add} disabled={!!busy}><Icon name="folderPlus" />{t('action.addFolder')}</button>
|
||||
</>}>{t('folders.emptyText')}</Empty></Card>
|
||||
</>}>{t('folders.emptyText')}</Empty></Section>
|
||||
) : (
|
||||
<Card title={t('folders.list')} icon="folder" sub={t('folders.listSub', { count: d.projects.length })} pad={false}>
|
||||
<Section title={t('folders.list')} icon="folder" sub={t('folders.listSub', { count: d.projects.length })} pad={false}>
|
||||
<div className="list">
|
||||
{d.projects.map(p => {
|
||||
const e = d.statusOf(p);
|
||||
const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e);
|
||||
return (
|
||||
<div className="li" key={p.id} style={{ alignItems: 'center' }}>
|
||||
<AppIcon name={p.name} src={d.icons[p.id]} />
|
||||
<Tinted key={p.id} src={d.icons[p.id]} name={e?.app?.name ?? p.name}>
|
||||
<AppIcon name={p.name} src={d.icons[p.id]} className="fold-icon" />
|
||||
<div className="li-main">
|
||||
<span className="row" style={{ gap: 8 }}><span className="li-title">{e?.app?.name ?? p.name}</span><Chip tone={st.tone}>{st.text}</Chip></span>
|
||||
<span className="row" style={{ gap: 8 }}><span className="li-title">{e?.app?.name ?? p.name}</span><StatusPill tone={st.tone}>{st.text}</StatusPill></span>
|
||||
<span className="li-sub mono-id selectable" title={p.path}>{shortPath(p.path, d.info?.home)}</span>
|
||||
<span className="row" style={{ gap: 6, marginTop: 6 }}>
|
||||
<StoreBadge store="ios" id={e?.app?.platforms.ios?.bundleId} off={!!e && !e.app?.platforms.ios} />
|
||||
<StoreBadge store="android" id={e?.app?.platforms.android?.packageName} off={!!e && !e.app?.platforms.android} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6, width: 270 }}>
|
||||
<div className="stack fold-accs" style={{ gap: 6 }}>
|
||||
<label className="acc-pick" title="Google Play"><StoreGlyph store="android" />
|
||||
<select className="input" aria-label={t('folders.playFor', { name: p.name })} value={p.playAccount ?? ''} onChange={ev => setAcc(p, ev.target.value || null, p.appleAccount)} disabled={!!busy}>
|
||||
<option value="">{t('folders.noPlay')}</option>
|
||||
|
|
@ -119,7 +122,7 @@ export function AppFolders() {
|
|||
{apple.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
<div className="actions" style={{ flexWrap: 'nowrap' }}>
|
||||
<div className="actions fold-acts">
|
||||
{p.missing ? <LocateButton project={p} /> : <>
|
||||
<button className="btn btn-sm" onClick={() => nav.go({ screen: 'app', appId: p.id, tab: 'releases' })} title={t('folders.openApp')}><Icon name="arrowRight" />{t('folders.open')}</button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => api.reveal(p.id).catch(e => setError(errorText(e)))} aria-label={t('folders.reveal')} title={t('folders.reveal')}><Icon name="eye" /></button>
|
||||
|
|
@ -128,14 +131,14 @@ export function AppFolders() {
|
|||
</>}
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => remove(p)} disabled={!!busy} aria-label={t('folders.remove', { name: p.name })} title={t('folders.remove', { name: p.name })}><Icon name="trash" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</Tinted>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Card title={t('folders.newTitle')} icon="sparkle" sub={t('folders.newSub')}>
|
||||
<Section title={t('folders.newTitle')} icon="sparkle" sub={t('folders.newSub')}>
|
||||
<div className="row between" style={{ gap: 16, flexWrap: 'wrap' }}>
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<Seg value={profile} onChange={setProfile} label={t('folders.profile')} options={[['app', t('profile.app')], ['game', t('profile.game')], ['subscription-app', t('profile.subscription-app')]]} />
|
||||
|
|
@ -147,16 +150,16 @@ export function AppFolders() {
|
|||
</div>
|
||||
</div>
|
||||
<p className="small muted act-note"><Icon name="info" />{t('folders.newNote')}</p>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
{run && (
|
||||
<Card title={`${run.title} · ${run.project}`} icon="terminal" sub={<>{status}{run.result && <> · <code className="selectable">{run.result.command}</code></>}</>}
|
||||
<Section title={`${run.title} · ${run.project}`} icon="terminal" sub={<>{status}{run.result && <> · <code className="selectable">{run.result.command}</code></>}</>}
|
||||
actions={<button className="btn btn-sm btn-ghost btn-icon" onClick={() => setRun(null)} aria-label={t('common.close')}><Icon name="x" /></button>}>
|
||||
<div className="stack">
|
||||
{run.error && <Notice tone="bad">{run.error}</Notice>}
|
||||
<pre className="log selectable" ref={logRef} aria-live="polite">{tildeText(run.result?.output ?? run.lines.join('\n'), d.info?.home) || ' '}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import { bytes } from '../lib/format';
|
|||
import { shortPath } from '../paths';
|
||||
import { useData } from '../store';
|
||||
import { Icon, type IconName } from '../ui/icons';
|
||||
import { Card, Chip, Notice, Skeleton } from '../ui/kit';
|
||||
import { Chip, Notice, Skeleton } from '../ui/kit';
|
||||
import { PageHeader, Section, StatTile, StatusPill } from '../ui/ds';
|
||||
import '../styles/deps.css';
|
||||
|
||||
const TOOL_ICON: Record<string, IconName> = { ffmpeg: 'image', ffprobe: 'search', chrome: 'globe', xcode: 'terminal', simctl: 'phone', adb: 'phone', git: 'layers' };
|
||||
|
||||
|
|
@ -72,29 +74,32 @@ export function Dependencies() {
|
|||
const managed = (id: string): DownloadInfo | undefined => info.find(i => i.tool === (id === 'ffprobe' ? 'ffmpeg' : id));
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div><h1>{t('nav.dependencies')}</h1><p>{t('deps.lead')}</p></div>
|
||||
<div className="actions"><button className="btn" onClick={refresh} disabled={busy}><Icon name="refresh" className={busy ? 'spin' : undefined} />{t('deps.checkAgain')}</button></div>
|
||||
</div>
|
||||
<div className="page scr-deps">
|
||||
<PageHeader icon="package" eyebrow={t('v2cfg.workspace')} title={t('nav.dependencies')} subtitle={t('deps.lead')} actions={<><button className="btn" onClick={refresh} disabled={busy}><Icon name="refresh" className={busy ? 'spin' : undefined} />{t('deps.checkAgain')}</button></>} />
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
<Card title={t('deps.tools')} icon="package" sub={tools ? t('deps.found', { found, total: tools.length }) : t('common.loading')} pad={false}>
|
||||
{tools && <div className="ds-grid deps-kpis">
|
||||
<StatTile icon="check" tone={found === tools.length ? 'ok' : undefined} label={t('v2cfg.depsFound')} value={`${found} / ${tools.length}`} />
|
||||
<StatTile icon="alert" tone={tools.some(x => !x.found || x.status === 'outdated') ? 'warn' : undefined} label={t('v2cfg.depsNeeds')} value={tools.filter(x => !x.found || x.status === 'outdated').length} />
|
||||
<StatTile icon="download" label={t('v2cfg.depsInApp')} value={info.filter(i => i.installed).length} sub={t('v2cfg.depsInAppSub')} />
|
||||
</div>}
|
||||
<Section title={t('deps.tools')} icon="package" sub={tools ? t('deps.found', { found, total: tools.length }) : t('common.loading')} pad={false}>
|
||||
{!tools ? <div className="card-body"><Skeleton lines={6} /></div> : (
|
||||
<div className="list">
|
||||
<div className="list deps-grid">
|
||||
{tools.map(x => {
|
||||
const st = toolState(x);
|
||||
const m = x.install.kind === 'download' ? managed(x.id) : undefined;
|
||||
const running = m ? dl[m.tool] : undefined;
|
||||
const pct = running?.total ? running.received / running.total : null;
|
||||
return (
|
||||
<div className="li tool-row" key={x.id} style={{ alignItems: 'center' }}>
|
||||
<div className={`li tool-row tool-${st.tone}`} key={x.id}>
|
||||
<span className="ti"><Icon name={TOOL_ICON[x.id] ?? 'package'} /></span>
|
||||
<div className="li-main">
|
||||
<span className="row" style={{ gap: 8 }}><span className="li-title">{x.name}</span><Chip tone={st.tone}>{st.text}</Chip>{x.version && <span className="small muted num">{x.version}</span>}</span>
|
||||
<span className="row" style={{ gap: 8 }}><span className="li-title">{x.name}</span></span>
|
||||
<span className="row tool-chips" style={{ gap: 6 }}><StatusPill tone={st.tone === 'accent' ? 'info' : st.tone}>{st.text}</StatusPill>{x.version && <Chip tone="outline">{x.version}</Chip>}{x.required && <Chip tone="neutral">{t('v2cfg.needs', { version: x.required })}</Chip>}</span>
|
||||
<span className="li-sub">{x.usedFor}</span>
|
||||
{x.path && <span className="li-sub mono-id selectable" title={x.path}>{shortPath(x.path, d.info?.home)}</span>}
|
||||
</div>
|
||||
<div className="actions" style={{ flexWrap: 'nowrap' }}>
|
||||
<div className="actions tool-acts">
|
||||
{running ? (
|
||||
<div className="prog" aria-live="polite">
|
||||
<div className={`bar${pct === null ? ' indet' : ''}`}><span style={{ width: `${(pct ?? 0.35) * 100}%` }} /></div>
|
||||
|
|
@ -114,9 +119,9 @@ export function Dependencies() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
{sdk && (
|
||||
<Card title={t('deps.sdkTitle')} icon="phone" sub={t('deps.sdkSub')} id="android-sdk"
|
||||
<Section title={t('deps.sdkTitle')} icon="phone" sub={t('deps.sdkSub')} id="android-sdk"
|
||||
actions={sdk.path ? <Chip tone="ok">{t('deps.installed')}</Chip> : <><Chip tone="warn">{t('deps.missing')}</Chip><button className="btn btn-sm" onClick={() => open('https://developer.android.com/studio')}><Icon name="external" />{t('deps.getIt')}</button></>}>
|
||||
{sdk.path ? (
|
||||
<dl className="kv">
|
||||
|
|
@ -129,27 +134,27 @@ export function Dependencies() {
|
|||
<ul className="note-list small">{sdk.checked.map((c, i) => <li key={i} className="selectable">{d.info?.home ? c.split(d.info.home).join("~") : c}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
)}
|
||||
<div className="grid g2">
|
||||
<div className="grid g2 deps-src">
|
||||
{info.map(m => (
|
||||
<Card key={m.tool} title={m.tool === 'chrome' ? 'Chrome for Testing' : `ffmpeg ${m.version ?? ''}`} icon={m.tool === 'chrome' ? 'globe' : 'image'} sub={m.installed ? t('deps.inAppData', { folder: m.folder }) : t('deps.notDownloaded')}>
|
||||
<Section key={m.tool} title={m.tool === 'chrome' ? 'Chrome for Testing' : `ffmpeg ${m.version ?? ''}`} icon={m.tool === 'chrome' ? 'globe' : 'image'} sub={m.installed ? t('deps.inAppData', { folder: m.folder }) : t('deps.notDownloaded')}>
|
||||
<dl className="kv">
|
||||
<dt>{t('deps.source')}</dt><dd className="selectable small">{m.source}</dd>
|
||||
<dt>{t('deps.license')}</dt><dd className="small">{m.license}</dd>
|
||||
<dt>{t('deps.verify')}</dt><dd className="small">{m.tool === 'chrome' ? t('deps.verifyChrome') : t('deps.verifyFfmpeg')}</dd>
|
||||
</dl>
|
||||
<div className="actions" style={{ marginTop: 12 }}><button className="btn btn-sm btn-ghost" onClick={() => open(m.licenseUrl)}><Icon name="external" />{t('deps.licenseLink')}</button></div>
|
||||
</Card>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
<Card title={t('deps.terminal')} icon="terminal" sub={t('deps.terminalSub')}>
|
||||
<Section title={t('deps.terminal')} icon="terminal" sub={t('deps.terminalSub')}>
|
||||
<div className="stack">
|
||||
{['storebound doctor', 'bun add -d playwright', 'brew install ffmpeg'].map(c => (
|
||||
<div className="cmd" key={c}><code className="selectable">{c}</code><button className="btn btn-sm btn-ghost btn-icon" onClick={() => copy(c)} aria-label={t('deps.copy', { cmd: c })}><Icon name="copy" /></button></div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import { withIgnored } from '../lib/identity';
|
|||
import { useNav } from '../nav';
|
||||
import { useData } from '../store';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { AppIcon, Card, Empty, StoreOnlyTag, StudioMark } from '../ui/kit';
|
||||
import { AppIcon, StoreOnlyTag, StudioMark } from '../ui/kit';
|
||||
import { EmptyState, PageHeader, Section } from '../ui/ds';
|
||||
import '../styles/folders.css';
|
||||
|
||||
export function Ignored() {
|
||||
const d = useData();
|
||||
|
|
@ -15,14 +17,11 @@ export function Ignored() {
|
|||
const restoreAll = () => d.updateOverrides(o => ({ ...o, ignored: [] }));
|
||||
const empty = !apps.length && !accounts.length;
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div><h1>{t('nav.ignored')}</h1><p>{t('ignored.lead')}</p></div>
|
||||
{!empty && <div className="actions"><button className="btn" onClick={restoreAll}><Icon name="eye" />{t('ignored.restoreAll')}</button></div>}
|
||||
</div>
|
||||
{empty ? <Card pad={false}><Empty compact illo="shield" title={t('ignored.emptyTitle')}>{t('ignored.emptyText')}</Empty></Card> : (
|
||||
<div className="page scr-ignored">
|
||||
<PageHeader icon="eyeOff" eyebrow={t('v2cfg.workspace')} title={t('nav.ignored')} subtitle={t('ignored.lead')} actions={!empty ? <><button className="btn" onClick={restoreAll}><Icon name="eye" />{t('ignored.restoreAll')}</button></> : undefined} />
|
||||
{empty ? <Section><EmptyState icon="shield" tone="ok"><b>{t('ignored.emptyTitle')}</b> {t('ignored.emptyText')}</EmptyState></Section> : (
|
||||
<>
|
||||
<Card title={t('ignored.apps')} icon="layers" sub={t('ignored.appsSub', { count: apps.length })} pad={false}>
|
||||
<Section title={t('ignored.apps')} icon="layers" sub={t('ignored.appsSub', { count: apps.length })} pad={false}>
|
||||
{apps.length ? (
|
||||
<div className="list">
|
||||
{apps.map(a => {
|
||||
|
|
@ -44,8 +43,8 @@ export function Ignored() {
|
|||
})}
|
||||
</div>
|
||||
) : <div className="card-body"><p className="small muted">{t('ignored.noApps')}</p></div>}
|
||||
</Card>
|
||||
<Card title={t('ignored.accounts')} icon="key" sub={t('ignored.accountsSub')} pad={false}>
|
||||
</Section>
|
||||
<Section title={t('ignored.accounts')} icon="key" sub={t('ignored.accountsSub')} pad={false}>
|
||||
{accounts.length ? (
|
||||
<div className="list">
|
||||
{accounts.map(a => (
|
||||
|
|
@ -57,7 +56,7 @@ export function Ignored() {
|
|||
))}
|
||||
</div>
|
||||
) : <div className="card-body"><p className="small muted">{t('ignored.noAccounts')}</p></div>}
|
||||
</Card>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
<p className="small muted">{t('ignored.howTo')} <button className="link" onClick={() => nav.go({ screen: 'settings', section: 'pairing' })}>{t('ignored.toPairing')}</button></p>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Icon, type IconName, Mark } from '../ui/icons';
|
|||
import { Illo } from '../ui/illustrations';
|
||||
import { AppIcon, Chip, Notice, Seg, StudioMark } from '../ui/kit';
|
||||
import { Swatches } from './Accounts';
|
||||
import '../styles/onboarding.css';
|
||||
|
||||
const STEPS: [Key, IconName][] = [['wz.s1', 'sparkle'], ['wz.s0', 'building'], ['wz.s2', 'folder'], ['wz.s3', 'key'], ['wz.s4', 'package'], ['wz.s5', 'refresh']];
|
||||
const LAST = STEPS.length - 1;
|
||||
|
|
@ -72,7 +73,7 @@ export function Onboarding({ onClose }: { onClose: () => void }) {
|
|||
const liveApps = d.live?.value.apps.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="wizard-scrim">
|
||||
<div className="wizard-scrim wz2">
|
||||
<div className="wizard" role="dialog" aria-modal="true" aria-labelledby="wz-title" ref={root} onKeyDown={onKey}>
|
||||
<aside className="wz-side">
|
||||
<div className="brand"><Mark className="brand-mark" /><span>storebound</span></div>
|
||||
|
|
@ -88,6 +89,12 @@ export function Onboarding({ onClose }: { onClose: () => void }) {
|
|||
<div className="foot"><span>{t('wz.footer')}</span><span className="row" style={{ gap: 6 }}><Icon name="shield" className="ic13" />{t('wz.footerSafe')}</span></div>
|
||||
</aside>
|
||||
<div className="wz-main">
|
||||
<div className="wz-progress">
|
||||
<span className="wz-eyebrow"><Icon name={STEPS[step]![1]} />{t('v2cfg.stepOf', { n: step + 1, total: STEPS.length })} · {t(STEPS[step]![0])}</span>
|
||||
<div className="wz-bar" role="progressbar" aria-label={t('v2cfg.progress')} aria-valuemin={0} aria-valuemax={STEPS.length} aria-valuenow={done.filter(Boolean).length}>
|
||||
{STEPS.map(([k], i) => <span key={k} className={i === step ? 'on' : done[i] ? 'done' : ''} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="wz-body">
|
||||
{step === 0 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
// local tools, the CLI sidecar, privacy, keyboard shortcuts, updates and reset. A section list on the left jumps.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useScrollSpy } from '../ui/useScrollSpy';
|
||||
import { PageHeader, Section } from '../ui/ds';
|
||||
import '../styles/settings.css';
|
||||
import { api, type CacheInfo, errorText, onUpdateProgress, type Settings as S, type UpdateInfo } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { ago, bytes, date, num } from '../lib/format';
|
||||
|
|
@ -11,7 +13,7 @@ import { useNav } from '../nav';
|
|||
import { usePrefs } from '../prefs-ctx';
|
||||
import { useData } from '../store';
|
||||
import { Icon, type IconName, StoreGlyph } from '../ui/icons';
|
||||
import { AppIcon, Card, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit';
|
||||
import { AppIcon, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit';
|
||||
|
||||
export const SECTIONS: [string, IconName, Key][] = [
|
||||
['studios', 'building', 'set.studios'], ['pairing', 'link', 'set.pairing'], ['refresh', 'refresh', 'set.refresh'], ['cache', 'database', 'set.cache'],
|
||||
|
|
@ -83,8 +85,8 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
const singles = storeOnly.filter(a => !(a.ios && a.android));
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph"><div><h1>{t('nav.settings')}</h1><p>{t('set.lead')}</p></div></div>
|
||||
<div className="page scr-settings">
|
||||
<PageHeader icon="settings" eyebrow={t('v2cfg.workspace')} title={t('nav.settings')} subtitle={t('set.lead')} />
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
<div className="set-layout">
|
||||
<nav className="set-nav" aria-label={t('set.sections')}>
|
||||
|
|
@ -93,7 +95,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
))}
|
||||
</nav>
|
||||
<div className="stack set-body" style={{ gap: 16 }}>
|
||||
<Card id="set-studios" title={t('set.studios')} icon="building" sub={t('set.studiosSub')} className="set-card" actions={<button className="btn btn-sm" onClick={() => nav.go({ screen: 'accounts' })}>{t('set.manage')}<Icon name="arrowRight" /></button>}>
|
||||
<Section id="set-studios" title={t('set.studios')} icon="building" sub={t('set.studiosSub')} className="set-card" actions={<button className="btn btn-sm" onClick={() => nav.go({ screen: 'accounts' })}>{t('set.manage')}<Icon name="arrowRight" /></button>}>
|
||||
{d.studios.length ? (
|
||||
<div className="studio-sum">
|
||||
{d.studios.map(st => {
|
||||
|
|
@ -107,20 +109,20 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
})}
|
||||
</div>
|
||||
) : <p className="small muted">{t('set.noStudios')}</p>}
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-pairing" title={t('set.pairing')} icon="link" sub={t('set.pairingSub')} pad={false} className="set-card">
|
||||
<Section id="set-pairing" title={t('set.pairing')} icon="link" sub={t('set.pairingSub')} pad={false} className="set-card">
|
||||
{storeOnly.length ? (
|
||||
<table className="table">
|
||||
<table className="table v2">
|
||||
<thead><tr><th>{t('col.app')}</th><th>{t('set.pairedHow')}</th><th className="right"><span className="sr">{t('col.actions')}</span></th></tr></thead>
|
||||
<tbody>{[...paired, ...singles].map(a => <PairRow key={a.key} app={a} all={d.apps} />)}</tbody>
|
||||
</table>
|
||||
) : <div className="card-body"><p className="small muted">{t('set.noStoreOnly')}</p></div>}
|
||||
<div className="card-foot"><Icon name="eyeOff" />{t('set.ignoredLine', { count: d.apps.filter(a => a.ignored).length, accounts: d.overrides.ignored.filter(i => i.startsWith('account:')).length })}
|
||||
<button className="link" onClick={() => nav.go({ screen: 'ignored' })}>{t('set.openIgnored')}</button></div>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-refresh" title={t('set.refresh')} icon="refresh" sub={t('set.refreshSub')} className="set-card">
|
||||
<Section id="set-refresh" title={t('set.refresh')} icon="refresh" sub={t('set.refreshSub')} className="set-card">
|
||||
<div className="form">
|
||||
<Toggle id="auto-read" label={t('set.autoRead')} hint={t('set.autoReadHint')} checked={s.refresh.autoRead} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoRead: v } }))} />
|
||||
<div className="field"><span className="legend">{t('set.everyMinutes')}</span>
|
||||
|
|
@ -138,9 +140,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
<span className="small muted">{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-cache" title={t('set.cache')} icon="database" sub={t('set.cacheSub')} className="set-card">
|
||||
<Section id="set-cache" title={t('set.cache')} icon="database" sub={t('set.cacheSub')} className="set-card">
|
||||
<div className="form">
|
||||
<dl className="kv">
|
||||
<dt>{t('set.cacheSize')}</dt><dd>{cache ? `${bytes(cache.file_bytes)} · ${t('set.cacheRows', { count: cache.rows })}` : '–'}</dd>
|
||||
|
|
@ -152,9 +154,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
<div className="actions"><button className="btn btn-sm" onClick={clear}><Icon name="trash" />{t('settings.clearCache')}</button></div>
|
||||
<p className="small muted">{t('settings.dataText')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-appearance" title={t('settings.appearance')} icon="sun" className="set-card">
|
||||
<Section id="set-appearance" title={t('settings.appearance')} icon="sun" className="set-card">
|
||||
<div className="form">
|
||||
<div className="field" role="group" aria-label={t('settings.language')}><span className="legend">{t('settings.language')}</span>
|
||||
<Seg value={prefs.lang} onChange={lang => update({ ...prefs, lang })} label={t('settings.language')} options={[['system', t('settings.system')], ['en', 'English'], ['tr', 'Türkçe']]} /></div>
|
||||
|
|
@ -163,9 +165,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
<div className="field" role="group" aria-label={t('settings.motion')}><span className="legend">{t('settings.motion')}</span>
|
||||
<Seg value={prefs.motion} onChange={motion => update({ ...prefs, motion })} label={t('settings.motion')} options={[['system', t('settings.followSystem')], ['reduce', t('settings.reduce')]]} /></div>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-notifications" title={t('set.notifications')} icon="bell" sub={t('set.notificationsSub')} className="set-card">
|
||||
<Section id="set-notifications" title={t('set.notifications')} icon="bell" sub={t('set.notificationsSub')} className="set-card">
|
||||
<div className="form">
|
||||
<Toggle id="n-rejected" label={t('set.nRejected')} hint={t('set.nRejectedHint')} checked={s.notify.rejected} onChange={v => set(x => ({ ...x, notify: { ...x.notify, rejected: v } }))} />
|
||||
<Toggle id="n-approved" label={t('set.nApproved')} hint={t('set.nApprovedHint')} checked={s.notify.approved} onChange={v => set(x => ({ ...x, notify: { ...x.notify, approved: v } }))} />
|
||||
|
|
@ -183,18 +185,18 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
<div className="actions"><button className="btn btn-sm" onClick={() => d.setMute(d.muteUntil > Date.now() ? 0 : 60)}><Icon name="bell" />{d.muteUntil > Date.now() ? t('set.unmute', { until: new Date(d.muteUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : t('set.muteHour')}</button></div>
|
||||
<div className="actions"><button className="btn btn-sm" onClick={() => api.notify(t('set.testTitle'), t('set.testBody')).catch(e => setError(errorText(e)))}><Icon name="bell" />{t('set.testNotify')}</button></div>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-dependencies" title={t('nav.dependencies')} icon="package" className="set-card" actions={<button className="btn btn-sm" onClick={() => nav.go({ screen: 'dependencies' })}>{t('set.manage')}<Icon name="arrowRight" /></button>}>
|
||||
<Section id="set-dependencies" title={t('nav.dependencies')} icon="package" className="set-card" actions={<button className="btn btn-sm" onClick={() => nav.go({ screen: 'dependencies' })}>{t('set.manage')}<Icon name="arrowRight" /></button>}>
|
||||
{tools ? (
|
||||
<div className="dep-sum">
|
||||
{tools.tools.map(x => <span key={x.id} className="row small" style={{ gap: 6 }}><i className={`dot ${x.found ? 'ok' : ''}`} />{x.name}{x.version ? <span className="muted num">{x.version}</span> : null}{x.source === 'app' && <Chip tone="outline">{t('set.appCopy')}</Chip>}</span>)}
|
||||
</div>
|
||||
) : <p className="small muted">–</p>}
|
||||
<p className="small muted" style={{ marginTop: 10 }}>{t('set.depsText')}</p>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-cli" title={t('set.cli')} icon="terminal" className="set-card">
|
||||
<Section id="set-cli" title={t('set.cli')} icon="terminal" className="set-card">
|
||||
<dl className="kv">
|
||||
<dt>{t('settings.app')}</dt><dd>{d.info ? `storebound desktop ${d.info.version}${d.info.debug ? ` (${t('settings.debug')})` : ''} · ${d.info.platform}` : '–'}</dd>
|
||||
<dt>CLI</dt><dd>{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? t('foot.cliStarting')}</dd>
|
||||
|
|
@ -202,9 +204,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
<dt>{t('set.state')}</dt><dd><span className="row" style={{ gap: 6 }}><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />{d.sidecar?.running ? t('set.running') : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}</span></dd>
|
||||
<dt>{t('set.writes')}</dt><dd>{t('set.writesText')}</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-privacy" title={t('set.privacy')} icon="shield" className="set-card">
|
||||
<Section id="set-privacy" title={t('set.privacy')} icon="shield" className="set-card">
|
||||
<ul className="plain-list">
|
||||
<li><Icon name="check" />{t('set.p1')}</li>
|
||||
<li><Icon name="check" />{t('set.p2')}</li>
|
||||
|
|
@ -215,9 +217,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
<button className="btn btn-sm" onClick={() => api.revealData().catch(e => setError(errorText(e)))}><Icon name="folder" />{t('set.reveal')}</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal('https://storebound.dev/').catch(e => setError(errorText(e)))}><Icon name="external" />storebound.dev</button>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-shortcuts" title={t('settings.shortcuts')} icon="keyboard" className="set-card">
|
||||
<Section id="set-shortcuts" title={t('settings.shortcuts')} icon="keyboard" className="set-card">
|
||||
<dl className="shortcuts">
|
||||
<dt>{t('settings.scPalette')}</dt><dd><Kbd keys={[MOD, 'K']} /></dd>
|
||||
<dt>{t('nav.portfolio')}</dt><dd><Kbd keys={[MOD, '1']} /></dd>
|
||||
|
|
@ -227,18 +229,18 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
<dt>{t('settings.scClose')}</dt><dd><Kbd keys={['Esc']} /></dd>
|
||||
<dt>{t('set.scQuit')}</dt><dd><Kbd keys={[MOD, 'Q']} /></dd>
|
||||
</dl>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-updates" title={t('set.updates')} icon="download" className="set-card">
|
||||
<Section id="set-updates" title={t('set.updates')} icon="download" className="set-card">
|
||||
<div className="form">
|
||||
<div className="field"><span className="legend">{t('set.channel')}</span>
|
||||
<Seg value={s.channel} label={t('set.channel')} onChange={channel => set(x => ({ ...x, channel }))} options={[['stable', t('set.stable')], ['beta', t('set.beta')]]} /></div>
|
||||
<Updates />
|
||||
<p className="small muted">{t('set.updatesText')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Card id="set-reset" title={t('set.reset')} icon="rotate" className="set-card danger-card">
|
||||
<Section id="set-reset" title={t('set.reset')} icon="rotate" className="set-card danger-card">
|
||||
<div className="stack">
|
||||
<p className="small muted">{t('set.resetText', { accounts: d.accounts.length, studios: d.studios.length, folders: d.projects.length })}</p>
|
||||
<div className="actions">
|
||||
|
|
@ -246,7 +248,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
<button className="btn btn-sm btn-danger" onClick={reset}><Icon name="rotate" />{t('set.resetButton')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
<p className="small muted">{t('set.footer', { n: num(d.apps.length) })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { type Key, t } from '../../i18n';
|
|||
import { AGE_FREQUENCY, AGE_RATING_NONE, APPLE_CATEGORIES, APPLE_GAME_SUBCATEGORIES, KIDS_AGE_BANDS, PLAY_AGE_GROUPS, PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from '../../../../../cli/lib/store-catalogs';
|
||||
import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, TextField, useEdits } from '../../ui/Editor';
|
||||
import { StoreGlyph } from '../../ui/icons';
|
||||
import { Card } from '../../ui/kit';
|
||||
import { Section } from '../../ui/ds';
|
||||
import '../../styles/appinfo.css';
|
||||
|
||||
type O = Record<string, unknown>;
|
||||
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
|
||||
|
|
@ -30,7 +31,7 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult }
|
|||
const contact = data.contact;
|
||||
return (
|
||||
<>
|
||||
<Card title={t('ed.appleInfoTitle')} icon="clipboard" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.appleInfoSub')}</span>}>
|
||||
<Section title={t('ed.appleInfoTitle')} icon="clipboard" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.appleInfoSub')}</span>}>
|
||||
<div className="ed-grid">
|
||||
<ChoiceField project={project} area="appinfo" path="apple.info.categories.primary" saved={str(cats.primary)} label={t('ed.catPrimary')} options={catOpts} />
|
||||
{games && <ChoiceField project={project} area="appinfo" path="apple.info.categories.primarySubOne" saved={str(cats.primarySubOne)} label={t('ed.catSub1')} options={subOpts} />}
|
||||
|
|
@ -43,8 +44,8 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult }
|
|||
options={[['false', t('ai.encNo')], ['true', t('ai.encYes')]]} hint={t('ed.encHint')} />
|
||||
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'apple.name' }} saved={str(apple.name)} label={t('ed.appleName')} limit={30} placeholder={data.appName} hint={t('ed.appleNameHint')} />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('ed.ageTitle')} icon="shield" sub={t('ed.ageSub')}>
|
||||
</Section>
|
||||
<Section title={t('ed.ageTitle')} icon="shield" sub={t('ed.ageSub')}>
|
||||
<div className="ed-grid three">
|
||||
{Object.entries(AGE_RATING_NONE).map(([q, none]) => (
|
||||
<ChoiceField key={q} project={project} area="appinfo" path={`apple.info.ageRating.${q}`} saved={age[q] === undefined ? null : (age[q] as string | boolean | null)} label={question(q)}
|
||||
|
|
@ -52,8 +53,8 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult }
|
|||
hint={age[q] === undefined ? t('ed.ageDefault', { value: none === null ? t('ed.none') : typeof none === 'boolean' ? t('common.no') : label(String(none)) }) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('ai.reviewTitle')} icon="users" sub={t('ed.reviewSub')}>
|
||||
</Section>
|
||||
<Section title={t('ai.reviewTitle')} icon="users" sub={t('ed.reviewSub')}>
|
||||
<div className="ed-grid">
|
||||
<ChoiceField project={project} area="appinfo" path="reviewAccess.signInRequired" saved={ra.signInRequired === undefined ? null : (ra.signInRequired as boolean)} label={t('ai.signIn')} options={yesNo()} />
|
||||
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'apple.info.reviewNotes' }} saved={str(info.reviewNotes)} label={t('ai.notes')} multiline rows={5}
|
||||
|
|
@ -65,7 +66,7 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult }
|
|||
))}
|
||||
<div className="ed-field"><div className="ed-head"><label>{t('ai.demo')}</label></div><span className="mono-id">{data.demoAccount ? `${data.demoAccount} · ••••••••` : t('ed.notSet')}</span><div className="ed-hint small muted">{t('ed.demoHint')}</div></div>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -75,7 +76,7 @@ function PlaySettings({ project, data }: { project: Project; data: EditableResul
|
|||
const acc = obj(data.config.accounts);
|
||||
const cats: [string, string][] = [...PLAY_GAME_CATEGORIES.map(c => [c, `${t('ed.game')}: ${c}`] as [string, string]), ...PLAY_APP_CATEGORIES.map(c => [c, `${t('ed.app')}: ${c}`] as [string, string])];
|
||||
return (
|
||||
<Card title={t('ed.playSettingsTitle')} icon="sliders" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('ed.playSettingsSub')}</span>}>
|
||||
<Section title={t('ed.playSettingsTitle')} icon="sliders" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('ed.playSettingsSub')}</span>}>
|
||||
<div className="ed-grid">
|
||||
<ChoiceField project={project} area="appinfo" path="play.category" saved={str(play.category)} label={t('ed.playCategory')} options={cats} hint={t('ed.noApiHint')} />
|
||||
<ChoiceField project={project} area="appinfo" path="play.managedPublishing" saved={play.managedPublishing === undefined ? null : (play.managedPublishing as boolean)} label={t('ed.managed')} options={yesNo()} hint={t('ed.noApiHint')} />
|
||||
|
|
@ -83,14 +84,14 @@ function PlaySettings({ project, data }: { project: Project; data: EditableResul
|
|||
<ListField project={project} area="appinfo" path="play.targetAges" saved={(play.targetAges as string[] | undefined) ?? null} label={t('ed.targetAges')} hint={t('ed.targetAgesHint', { groups: PLAY_AGE_GROUPS.join(', ') })} />
|
||||
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'accounts.deletionUrl' }} saved={str(acc.deletionUrl)} label={t('ed.deletionUrl')} kind="url" mono />
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Declarations({ project, data }: { project: Project; data: EditableResult }) {
|
||||
const ds = obj(data.config.dataSafety);
|
||||
return (
|
||||
<Card title={t('ed.declTitle')} icon="lock" sub={t('ed.declSub')}>
|
||||
<Section title={t('ed.declTitle')} icon="lock" sub={t('ed.declSub')}>
|
||||
<div className="ed-grid">
|
||||
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.collectsData" saved={ds.collectsData === undefined ? null : (ds.collectsData as boolean)} label={t('ai.dsCollects')} options={yesNo()} />}
|
||||
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.encryptedInTransit" saved={ds.encryptedInTransit === undefined ? null : (ds.encryptedInTransit as boolean)} label={t('ai.dsEncrypted')} options={yesNo()} />}
|
||||
|
|
@ -101,14 +102,14 @@ function Declarations({ project, data }: { project: Project; data: EditableResul
|
|||
{data.play && <JsonField project={project} area="appinfo" path="console.contentRating" saved={obj(data.config.console).contentRating ?? null} label={t('ed.contentRating')} rows={6} hint={t('ed.contentRatingHint')} />}
|
||||
{data.play && <JsonField project={project} area="appinfo" path="console.targetAudience" saved={obj(data.config.console).targetAudience ?? null} label={t('ed.targetAudience')} rows={4} hint={t('ed.noApiHint')} />}
|
||||
{data.play && <JsonField project={project} area="appinfo" path="console.appContent" saved={obj(data.config.console).appContent ?? null} label={t('ed.appContent')} rows={4} hint={t('ed.appContentHint')} />}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Promotion({ project, data }: { project: Project; data: EditableResult }) {
|
||||
const apple = obj(data.config.apple);
|
||||
return (
|
||||
<Card title={t('ed.promoTitle')} icon="sparkle" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.promoSub')}</span>}>
|
||||
<Section title={t('ed.promoTitle')} icon="sparkle" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.promoSub')}</span>}>
|
||||
<JsonField project={project} area="appinfo" path="apple.promoted" saved={apple.promoted ?? null} label={t('ed.promoted')} rows={6} hint={t('ed.promotedHint')} />
|
||||
<JsonField project={project} area="appinfo" path="apple.customProductPages" saved={apple.customProductPages ?? null} label={t('ed.cpp')} rows={8} hint={t('ed.cppHint')} />
|
||||
<JsonField project={project} area="appinfo" path="apple.events" saved={apple.events ?? null} label={t('ed.events')} rows={8} hint={t('ed.eventsHint')} />
|
||||
|
|
@ -116,7 +117,7 @@ function Promotion({ project, data }: { project: Project; data: EditableResult }
|
|||
<ApplyPanel project={project} method="apple.cpp_create" label={t('ed.applyCpp')} done={t('ai.pushed')} blocked={null} />
|
||||
<ApplyPanel project={project} method="apple.events_create" label={t('ed.applyEvents')} done={t('ai.pushed')} blocked={null} />
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -124,13 +125,13 @@ export function AppInfoEditor({ project, data, onSaved }: { project: Project; da
|
|||
const e = useEdits();
|
||||
const unsaved = e.count(project.id, 'appinfo') ? t('ed.saveFirst') : null;
|
||||
return (
|
||||
<div className="stack" id="appinfo-editor" style={{ gap: 16 }}>
|
||||
<div className="stack scr-appinfo-ed" id="appinfo-editor" style={{ gap: 20 }}>
|
||||
{data.apple && <AppleInfo project={project} data={data} />}
|
||||
{data.play && <PlaySettings project={project} data={data} />}
|
||||
<Declarations project={project} data={data} />
|
||||
{data.apple && <Promotion project={project} data={data} />}
|
||||
<EditBar project={project} area="appinfo" data={data} files={[data.files.config, data.files.contact]} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyInfoSub')}>
|
||||
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyInfoSub')}>
|
||||
<div className="act-grid">
|
||||
{data.apple && (
|
||||
<div className="act-col">
|
||||
|
|
@ -149,7 +150,7 @@ export function AppInfoEditor({ project, data, onSaved }: { project: Project; da
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
// Store art editor (Store art tab): the order of the screenshot scenes (the store shows them in this order) and the
|
||||
// raw frame each scene shows (replace a screenshot by pointing the scene at another frame). Saved into the config;
|
||||
// then render, and upload to each store with the dry run in the window (uploads replace the store's sets).
|
||||
import '../../styles/art.css';
|
||||
import { useState } from 'react';
|
||||
import { api, errorText, type EditableResult, type Project } from '../../api';
|
||||
import { useData, useWrite } from '../../store';
|
||||
import { t } from '../../i18n';
|
||||
import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Empty } from '../../ui/kit';
|
||||
import { Empty } from '../../ui/kit';
|
||||
import { Section } from '../../ui/ds';
|
||||
import { keyOf } from '../../lib/edits';
|
||||
|
||||
type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Record<string, unknown>;
|
||||
|
|
@ -33,7 +35,7 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
|
|||
const p = e.get(project.id, keyOf(op));
|
||||
const scenes = (p && p.op.kind === 'config' ? p.op.value : saved) as Scene[];
|
||||
const unsaved = e.count(project.id, 'art') ? t('ed.saveFirst') : null;
|
||||
if (!saved.length) return <Card title={t('ed.artTitle')} icon="image"><Empty compact illo="text" title={t('ed.artEmpty')}>{t('ed.artEmptyText')}</Empty></Card>;
|
||||
if (!saved.length) return <Section className="scr-art" title={t('ed.artTitle')} icon="image"><Empty compact illo="text" title={t('ed.artEmpty')}>{t('ed.artEmptyText')}</Empty></Section>;
|
||||
const move = (i: number, d: -1 | 1) => {
|
||||
const next = [...scenes];
|
||||
const j = i + d;
|
||||
|
|
@ -42,8 +44,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
|
|||
e.set(project.id, { ...op, value: next }, 'art', saved);
|
||||
};
|
||||
return (
|
||||
<div className="stack" id="art-editor" style={{ gap: 16 }}>
|
||||
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}
|
||||
<div className="stack scr-art art-editor" id="art-editor" style={{ gap: 16 }}>
|
||||
<Section title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}
|
||||
actions={langs.length > 1 ? <label className="row small" style={{ gap: 6 }}>{t('ed.frameLang')}<select value={fl} onChange={ev => setFrameLang(ev.target.value)} data-frame-lang>{langs.map(l => <option key={l} value={l}>{l}</option>)}</select></label> : undefined}>
|
||||
<ol className="ed-scenes">
|
||||
{scenes.map((s, i) => (
|
||||
|
|
@ -61,16 +63,16 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
|
|||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Card>
|
||||
</Section>
|
||||
{data.shotsCopy && <Captions project={project} data={data} scenes={scenes.map(s => s.id)} />}
|
||||
<EditBar project={project} area="art" data={data} files={[data.files.config, data.files.shotsCopy ?? null]} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyArtSub')}>
|
||||
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyArtSub')}>
|
||||
<div className="act-grid">
|
||||
<div className="act-col"><div className="act-head"><Icon name="wand" />{t('act.render')}</div><ApplyPanel project={project} method="shots.render" label={t('act.renderShots')} done={t('act.rendered')} blocked={unsaved} /></div>
|
||||
{data.play && <div className="act-col"><div className="act-head"><StoreGlyph store="android" />Google Play</div><ApplyPanel project={project} method="play.images" label={t('act.playImages')} done={t('act.uploaded')} blocked={unsaved} /></div>}
|
||||
{data.apple && <div className="act-col"><div className="act-head"><StoreGlyph store="ios" />App Store</div><ApplyPanel project={project} method="apple.screenshots" label={t('act.appleShots')} done={t('act.uploaded')} blocked={unsaved} /><ApplyPanel project={project} method="apple.previews" label={t('act.applePreviews')} done={t('act.uploaded')} blocked={unsaved} /></div>}
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -82,7 +84,7 @@ function Captions({ project, data, scenes }: { project: Project; data: EditableR
|
|||
const [lang, setLang] = useState(langs[0] ?? '');
|
||||
const L = langs.includes(lang) ? lang : langs[0] ?? '';
|
||||
return (
|
||||
<Card title={t('ed.captionsTitle')} icon="text" sub={t('ed.captionsSub', { file: data.files.shotsCopy ?? '' })} id="captions-editor">
|
||||
<Section title={t('ed.captionsTitle')} icon="text" sub={t('ed.captionsSub', { file: data.files.shotsCopy ?? '' })} id="captions-editor">
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap', marginBottom: 12 }} role="tablist" aria-label={t('col.language')}>
|
||||
{langs.map(x => <button key={x} role="tab" aria-selected={x === L} className={`btn btn-sm${x === L ? ' btn-primary' : ''}`} onClick={() => setLang(x)}><Icon name="globe" />{x}</button>)}
|
||||
</div>
|
||||
|
|
@ -103,6 +105,6 @@ function Captions({ project, data, scenes }: { project: Project; data: EditableR
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
// offer blocks (introductory and promotional offers, offer codes, win-back) as checked JSON. Saved into the config,
|
||||
// then applied per store with the dry run in the window. A price change of a product that is already live goes
|
||||
// through the owner's price decision (the Catalog tab's "Apply the price changes" owner actions).
|
||||
import '../../styles/catalog.css';
|
||||
import { useState } from 'react';
|
||||
import type { EditableResult, Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, NumberField, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty } from '../../ui/kit';
|
||||
import { Chip, Empty } from '../../ui/kit';
|
||||
import { Section } from '../../ui/ds';
|
||||
|
||||
type O = Record<string, unknown>;
|
||||
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
|
||||
|
|
@ -44,8 +46,8 @@ function ProductCard({ project, data, p }: { project: Project; data: EditableRes
|
|||
return (
|
||||
<div className="ed-item" data-item={String(p.id)}>
|
||||
<button className="ed-item-head" onClick={() => setOpen(o => !o)} aria-expanded={open}>
|
||||
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="mono-id strong">{String(p.id)}</span><Chip tone="outline">{String(p.type)}</Chip>
|
||||
<span className="num small muted">${String(p.usd)}</span>{n > 0 && <Chip tone="accent" icon="edit">{t('ed.unsaved', { count: n })}</Chip>}
|
||||
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="cat-ic sm" aria-hidden="true"><Icon name="tag" /></span><span className="mono-id strong">{String(p.id)}</span><Chip tone="outline">{String(p.type)}</Chip>
|
||||
<span className="num cat-price">${String(p.usd)}</span>{n > 0 && <Chip tone="accent" icon="edit">{t('ed.unsaved', { count: n })}</Chip>}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ed-item-body stack">
|
||||
|
|
@ -71,7 +73,7 @@ function SubCard({ project, data, group, s }: { project: Project; data: Editable
|
|||
return (
|
||||
<div className="ed-item" data-item={String(s.id)}>
|
||||
<button className="ed-item-head" onClick={() => setOpen(o => !o)} aria-expanded={open}>
|
||||
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="mono-id strong">{String(s.id)}</span><Chip tone="outline">{String(s.period)}</Chip><span className="num small muted">${String(s.usd)}</span>
|
||||
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="cat-ic sm" aria-hidden="true"><Icon name="refresh" /></span><span className="mono-id strong">{String(s.id)}</span><Chip tone="outline">{String(s.period)}</Chip><span className="num cat-price">${String(s.usd)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ed-item-body stack">
|
||||
|
|
@ -99,17 +101,17 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da
|
|||
const groups = data.config.subscriptions as O[];
|
||||
const unsaved = e.count(project.id, 'catalog') ? t('ed.saveFirst') : null;
|
||||
if (!products.length && !groups.length) {
|
||||
return <Card title={t('ed.catalogTitle')} icon="edit"><Empty compact illo="catalog" title={t('ed.catalogEmpty')}>{t('ed.catalogEmptyText')}</Empty></Card>;
|
||||
return <Section className="scr-catalog" title={t('ed.catalogTitle')} icon="edit"><Empty compact illo="catalog" title={t('ed.catalogEmpty')}>{t('ed.catalogEmptyText')}</Empty></Section>;
|
||||
}
|
||||
return (
|
||||
<div className="stack" id="catalog-editor" style={{ gap: 16 }}>
|
||||
<div className="stack scr-catalog cat-editor" id="catalog-editor" style={{ gap: 16 }}>
|
||||
{products.length > 0 && (
|
||||
<Card title={t('ed.productsTitle')} icon="edit" sub={t('ed.productsSub')} pad={false}>
|
||||
<Section title={t('ed.productsTitle')} icon="edit" sub={t('ed.productsSub')} pad={false}>
|
||||
<div className="ed-items">{products.map(p => <ProductCard key={String(p.id)} project={project} data={data} p={p} />)}</div>
|
||||
</Card>
|
||||
</Section>
|
||||
)}
|
||||
{groups.map(g => (
|
||||
<Card key={String(g.name)} title={t('ed.groupTitle', { name: String(g.name) })} icon="layers" sub={t('ed.groupSub')} pad={false}>
|
||||
<Section key={String(g.name)} title={t('ed.groupTitle', { name: String(g.name) })} icon="layers" sub={t('ed.groupSub')} pad={false}>
|
||||
<div className="card-body">
|
||||
<div className="ed-langs">
|
||||
{data.languages.map(l => (
|
||||
|
|
@ -120,10 +122,10 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da
|
|||
</div>
|
||||
</div>
|
||||
<div className="ed-items">{(g.subscriptions as O[]).map(s => <SubCard key={String(s.id)} project={project} data={data} group={String(g.name)} s={s} />)}</div>
|
||||
</Card>
|
||||
</Section>
|
||||
))}
|
||||
<EditBar project={project} area="catalog" data={data} files={[data.files.config]} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyCatalogSub')}>
|
||||
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyCatalogSub')}>
|
||||
<div className="act-grid">
|
||||
{data.play && (
|
||||
<div className="act-col">
|
||||
|
|
@ -142,7 +144,7 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da
|
|||
)}
|
||||
</div>
|
||||
<p className="small muted act-note"><Icon name="shield" />{t('ed.priceGate')}</p>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// counters against the store limits and the checks of `check --aso` / `scan` as the owner types; the shared store
|
||||
// details (URLs, contact, copyright, promo video); Save into store/listing.md, store/appstore.md and the config
|
||||
// (project.edit), Pull the live texts into the files (diff first), and Apply to each store (dry run in the window).
|
||||
import '../../styles/listings.css';
|
||||
import { useState } from 'react';
|
||||
import type { EditableListing, EditableResult, Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
|
|
@ -10,7 +11,8 @@ import { keyOf } from '../../lib/edits';
|
|||
import { useData, useWrite } from '../../store';
|
||||
import { ApplyPanel, EditBar, type FieldIssueView, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Notice } from '../../ui/kit';
|
||||
import { Chip, Notice } from '../../ui/kit';
|
||||
import { Section } from '../../ui/ds';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
||||
const PLAY: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [
|
||||
|
|
@ -30,6 +32,30 @@ function current(e: ReturnType<typeof useEdits>, project: Project, store: 'play'
|
|||
}
|
||||
return out;
|
||||
}
|
||||
/** The texts of one language as the store page shows them: icon, title, subtitle / short description, promo text and
|
||||
* the first lines of the description (live, with the unsaved edits). */
|
||||
export function StorePreview({ store, vals, name }: { store: 'play' | 'apple'; vals: Record<string, string>; name: string }) {
|
||||
const title = (store === 'play' ? vals.title : vals.name) || name;
|
||||
const sub = store === 'play' ? vals.short : vals.subtitle;
|
||||
const promo = store === 'apple' ? vals.promotionalText : '';
|
||||
const desc = store === 'play' ? vals.full : vals.description;
|
||||
return (
|
||||
<div className={`lst-preview ${store}`} role="group" aria-label={t('v2lst.preview')} data-preview={store}>
|
||||
<span className="lst-pv-eyebrow">{t('v2lst.preview')}</span>
|
||||
<div className="lst-pv-top">
|
||||
<span className="lst-pv-icon" aria-hidden="true">{(title || '?').trim().slice(0, 1).toUpperCase()}</span>
|
||||
<div className="lst-pv-names">
|
||||
<div className="lst-pv-title">{title || '–'}</div>
|
||||
{sub ? <div className="lst-pv-sub">{sub}</div> : <div className="lst-pv-sub muted">{t('v2lst.noSub')}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{promo && <p className="lst-pv-promo">{promo}</p>}
|
||||
{desc ? <p className="lst-pv-desc">{desc}</p> : <p className="lst-pv-desc muted">{t('v2lst.noDesc')}</p>}
|
||||
{desc && desc.length > 180 && <span className="lst-pv-more" aria-hidden="true">{t('v2lst.more')}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const byField = (issues: FieldIssue[], f: string): FieldIssueView[] => issues.filter(i => i.field === f).map(i => ({ level: i.level, msg: i.msg }));
|
||||
|
||||
function StoreColumn({ project, data, store, l }: { project: Project; data: EditableResult; store: 'play' | 'apple'; l: EditableListing }) {
|
||||
|
|
@ -48,6 +74,7 @@ function StoreColumn({ project, data, store, l }: { project: Project; data: Edit
|
|||
{l.status && <Chip tone="warn">{l.status === 'draft' ? t('ed.statusDraft') : t('ed.statusTranslate')}</Chip>}
|
||||
{(l.codes?.length ?? 0) > 1 && <Chip tone="outline">{t('ed.alsoServes', { codes: l.codes!.filter(c => c !== l.lang).join(', ') })}</Chip>}
|
||||
</div>
|
||||
<StorePreview store={store} vals={vals} name={store === 'play' ? data.appName : data.appleName} />
|
||||
{fresh && <Notice tone="info" icon="info">{t('ed.newSection', { lang: l.lang })}</Notice>}
|
||||
{defs.map(d => (
|
||||
<TextField key={d.f} project={project} area="listings" op={{ kind: 'listing', store, lang: l.lang, field: d.f }} saved={l.values[d.f] ?? ''}
|
||||
|
|
@ -101,15 +128,15 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da
|
|||
const okVersion = /^\d+(\.\d+){0,2}$/.test(version);
|
||||
const files = [data.files.play, data.files.apple, data.files.config];
|
||||
return (
|
||||
<>
|
||||
<Card title={t('ed.listingTitle')} icon="edit" sub={t('ed.listingSub')} id="listing-editor"
|
||||
<div className="scr-listings lst-editor">
|
||||
<Section title={t('ed.listingTitle')} icon="edit" sub={t('ed.listingSub')} id="listing-editor"
|
||||
actions={<>
|
||||
{data.play && <WriteButton run={w} icon="download" w={{ method: 'play.listing_pull', args: {}, title: t('ed.pullPlay'), done: t('ed.pulled'), after: onSaved }} hint={unsaved ? t('ed.pullDirty') : t('ed.pullHint')}>{t('ed.pullPlay')}</WriteButton>}
|
||||
{data.apple && <WriteButton run={w} icon="download" w={{ method: 'apple.listing_pull', args: {}, title: t('ed.pullApple'), done: t('ed.pulled'), after: onSaved }} hint={unsaved ? t('ed.pullDirty') : t('ed.pullHint')}>{t('ed.pullApple')}</WriteButton>}
|
||||
</>}>
|
||||
<div className="stack" style={{ gap: 12 }}>
|
||||
{unsaved > 0 && <Notice tone="info" icon="info">{t('ed.pullNote')}</Notice>}
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }} role="tablist" aria-label={t('col.language')}>
|
||||
<div className="row lst-langbar" style={{ gap: 8, flexWrap: 'wrap' }} role="tablist" aria-label={t('col.language')}>
|
||||
{data.languages.map(x => {
|
||||
const n = e.list(project.id, 'listings').filter(p => p.op.kind === 'listing' && (p.op.lang === x.apple || x.play.includes(p.op.lang))).length;
|
||||
return (
|
||||
|
|
@ -132,12 +159,12 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da
|
|||
{!pl && !al && <Notice tone="warn">{t('ed.noStoreForLang', { lang: L?.id ?? '' })}</Notice>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('ed.detailsTitle')} icon="link" sub={t('ed.detailsSub')}>
|
||||
</Section>
|
||||
<Section title={t('ed.detailsTitle')} icon="link" sub={t('ed.detailsSub')}>
|
||||
<StoreDetails project={project} data={data} />
|
||||
</Card>
|
||||
</Section>
|
||||
<EditBar project={project} area="listings" data={data} files={files} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applySub')}>
|
||||
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applySub')}>
|
||||
<div className="act-grid">
|
||||
{data.play && (
|
||||
<div className="act-col">
|
||||
|
|
@ -157,7 +184,7 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da
|
|||
)}
|
||||
</div>
|
||||
<p className="small muted act-note"><Icon name="shield" />{t('ed.applyNote')}</p>
|
||||
</Card>
|
||||
</>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// (listing.live), edited in place with the same counters and checks, and applied straight to the store per language
|
||||
// (listing.set: the store's dry run in the window, the native dialog, then the real run, which changes only the edited
|
||||
// fields). URLs, contact and app details stay with an app folder (they come from its config).
|
||||
import '../../styles/listings.css';
|
||||
import { useState } from 'react';
|
||||
import type { Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
|
|
@ -12,7 +13,9 @@ import { keyOf } from '../../lib/edits';
|
|||
import { useRead } from '../../store';
|
||||
import { ApplyPanel, type FieldIssueView, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { Section } from '../../ui/ds';
|
||||
import { StorePreview } from './ListingEditor';
|
||||
|
||||
const PLAY: { f: 'title' | 'short' | 'full'; label: Key; limit: number; rows?: number }[] = [
|
||||
{ f: 'title', label: 'lst.title', limit: 30 }, { f: 'short', label: 'lst.short', limit: 80 }, { f: 'full', label: 'lst.full', limit: 4000, rows: 10 },
|
||||
|
|
@ -41,6 +44,7 @@ function Column({ project, store, lang, saved, appName, editable }: { project: P
|
|||
return (
|
||||
<div className="ed-col" data-store={store} data-lang={lang}>
|
||||
<div className="act-head"><StoreGlyph store={store === 'play' ? 'android' : 'ios'} />{store === 'play' ? 'Google Play' : 'App Store'}<span className="mono-id small muted">{lang}</span></div>
|
||||
<StorePreview store={store} vals={vals} name={appName} />
|
||||
{store === 'apple' && !editable && <Notice tone="info" icon="info">{t('ed.noEditableVersion')}</Notice>}
|
||||
{defs.map(d => (
|
||||
<TextField key={d.f} project={project} area="listings" op={{ kind: 'listing', store, lang, field: d.f }} saved={saved[d.f] ?? ''} label={t(d.label)} limit={d.limit}
|
||||
|
|
@ -60,8 +64,8 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) {
|
|||
// the window's edit store is keyed by app: a store-only app is keyed by its store target
|
||||
const project = { id: app.target, path: '', name: app.name, playAccount: null, appleAccount: null, added: '' } as Project;
|
||||
if (!r.data) {
|
||||
if (r.loading && !r.error) return <Card><Skeleton lines={6} block /></Card>;
|
||||
return <Card pad={false}><Empty illo="text" title={t('ed.liveEmpty')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('tf.read')}</button>}>{r.error ?? t('ed.storeOnlyNote')}</Empty></Card>;
|
||||
if (r.loading && !r.error) return <div className="scr-listings"><Section><Skeleton lines={6} block /></Section></div>;
|
||||
return <div className="scr-listings"><Section pad={false}><Empty illo="text" title={t('ed.liveEmpty')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('tf.read')}</button>}>{r.error ?? t('ed.storeOnlyNote')}</Empty></Section></div>;
|
||||
}
|
||||
const L = r.data;
|
||||
const langs = [...new Set([...(L.play ?? []).map(x => x.language), ...(L.apple?.locales ?? []).map(x => x.locale)])].sort();
|
||||
|
|
@ -69,12 +73,12 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) {
|
|||
const pl = L.play?.find(x => x.language === cur);
|
||||
const al = L.apple?.locales.find(x => x.locale === cur);
|
||||
return (
|
||||
<>
|
||||
<div className="scr-listings lst-editor">
|
||||
{Object.entries(L.errors).map(([s, e]) => <ErrorNotice key={s} error={`${s === 'play' ? 'Google Play' : 'App Store'}: ${e}`} />)}
|
||||
<Card title={t('ed.storeOnlyTitle')} icon="edit" sub={t('ed.storeOnlySub')} actions={<ReadControl r={r} />} id="store-listing-editor">
|
||||
<Section title={t('ed.storeOnlyTitle')} icon="edit" sub={t('ed.storeOnlySub')} actions={<ReadControl r={r} />} id="store-listing-editor">
|
||||
<div className="stack" style={{ gap: 12 }}>
|
||||
<Notice tone="info" icon="info">{t('ed.storeOnlyNote')}</Notice>
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }} role="tablist" aria-label={t('col.language')}>
|
||||
<div className="row lst-langbar" style={{ gap: 8, flexWrap: 'wrap' }} role="tablist" aria-label={t('col.language')}>
|
||||
{langs.map(x => <button key={x} role="tab" aria-selected={x === cur} className={`btn btn-sm${x === cur ? ' btn-primary' : ''}`} onClick={() => setLang(x)} data-lang-tab={x}><Icon name="globe" />{x}</button>)}
|
||||
</div>
|
||||
<div className="ed-cols">
|
||||
|
|
@ -83,7 +87,7 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) {
|
|||
{!pl && !al && <Notice tone="warn">{t('ed.noStoreForLang', { lang: cur })}</Notice>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { t } from '../../i18n';
|
|||
import { edits } from '../../lib/edits';
|
||||
import type { AppTab } from '../../nav';
|
||||
import { useEditable } from '../../ui/Editor';
|
||||
import { Card, ErrorNotice, Skeleton } from '../../ui/kit';
|
||||
import { ErrorNotice, Skeleton } from '../../ui/kit';
|
||||
import { Section } from '../../ui/ds';
|
||||
import { AppInfoEditor } from './AppInfoEditor';
|
||||
import { ArtEditor } from './ArtEditor';
|
||||
import { CatalogEditor } from './CatalogEditor';
|
||||
|
|
@ -21,7 +22,7 @@ export const EDIT_TABS: Partial<Record<AppTab, 'listings' | 'appinfo' | 'catalog
|
|||
export function TabEditor({ tab, project }: { tab: AppTab; project: Project }) {
|
||||
const r = useEditable(project);
|
||||
if (!EDIT_TABS[tab]) return null;
|
||||
if (!r.data) return r.error ? <ErrorNotice error={t('ed.loadFailed', { error: r.error })} tone="bad" /> : <Card><Skeleton lines={4} block /></Card>;
|
||||
if (!r.data) return r.error ? <ErrorNotice error={t('ed.loadFailed', { error: r.error })} tone="bad" /> : <Section><Skeleton lines={4} block /></Section>;
|
||||
const p = { project, data: r.data, onSaved: () => { r.refresh(); } };
|
||||
switch (tab) {
|
||||
case 'listings': return <ListingEditor {...p} />;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
import type { EditableResult, Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ApplyPanel, EditBar, JsonField, TextField, useEdits } from '../../ui/Editor';
|
||||
import { Card } from '../../ui/kit';
|
||||
import { Section } from '../../ui/ds';
|
||||
import '../../styles/testflight.css';
|
||||
|
||||
type O = Record<string, unknown>;
|
||||
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
|
||||
|
|
@ -17,8 +18,8 @@ export function TestFlightEditor({ project, data, onSaved }: { project: Project;
|
|||
const unsaved = e.count(project.id, 'testflight') ? t('ed.saveFirst') : null;
|
||||
if (!data.apple) return null;
|
||||
return (
|
||||
<div className="stack" id="testflight-editor" style={{ gap: 16 }}>
|
||||
<Card title={t('ed.tfTitle')} icon="edit" sub={t('ed.tfSub')}>
|
||||
<div className="stack scr-testflight-ed" id="testflight-editor" style={{ gap: 16 }}>
|
||||
<Section title={t('ed.tfTitle')} icon="edit" sub={t('ed.tfSub')}>
|
||||
<div className="ed-langs">
|
||||
{data.apple.map(l => (
|
||||
<div className="ed-lang" key={l.lang}>
|
||||
|
|
@ -35,14 +36,14 @@ export function TestFlightEditor({ project, data, onSaved }: { project: Project;
|
|||
{typeof desc === 'string' && <TextField project={project} area="testflight" op={{ kind: 'config', path: 'apple.testflight.description' }} saved={desc} label={t('ed.betaDescriptionAll')} limit={4000} multiline rows={3} />}
|
||||
<TextField project={project} area="testflight" op={{ kind: 'config', path: 'apple.testflight.reviewNotes' }} saved={typeof tf.reviewNotes === 'string' ? tf.reviewNotes : ''} label={t('ed.betaNotes')} limit={4000} multiline rows={3} />
|
||||
<JsonField project={project} area="testflight" path="apple.testflight.groups" saved={tf.groups ?? null} label={t('ed.tfGroups')} rows={6} hint={t('ed.tfGroupsHint')} />
|
||||
</Card>
|
||||
</Section>
|
||||
<EditBar project={project} area="testflight" data={data} files={[data.files.apple, data.files.config]} onSaved={onSaved} />
|
||||
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyTfSub')}>
|
||||
<div className="act-grid">
|
||||
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyTfSub')}>
|
||||
<div className="act-grid tf-apply">
|
||||
<ApplyPanel project={project} method="apple.testflight_info" label={t('ed.applyTfInfo')} done={t('ai.pushed')} blocked={unsaved} />
|
||||
<ApplyPanel project={project} method="apple.testflight_groups" label={t('tf.groupsSync')} done={t('tf.groupsSynced')} blocked={unsaved} />
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ import { declarationsMd, exportName } from '../../lib/exports';
|
|||
import { ago, num } from '../../lib/format';
|
||||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { useExports, useRunExport } from '../../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
|
||||
import { CONSOLE_FORM_NAME, ConsoleFillButton } from '../../ui/ConsoleFill';
|
||||
import { ConsolePlanButton, PLAN_FORMS } from '../../ui/ConsolePlan';
|
||||
import { Bar, Card, Chip, Empty, ErrorNotice, Notice, Skeleton } from '../../ui/kit';
|
||||
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';
|
||||
|
|
@ -34,7 +36,7 @@ const yes = (b: boolean | null | undefined): string => (b === null || b === unde
|
|||
function AskCard({ ask }: { ask: DeclAsk[] }) {
|
||||
if (!ask.length) return <Notice tone="ok" icon="check">{t('ai.allAnswered')}</Notice>;
|
||||
return (
|
||||
<Card className="ask-card" title={t('ai.askTitle', { count: ask.length })} icon="question" sub={t('ai.askSub')} pad={false}>
|
||||
<Section className="ask-card" title={t('ai.askTitle', { count: ask.length })} icon="question" sub={t('ai.askSub')} pad={false}>
|
||||
<div className="list">
|
||||
{ask.map(a => (
|
||||
<div className="li" key={a.id}>
|
||||
|
|
@ -45,7 +47,7 @@ function AskCard({ ask }: { ask: DeclAsk[] }) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -76,12 +78,12 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR
|
|||
const up = ds?.uploaded ?? null;
|
||||
const asked = d.ask.filter(a => a.area === 'dataSafety').length;
|
||||
return (
|
||||
<Card title={t('ai.dsTitle')} icon="shield" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('ai.dsSub')}</span>} pad={false}
|
||||
<Section title={t('ai.dsTitle')} icon="shield" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('ai.dsSub')}</span>} pad={false}
|
||||
actions={ds ? <>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => run({ id: 'ds', label: t('export.dataSafety'), kind: 'csv', file: exportName(project.name, 'data safety'), build: () => ds.csv })}><Icon name="download" />CSV</button>
|
||||
<WriteButton run={w} primary icon="upload" w={{ method: 'play.data_safety', args: {}, title: t('ai.dsUpload'), done: t('ai.dsUploaded'), after: onDone }} hint={t('ai.dsUploadHint')}>{t('ai.dsUpload')}</WriteButton>
|
||||
</> : undefined}>
|
||||
{!ds ? <Empty compact illo="shield" title={t('ai.dsNoneTitle')}>{t('ai.dsNoneText')}</Empty> : (
|
||||
{!ds ? <EmptyState icon="shield"><b>{t('ai.dsNoneTitle')}</b><br />{t('ai.dsNoneText')}</EmptyState> : (
|
||||
<>
|
||||
<div className="card-body stack">
|
||||
<dl className="kv">
|
||||
|
|
@ -99,7 +101,7 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR
|
|||
{asked > 0 && <span className="small muted row" style={{ gap: 6 }}><Icon name="question" className="ic13" />{t('ai.askedHere', { count: asked })}</span>}
|
||||
</div>
|
||||
{ds.types.length > 0 && (
|
||||
<table className="table">
|
||||
<div className="ds-table-wrap"><table className="table v2">
|
||||
<thead><tr><th>{t('ai.colType')}</th><th>{t('ai.colCollected')}</th><th>{t('ai.colShared')}</th></tr></thead>
|
||||
<tbody>
|
||||
{ds.types.map(x => (
|
||||
|
|
@ -110,12 +112,12 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR
|
|||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
)}
|
||||
<div className="card-foot"><Icon name="info" />{t('ai.dsFoot', { rows: ds.rows })}</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -123,14 +125,14 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe
|
|||
const ap = d.appPrivacy;
|
||||
if (!ap) return null;
|
||||
return (
|
||||
<Card title={t('ai.apTitle')} icon="lock" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ai.apSub')}</span>} pad={false}
|
||||
<Section title={t('ai.apTitle')} icon="lock" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ai.apSub')}</span>} pad={false}
|
||||
actions={<>
|
||||
<button className="btn btn-sm" onClick={() => api.openExternal(ascUrl(appId).replace(/\/distribution$/, '/distribution/privacy')).catch(() => {})}><Icon name="external" />{t('ai.apOpen')}</button>
|
||||
<ConsoleFillButton project={project} form="apple-app-privacy" />
|
||||
</>}>
|
||||
<div className="card-body"><Notice tone="info" icon="info">{t('ai.apNote')}</Notice></div>
|
||||
{!ap.items.length ? <Empty compact illo="shield" title={ap.fromDataSafety ? t('ai.apNoneTitle') : t('ai.apUnknownTitle')}>{ap.fromDataSafety ? t('ai.apNoneText') : t('ai.apUnknownText')}</Empty> : (
|
||||
<table className="table">
|
||||
{!ap.items.length ? <EmptyState icon="shield"><b>{ap.fromDataSafety ? t('ai.apNoneTitle') : t('ai.apUnknownTitle')}</b><br />{ap.fromDataSafety ? t('ai.apNoneText') : t('ai.apUnknownText')}</EmptyState> : (
|
||||
<div className="ds-table-wrap"><table className="table v2">
|
||||
<thead><tr><th>{t('ai.colAppleType')}</th><th>{t('ai.colPurposes')}</th><th>{t('ai.colLinked')}</th><th>{t('ai.colTracking')}</th></tr></thead>
|
||||
<tbody>
|
||||
{ap.items.map(x => (
|
||||
|
|
@ -142,9 +144,9 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe
|
|||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +156,7 @@ function ConsoleSteps({ project, stores }: { project: Project; stores: Declarati
|
|||
const forms = PLAN_FORMS.filter(f => (f.store === 'apple' ? stores.apple : stores.play));
|
||||
if (!forms.length) return null;
|
||||
return (
|
||||
<Card title={t('cp.title')} icon="clipboard" sub={t('cp.sub')} pad={false} id="console-steps">
|
||||
<Section title={t('cp.title')} icon="clipboard" sub={t('cp.sub')} pad={false} id="console-steps">
|
||||
<div className="list">
|
||||
{forms.map(f => (
|
||||
<div className="li" key={f.form} data-form={f.form}>
|
||||
|
|
@ -167,7 +169,7 @@ function ConsoleSteps({ project, stores }: { project: Project; stores: Declarati
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -181,27 +183,45 @@ export function AppInfoTab({ project }: { project: Project }) {
|
|||
{ id: 'decl-md', label: t('export.declarations'), icon: 'clipboard', kind: 'md', file: exportName(name, 'store declarations'), disabled: r.data ? false : t('export.readFirst'), build: () => declarationsMd(name, r.data!) },
|
||||
{ id: 'ds-csv', label: t('export.dataSafety'), icon: 'shield', kind: 'csv', file: exportName(name, 'data safety'), disabled: r.data?.dataSafety ? false : t('export.noDataSafety'), build: () => r.data!.dataSafety!.csv },
|
||||
]);
|
||||
if (!r.data) return r.error ? <ErrorNotice error={r.error} tone="bad" /> : <Card><Skeleton lines={6} block /></Card>;
|
||||
if (!r.data) return r.error ? <ErrorNotice error={r.error} tone="bad" /> : <Section><Skeleton lines={6} block /></Section>;
|
||||
const d = r.data;
|
||||
const info = d.appInfo;
|
||||
const enc = d.encryption;
|
||||
const rv = d.review;
|
||||
const cat = info?.categories ? [info.categories.primary, info.categories.primarySubOne, info.categories.primarySubTwo].filter(Boolean).join(' / ') : null;
|
||||
const ds = d.dataSafety;
|
||||
const facts: { k: string; label: string; icon: IconName; value: string; tone: 'ok' | 'warn' | 'neutral' }[] = [
|
||||
{ k: 'ask', label: t('v2ins.aiOpen'), icon: 'question', value: d.ask.length ? num(d.ask.length) : t('v2ins.aiNone'), tone: d.ask.length ? 'warn' : 'ok' },
|
||||
...(d.stores.apple && info ? [
|
||||
{ k: 'cat', label: t('ai.category'), icon: 'grid' as IconName, value: cat ?? t('ai.notSet'), tone: cat ? 'neutral' as const : 'warn' as const },
|
||||
{ k: 'price', label: t('ai.price'), icon: 'coins' as IconName, value: info.free ? t('ai.free') : t('ai.paid'), tone: 'neutral' as const },
|
||||
{ k: 'enc', label: t('ai.encTitle'), icon: 'lock' as IconName, value: enc === null ? t('ai.notAnswered') : enc ? t('ai.encYes') : t('ai.encNo'), tone: enc === null ? 'warn' as const : 'neutral' as const },
|
||||
] : []),
|
||||
...(d.stores.play && ds ? [{ k: 'ds', label: t('ai.dsCollects'), icon: 'shield' as IconName, value: yes(ds.collectsData), tone: ds.collectsData === null || ds.collectsData === undefined ? 'warn' as const : 'neutral' as const }] : []),
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className="scr-appinfo">
|
||||
<section className="ai-sheet" aria-label={t('v2ins.aiSheet')}>
|
||||
<div className="ai-sheet-h"><span className="ds-eyebrow">{t('v2ins.aiSheet')}</span><h2>{name}</h2>
|
||||
<span className="ai-sheet-stores">{d.stores.apple && <span><StoreGlyph store="ios" className="ic13" />App Store</span>}{d.stores.play && <span><StoreGlyph store="android" className="ic13" />Google Play</span>}</span>
|
||||
</div>
|
||||
<dl className="ai-facts">
|
||||
{facts.map(f => <div key={f.k} className={`ai-fact tone-${f.tone}`}><dt><Icon name={f.icon} />{f.label}</dt><dd title={f.value}>{f.value}</dd></div>)}
|
||||
</dl>
|
||||
</section>
|
||||
<AskCard ask={d.ask} />
|
||||
{d.stores.apple && e?.app?.platforms.ios && !e.app.platforms.ios.ascAppId && (
|
||||
<Notice tone="warn" icon="alert" action={<ConsoleFillButton project={project} form="apple-app-record" primary />}>
|
||||
<div className="stack" style={{ gap: 2 }}><b>{t('cf.noRecordTitle')}</b><span className="small">{t('cf.noRecordText')}</span></div>
|
||||
</Notice>
|
||||
)}
|
||||
<div className="grid g2" style={{ alignItems: 'start' }}>
|
||||
{d.stores.play ? <DataSafety project={project} d={d} onDone={r.refresh} /> : <Card title={t('ai.dsTitle')} icon="shield" pad={false}><Empty compact illo="shield" title={t('ai.noPlay')}>{t('ai.noPlayText')}</Empty></Card>}
|
||||
{d.stores.apple ? <AppPrivacy project={project} d={d} appId={e?.app?.platforms.ios?.ascAppId} /> : <Card title={t('ai.apTitle')} icon="lock" pad={false}><Empty compact illo="shield" title={t('ai.noApple')}>{t('ai.noAppleText')}</Empty></Card>}
|
||||
<div className="ai-two">
|
||||
{d.stores.play ? <DataSafety project={project} d={d} onDone={r.refresh} /> : <Section title={t('ai.dsTitle')} icon="shield" pad={false}><EmptyState icon="shield"><b>{t('ai.noPlay')}</b><br />{t('ai.noPlayText')}</EmptyState></Section>}
|
||||
{d.stores.apple ? <AppPrivacy project={project} d={d} appId={e?.app?.platforms.ios?.ascAppId} /> : <Section title={t('ai.apTitle')} icon="lock" pad={false}><EmptyState icon="shield"><b>{t('ai.noApple')}</b><br />{t('ai.noAppleText')}</EmptyState></Section>}
|
||||
</div>
|
||||
{d.stores.apple && (
|
||||
<div className="grid g3" style={{ alignItems: 'start' }}>
|
||||
<Card title={t('ai.infoTitle')} icon="clipboard" sub={t('ai.infoSub')}
|
||||
<div className="ai-three">
|
||||
<Section title={t('ai.infoTitle')} icon="clipboard" sub={t('ai.infoSub')}
|
||||
actions={<>
|
||||
<WriteButton run={w} icon="upload" w={{ method: 'apple.appinfo', args: {}, title: t('ai.infoPush'), done: t('ai.pushed') }}>{t('ai.push')}</WriteButton>
|
||||
<ConsoleFillButton project={project} form="apple-age-rating" />
|
||||
|
|
@ -212,15 +232,15 @@ export function AppInfoTab({ project }: { project: Project }) {
|
|||
<dt>{t('ai.rights')}</dt><dd>{info!.contentRights ? <span className="small">{info!.contentRights === 'USES_THIRD_PARTY_CONTENT' ? t('ai.rightsThird') : t('ai.rightsOwn')}</span> : <Chip tone="warn" icon="question">{t('ai.notSet')}</Chip>}</dd>
|
||||
<dt>{t('ai.price')}</dt><dd>{info!.free ? t('ai.free') : t('ai.paid')}</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title={t('ai.encTitle')} icon="lock" sub={t('ai.encSub')}
|
||||
</Section>
|
||||
<Section title={t('ai.encTitle')} icon="lock" sub={t('ai.encSub')}
|
||||
actions={<WriteButton run={w} icon="upload" disabled={enc === null} hint={enc === null ? t('ai.encUnsetHint') : undefined} w={{ method: 'apple.encryption', args: {}, title: t('ai.encPush'), done: t('ai.pushed') }}>{t('ai.encAnswer')}</WriteButton>}>
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
{enc === null ? <Chip tone="warn" icon="question">{t('ai.notAnswered')}</Chip> : <Chip tone={enc ? 'info' : 'ok'}>{enc ? t('ai.encYes') : t('ai.encNo')}</Chip>}
|
||||
<span className="small muted">{enc === null ? t('ai.encUnset') : enc ? t('ai.encYesText') : t('ai.encNoText')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('ai.reviewTitle')} icon="users" sub={t('ai.reviewSub')}
|
||||
</Section>
|
||||
<Section title={t('ai.reviewTitle')} icon="users" sub={t('ai.reviewSub')}
|
||||
actions={<WriteButton run={w} icon="upload" w={{ method: 'apple.review_details', args: {}, title: t('ai.reviewPush'), done: t('ai.pushed') }}>{t('ai.push')}</WriteButton>}>
|
||||
<div className="stack" style={{ gap: 10 }}>
|
||||
<dl className="kv">
|
||||
|
|
@ -235,13 +255,13 @@ export function AppInfoTab({ project }: { project: Project }) {
|
|||
</div>
|
||||
<span className="small muted row" style={{ gap: 6 }}><Icon name="lock" className="ic13" />{t('ai.reviewSafe')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
<GameServicesCard project={project} />
|
||||
<ConsoleSteps project={project} stores={d.stores} />
|
||||
|
||||
<p className="small muted">{t('ai.foot', { when: ago(r.at) })}</p>
|
||||
</>
|
||||
<p className="ai-foot">{t('ai.foot', { when: ago(r.at) })}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// size (`shots gallery --json`, offline), store check issues outlined. Images load one by one as they scroll into view
|
||||
// (`project.render`, a data: URL each: the window loads no files itself). Re-render (dry run, dialog, real run), write
|
||||
// the review page, open it or the folder.
|
||||
import '../../styles/art.css';
|
||||
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, errorText, type Project, type RenderImage, type RendersResult, type RenderTile, type ToolResult } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
|
|
@ -9,7 +10,8 @@ import { ago, bytes, num } from '../../lib/format';
|
|||
import { artRows, langIssues } from '../../lib/views';
|
||||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Seg, Skeleton } from '../../ui/kit';
|
||||
import { Chip, Empty, ErrorNotice, Seg, Skeleton } from '../../ui/kit';
|
||||
import { Section, StatTile, StatusPill } from '../../ui/ds';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { LiveArt } from './LiveArt';
|
||||
|
||||
|
|
@ -57,11 +59,13 @@ function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: Re
|
|||
return () => { alive = false; io.disconnect(); };
|
||||
}, [projectId, file, stamp, src]);
|
||||
const lvl = tile.issues.some(i => i.level === 'error') ? 'err' : tile.issues.length ? 'warn' : '';
|
||||
const shape = tile.h > tile.w ? 'portrait' : 'landscape';
|
||||
return (
|
||||
<figure className={`art-tile ${lvl}`} style={{ ['--ar' as string]: `${tile.w} / ${tile.h}` }}>
|
||||
<figure className={`art-tile ${lvl} ${shape}`} style={{ ['--ar' as string]: `${tile.w} / ${tile.h}` }}>
|
||||
<button ref={ref} className="art-pic" onClick={onOpen} aria-label={t('art.open', { name: tile.name })}>
|
||||
{src ? <img src={src} alt="" draggable={false} /> : src === null && file ? <span className="art-ph"><Icon name="image" />{t('art.tooBig')}</span> : <span className="art-ph">{tile.kind === 'video' ? <Icon name="play" /> : <span className="sk sk-block" />}</span>}
|
||||
{tile.kind === 'video' && <span className="art-play"><Icon name="play" /></span>}
|
||||
{lvl && <span className={`art-badge ${lvl}`} title={tile.issues.map(i => i.msg).join('\n')}><Icon name={lvl === 'err' ? 'alertCircle' : 'alert'} />{tile.issues.length}</span>}
|
||||
</button>
|
||||
<figcaption><span className="mono-id">{tile.name}</span><span className="muted">{tile.w}×{tile.h}{tile.bytes ? ` · ${bytes(tile.bytes)}` : ''}</span></figcaption>
|
||||
{tile.issues.length > 0 && <ul className="note-list art-issues">{tile.issues.slice(0, 2).map((i, k) => <li key={k} className={i.level}>{i.msg}</li>)}</ul>}
|
||||
|
|
@ -125,7 +129,7 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
}, [project.id, d, w, data?.dir, r.refresh]);
|
||||
|
||||
// the live art stays the first child in both branches, so it keeps its state when the local renders arrive
|
||||
if (!data) return <><LiveArt target={project.id} project={project} />{r.error ? <ErrorNotice error={r.error} tone="bad" /> : <Card><Skeleton lines={6} block /></Card>}</>;
|
||||
if (!data) return <div className="scr-art"><LiveArt target={project.id} project={project} />{r.error ? <ErrorNotice error={r.error} tone="bad" /> : <Section><Skeleton lines={6} block /></Section>}</div>;
|
||||
const total = data.totals.images + data.totals.videos;
|
||||
const toolbar = (
|
||||
<>
|
||||
|
|
@ -136,15 +140,15 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="scr-art">
|
||||
<LiveArt target={project.id} project={project} />
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="image" label={t('art.images')} value={num(data.totals.images)} sub={t('art.imagesSub', { count: langs.length })} />
|
||||
<Kpi icon="play" label={t('art.videos')} value={num(data.totals.videos)} sub={t('art.videosSub')} />
|
||||
<Kpi icon="alertCircle" label={t('art.errors')} value={num(data.totals.errors)} sub={t('art.errorsSub')} tone={data.totals.errors ? 'bad' : undefined} />
|
||||
<Kpi icon="alert" label={t('art.warnings')} value={num(data.totals.warnings)} sub={t('art.rendered', { when: data.generated })} tone={data.totals.warnings ? 'warn' : undefined} />
|
||||
<div className="ds-grid art-stats">
|
||||
<StatTile icon="image" label={t('art.images')} value={num(data.totals.images)} sub={t('art.imagesSub', { count: langs.length })} />
|
||||
<StatTile icon="play" label={t('art.videos')} value={num(data.totals.videos)} sub={t('art.videosSub')} />
|
||||
<StatTile icon="alertCircle" label={t('art.errors')} value={num(data.totals.errors)} sub={t('art.errorsSub')} tone={data.totals.errors ? 'bad' : undefined} />
|
||||
<StatTile icon="alert" label={t('art.warnings')} value={num(data.totals.warnings)} sub={t('art.rendered', { when: data.generated })} tone={data.totals.warnings ? 'warn' : undefined} />
|
||||
</div>
|
||||
<Card title={t('art.title')} icon="grid" sub={<span className="mono-id">{dir}</span>} actions={toolbar} pad={false}>
|
||||
<Section title={t('art.title')} icon="grid" sub={<span className="mono-id">{dir}</span>} actions={toolbar} pad={false}>
|
||||
<div className="card-body art-bar">
|
||||
{data.variants.length > 1 && <Seg value={v?.id ?? ''} onChange={setVariant} label={t('art.variant')} options={data.variants.map(x => [x.id, x.id ? t('gr.variant', { id: x.id }) : t('art.base')] as [string, string])} />}
|
||||
<div className="fchips" role="group" aria-label={t('col.language')}>
|
||||
|
|
@ -163,9 +167,9 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
<div className="art-rows">
|
||||
{rows.map(row => (
|
||||
<section className={`art-row${row.issues.some(i => i.level === 'error') ? ' bad' : ''}`} key={row.size}>
|
||||
<h3><span>{row.label}</span><code>{lg}/{row.size}</code><Chip tone="outline" dot={false}>{row.store === 'apple' ? 'App Store' : 'Google Play'}</Chip><span className="muted small">{t('art.count', { count: row.tiles.length })}</span></h3>
|
||||
<h3><span>{row.label}</span><code>{lg}/{row.size}</code><Chip tone="outline" dot={false}>{row.store === 'apple' ? 'App Store' : 'Google Play'}</Chip>{row.issues.length > 0 && <StatusPill tone={row.issues.some(i => i.level === 'error') ? 'bad' : 'warn'}>{t('v2lst.issues', { count: row.issues.length })}</StatusPill>}<span className="muted small">{t('art.count', { count: row.tiles.length })}</span></h3>
|
||||
{row.issues.length > 0 && <ul className="note-list art-issues">{row.issues.map((i, k) => <li key={k} className={i.level}>{i.msg}</li>)}</ul>}
|
||||
<div className="art-tiles" style={{ ['--tw' as string]: row.size === 'feature' || /landscape/.test(row.size) ? '300px' : row.tiles[0] && row.tiles[0].w > row.tiles[0].h ? '260px' : '150px' }}>
|
||||
<div className="art-tiles" style={{ ['--tw' as string]: row.size === 'feature' || /landscape/.test(row.size) ? '300px' : row.tiles[0] && row.tiles[0].w > row.tiles[0].h ? '260px' : '170px' }}>
|
||||
{row.tiles.map((tile, i) => <Thumb key={tile.src} projectId={project.id} tile={tile} stamp={stamp} onOpen={() => setOpen({ tiles: row.tiles, i })} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -173,8 +177,8 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
</div>
|
||||
)}
|
||||
<div className="card-foot"><Icon name="info" />{t('art.foot', { when: ago(r.at) })}</div>
|
||||
</Card>
|
||||
</Section>
|
||||
{open && <Lightbox projectId={project.id} tiles={open.tiles} index={open.i} stamp={stamp} onClose={() => setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} onReplace={tile => { setOpen(null); replace(tile); }} />}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Catalog: products and subscriptions defined once in the config against both stores (catalog diff): a parity
|
||||
// matrix (present, mismatched, missing, not sold there, not read), and price gaps between the stores per region.
|
||||
import '../../styles/catalog.css';
|
||||
import type { Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
import { ago, num } from '../../lib/format';
|
||||
|
|
@ -8,17 +9,19 @@ import { useData, useRead, useWrite } from '../../store';
|
|||
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
|
||||
import { OwnerAction } from '../../ui/OwnerAction';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { Chip, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatTile, StatusPill } from '../../ui/ds';
|
||||
import { catalogMd, exportName } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
import { useNav } from '../../nav';
|
||||
|
||||
const CELL: Record<CellState, [IconName, Key]> = { ok: ['check', 'cat.ok'], warn: ['alert', 'cat.warn'], error: ['x', 'cat.error'], na: ['dot', 'cat.na'], unknown: ['info', 'cat.unknown'] };
|
||||
|
||||
function Cell({ c }: { c: { state: CellState; notes: string[] } }) {
|
||||
function Cell({ c, store }: { c: { state: CellState; notes: string[] }; store?: 'ios' | 'android' }) {
|
||||
const [icon, key] = CELL[c.state];
|
||||
return (
|
||||
<div>
|
||||
<div className={`cat-store s-${c.state}`}>
|
||||
{store && <span className="cat-store-l"><StoreGlyph store={store} />{store === 'ios' ? 'App Store' : 'Google Play'}</span>}
|
||||
<span className="mx-cell"><span className={`mx-icon mx-${c.state}`}><Icon name={icon} /></span>{t(key)}</span>
|
||||
{c.notes.length > 0 && <ul className="note-list">{c.notes.slice(0, 3).map((n, i) => <li key={i}>{n}</li>)}</ul>}
|
||||
</div>
|
||||
|
|
@ -33,7 +36,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project
|
|||
const e = d.statusOf(project);
|
||||
const ios = !!e?.app?.platforms.ios, android = !!e?.app?.platforms.android;
|
||||
return (
|
||||
<Card title={t('act.catalogTitle')} icon="upload" sub={t('act.catalogSub')}>
|
||||
<Section title={t('act.catalogTitle')} icon="upload" sub={t('act.catalogSub')}>
|
||||
<div className="act-grid">
|
||||
{android && (
|
||||
<div className="act-col">
|
||||
|
|
@ -60,7 +63,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project
|
|||
</div>
|
||||
</div>
|
||||
<p className="small muted act-note"><Icon name="shield" />{t('act.catalogNote')}</p>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -73,48 +76,54 @@ export function CatalogTab({ project }: { project: Project }) {
|
|||
useExports('catalog', [
|
||||
{ id: 'catalog-md', label: t('export.catalog'), icon: 'layers', kind: 'md', file: exportName(name, 'catalog parity'), disabled: diff.data ? false : t('export.readFirst'), build: () => catalogMd(name, diff.data!) },
|
||||
]);
|
||||
if (!e) return <Card><Skeleton lines={5} block /></Card>;
|
||||
if (!e) return <div className="scr-catalog"><Section><Skeleton lines={5} block /></Section></div>;
|
||||
const rows = parityMatrix(s, diff.data);
|
||||
const read = !!diff.data;
|
||||
const inParity = rows.filter(r => r.ios.state !== 'error' && r.ios.state !== 'warn' && r.android.state !== 'error' && r.android.state !== 'warn' && r.kind !== 'extra').length;
|
||||
const gaps = diff.data?.parity ?? [];
|
||||
|
||||
if (!rows.length) {
|
||||
return <Card pad={false}><Empty illo="catalog" title={t('cat.emptyTitle')}>{t('cat.emptyText')}</Empty></Card>;
|
||||
return <div className="scr-catalog"><Section><EmptyState icon="tag"><b>{t('cat.emptyTitle')}</b> {t('cat.emptyText')}</EmptyState></Section></div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="tag" label={t('cat.products')} value={s?.products ?? 0} sub={t('cat.productsSub')} />
|
||||
<Kpi icon="refresh" label={t('cat.subscriptions')} value={s?.subscriptions ?? 0} sub={t('cat.subsSub')} />
|
||||
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
|
||||
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : diff.loading ? t('read.waiting') : t('kpi.notRead')} />
|
||||
<div className="scr-catalog">
|
||||
<div className="ds-grid cat-stats">
|
||||
<StatTile icon="tag" label={t('cat.products')} value={s?.products ?? 0} sub={t('cat.productsSub')} />
|
||||
<StatTile icon="refresh" label={t('cat.subscriptions')} value={s?.subscriptions ?? 0} sub={t('cat.subsSub')} />
|
||||
<StatTile icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
|
||||
<StatTile icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : diff.loading ? t('read.waiting') : t('kpi.notRead')} />
|
||||
</div>
|
||||
{diff.error && <ErrorNotice error={diff.error} keep />}
|
||||
<CatalogActions project={project} products={s?.products ?? 0} subs={s?.subscriptions ?? 0} refresh={() => { diff.refresh(); }} />
|
||||
<Card title={t('cat.matrix')} icon="layers" sub={read ? (diff.fromCache ? t('common.cachedFrom', { when: ago(diff.at) }) : t('common.readWhen', { when: ago(diff.at) })) : t('cat.configOnly')} pad={false}
|
||||
<Section title={t('cat.matrix')} icon="layers" sub={read ? (diff.fromCache ? t('common.cachedFrom', { when: ago(diff.at) }) : t('common.readWhen', { when: ago(diff.at) })) : t('cat.configOnly')}
|
||||
actions={<ReadControl r={diff} label={t('cat.compare')} />}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.item')}</th><th>{t('col.type')}</th><th>{t('col.config')}</th><th>App Store</th><th>Google Play</th><th className="right">{t('col.priceGap')}</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td><span className="mono-id strong">{r.id}</span></td>
|
||||
<td>{r.kind === 'extra' ? <Chip tone="warn">{t('cat.extra')}</Chip> : <Chip tone="outline">{r.kind === 'subscription' ? t('cat.sub') : t('cat.product')}</Chip>}</td>
|
||||
<td className="small">{r.config ? [s?.catalog.ios.includes(r.id) ? 'iOS' : null, s?.catalog.android.includes(r.id) ? 'Android' : null].filter(Boolean).join(' + ') || t('cat.na') : <span className="muted">{t('cat.notInConfig')}</span>}</td>
|
||||
<td><Cell c={r.ios} /></td>
|
||||
<td><Cell c={r.android} /></td>
|
||||
<td className="right">{r.maxDiff !== null ? <Chip tone={r.maxDiff > 25 ? 'bad' : 'warn'}>{`${num(r.maxDiff, 1)} %`}</Chip> : <span className="muted small">{read ? t('cat.noGap') : '–'}</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<ul className="cat-cards" aria-label={t('cat.matrix')}>
|
||||
{rows.map(r => {
|
||||
const worst = [r.ios.state, r.android.state].includes('error') ? 'bad' : [r.ios.state, r.android.state].includes('warn') || r.kind === 'extra' ? 'warn' : 'ok';
|
||||
return (
|
||||
<li key={r.id} className={`cat-card t-${worst}`} data-item={r.id}>
|
||||
<div className="cat-card-h">
|
||||
<span className="cat-ic" aria-hidden="true"><Icon name={r.kind === 'subscription' ? 'refresh' : 'tag'} /></span>
|
||||
<div className="cat-names">
|
||||
<span className="mono-id strong cat-id">{r.id}</span>
|
||||
<span className="cat-type">{r.kind === 'extra' ? <Chip tone="warn">{t('cat.extra')}</Chip> : <Chip tone="outline">{r.kind === 'subscription' ? t('cat.sub') : t('cat.product')}</Chip>}
|
||||
<span className="small muted">{t('col.config')}: {r.config ? [s?.catalog.ios.includes(r.id) ? 'iOS' : null, s?.catalog.android.includes(r.id) ? 'Android' : null].filter(Boolean).join(' + ') || t('cat.na') : t('cat.notInConfig')}</span></span>
|
||||
</div>
|
||||
<span className="grow" />
|
||||
{r.maxDiff !== null ? <StatusPill tone={r.maxDiff > 25 ? 'bad' : 'warn'} title={t('col.priceGap')}>{`Δ ${num(r.maxDiff, 1)} %`}</StatusPill> : <span className="muted small" title={t('col.priceGap')}>{read ? t('cat.noGap') : '–'}</span>}
|
||||
</div>
|
||||
<div className="cat-stores">
|
||||
<Cell c={r.ios} store="ios" />
|
||||
<Cell c={r.android} store="android" />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
{gaps.length > 0 && (
|
||||
<Card title={t('cat.gapsTitle')} icon="globe" sub={t('cat.gapsTitleSub')} pad={false}>
|
||||
<table className="table">
|
||||
<Section title={t('cat.gapsTitle')} icon="globe" sub={t('cat.gapsTitleSub')}>
|
||||
<div className="ds-table-wrap"><table className="table v2">
|
||||
<thead><tr><th>{t('col.item')}</th><th>{t('col.region')}</th><th className="right">App Store</th><th className="right">Google Play</th><th className="right">{t('col.diff')}</th></tr></thead>
|
||||
<tbody>
|
||||
{gaps.slice(0, 12).map((g, i) => (
|
||||
|
|
@ -125,9 +134,9 @@ export function CatalogTab({ project }: { project: Project }) {
|
|||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</table></div>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import { AiFix, aiFinding } from '../../ui/AiFix';
|
|||
import { api, errorText, newJob } from '../../api';
|
||||
import type { ToolResult } from '../../api';
|
||||
import type { ReportData } from '../../../../../cli/lib/report';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
|
||||
import { OpenLink } from '../../ui/OpenLink';
|
||||
import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { Bar, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatusPill } from '../../ui/ds';
|
||||
import '../../styles/checks.css';
|
||||
import { exportName, validateMd } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
|
||||
|
|
@ -35,8 +37,27 @@ function FixButton({ action, what, w }: { action: FixAction; what: string; w: Re
|
|||
}
|
||||
|
||||
const SEV: Record<ScanSeverity, ['bad' | 'warn' | 'info', 'alertCircle' | 'alert' | 'info']> = { blocker: ['bad', 'alertCircle'], high: ['bad', 'alert'], medium: ['warn', 'alert'], low: ['info', 'info'] };
|
||||
const SEV_ORDER: ScanSeverity[] = ['blocker', 'high', 'medium', 'low'];
|
||||
|
||||
/** The `storebound scan` card: rejection risks with guideline, evidence file:line, fix and skill. */
|
||||
/** One check as a card: tone icon tile, title, detail lines, the fix, and its actions on the right. */
|
||||
function CheckCard({ tone, icon, title, children, actions, tags }: { tone: 'bad' | 'warn' | 'ok' | 'info'; icon: IconName; title: ReactNode; children?: ReactNode; actions?: ReactNode; tags?: ReactNode }) {
|
||||
return (
|
||||
<div className={`ck-card tone-${tone}`}>
|
||||
<span className={`ck-ic tone-bg-${tone}`}><Icon name={icon} /></span>
|
||||
<div className="ck-main">
|
||||
<div className="ck-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
<div className="ck-side">
|
||||
{tags && <div className="ck-tags">{tags}</div>}
|
||||
{actions && <div className="ck-acts">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The `storebound scan` section: rejection risks with guideline, evidence file:line, fix and skill; open risks first,
|
||||
* accepted ones collapsed. */
|
||||
function ScanCard({ project }: { project: Project }) {
|
||||
const s = useRead<ScanResult>(project.id, 'scan', { network: false, title: t('checks.scan') });
|
||||
const w = useWrite(project.id);
|
||||
|
|
@ -57,43 +78,49 @@ function ScanCard({ project }: { project: Project }) {
|
|||
}, [project.id, firstAi, claude]);
|
||||
const sdks = Array.isArray(s.data?.sdks) ? s.data!.sdks : [];
|
||||
const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length;
|
||||
const keyed = rows.map(f => ({ f, k: keyOf(f) }));
|
||||
const open = keyed.filter(x => !x.f.accepted).sort((a, b) => SEV_ORDER.indexOf(a.f.severity) - SEV_ORDER.indexOf(b.f.severity));
|
||||
const accepted = keyed.filter(x => !!x.f.accepted);
|
||||
const card = ({ f, k }: { f: ScanFinding; k: string }) => {
|
||||
const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low;
|
||||
const ev = Array.isArray(f.evidence) ? f.evidence : [];
|
||||
return (
|
||||
<CheckCard key={k} tone={tone} icon={icon} title={<><span className="ck-gl">{f.guideline}</span>{f.title}</>}
|
||||
tags={<><StatusPill tone={f.accepted ? 'ok' : tone}>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters<typeof t>[0])}</StatusPill><span className="ck-store"><StoreGlyph store={f.store === 'apple' ? 'ios' : 'android'} className="ic13" />{f.store === 'apple' ? 'App Store' : 'Google Play'}</span></>}
|
||||
actions={<>{!f.accepted && <OpenLink url={f.url} />}{!f.accepted && fixable(f.action) && <FixButton action={f.action} what={f.title} w={w} />}</>}>
|
||||
{ev.slice(0, 2).map((e, j) => <span className="ck-sub selectable" key={j}><code>{e.file}:{e.line}</code>{e.text ? ` ${e.text}` : ''}</span>)}
|
||||
{f.accepted ? <span className="ck-sub">{t('checks.accepted', { reason: f.accepted })}</span>
|
||||
: <span className="ck-fix"><Icon name="wand" /><span className="selectable">{f.fix} <code>{f.skill}</code></span></span>}
|
||||
{!f.accepted && f.aiFix && !fixable(f.action) && <div className="ck-ai"><AiFix projectId={project.id} f={f} claude={claude} busy={aiBusy} setBusy={setAiBusy} rescan={() => { s.refresh(); }}
|
||||
stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} /></div>}
|
||||
</CheckCard>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Card title={t('checks.scan')} icon="search" sub={s.data && sdks.length ? t('checks.scanSdks', { list: sdks.map(x => x.name).join(', ') }) : t('checks.scanSub')} pad={false}
|
||||
<Section title={t('checks.scan')} icon="search" sub={s.data && sdks.length ? t('checks.scanSdks', { list: sdks.map(x => x.name).join(', ') }) : t('checks.scanSub')}
|
||||
actions={<ReadControl r={s} label={t('checks.scanAgain')} />}>
|
||||
{s.error && <div className="card-body"><ErrorNotice error={s.error} keep={!!s.data} /></div>}
|
||||
{!s.data ? (s.loading && !s.error ? <div className="card-body"><Skeleton lines={4} /></div> : null)
|
||||
: !rows.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
|
||||
<div className="list">
|
||||
{rows.map(f => {
|
||||
const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low;
|
||||
const ev = Array.isArray(f.evidence) ? f.evidence : [];
|
||||
return (
|
||||
<div className="li" key={keyOf(f)}>
|
||||
<span className={`li-icon tone-bg-${tone}`}><Icon name={icon} /></span>
|
||||
<div className="li-main">
|
||||
<span className="li-title">{f.guideline} · {f.title}</span>
|
||||
{ev.slice(0, 2).map((e, j) => <span className="li-sub selectable" key={j}><code>{e.file}:{e.line}</code>{e.text ? ` ${e.text}` : ''}</span>)}
|
||||
{f.accepted ? <span className="li-sub">{t('checks.accepted', { reason: f.accepted })}</span>
|
||||
: <span className="fix"><Icon name="wand" /><span className="selectable">{f.fix} <code>{f.skill}</code></span></span>}
|
||||
{!f.accepted && <OpenLink url={f.url} />}
|
||||
{!f.accepted && f.aiFix && !fixable(f.action) && <AiFix projectId={project.id} f={f} claude={claude} busy={aiBusy} setBusy={setAiBusy} rescan={() => { s.refresh(); }}
|
||||
stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />}
|
||||
</div>
|
||||
{!f.accepted && fixable(f.action) && <FixButton action={f.action} what={f.title} w={w} />}
|
||||
<Chip tone={f.accepted ? 'ok' : tone}>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters<typeof t>[0])}</Chip>
|
||||
<Chip tone="outline">{f.store === 'apple' ? 'App Store' : 'Google Play'}</Chip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="ck-list">
|
||||
{s.error && <ErrorNotice error={s.error} keep={!!s.data} />}
|
||||
{!s.data ? (s.loading && !s.error ? <Skeleton lines={4} /> : null)
|
||||
: !rows.length ? <EmptyState icon="shield" tone="ok"><b>{t('checks.scanClean')}</b><br />{t('checks.scanCleanText')}</EmptyState> : (
|
||||
<>
|
||||
{open.map(card)}
|
||||
{accepted.length > 0 && (
|
||||
<details className="ck-passed">
|
||||
<summary><Icon name="check" />{t('v2ins.cAccepted', { count: accepted.length })}</summary>
|
||||
<div className="ck-list">{accepted.map(card)}</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{s.data && (asks > 0 || s.data.declarations?.changed) && (
|
||||
<div className="ck-notes">
|
||||
{asks > 0 && <div className="ck-sub">{t('checks.scanAsk', { count: asks })}</div>}
|
||||
{s.data.declarations?.changed && <div className="ck-sub"><code className="selectable">{t('checks.scanWrite')}</code></div>}
|
||||
</div>
|
||||
)}
|
||||
{s.data && (asks > 0 || s.data.declarations?.changed) && (
|
||||
<div className="card-body">
|
||||
{asks > 0 && <div className="li-sub">{t('checks.scanAsk', { count: asks })}</div>}
|
||||
{s.data.declarations?.changed && <div className="li-sub"><code className="selectable">{t('checks.scanWrite')}</code></div>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -114,10 +141,10 @@ function ReportBar({ project }: { project: Project }) {
|
|||
finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<Card title={t('checks.report')} icon="external" sub={last ? reportSummary(last) : t('checks.reportSub')}
|
||||
actions={<button className="btn btn-sm btn-primary" onClick={run} disabled={busy}><Icon name={busy ? 'refresh' : 'external'} className={busy ? 'spin' : undefined} />{t('checks.reportOpen')}</button>}>
|
||||
{null}
|
||||
</Card>
|
||||
<div className="ck-report">
|
||||
<span className="ck-report-t"><b>{t('checks.report')}</b><span>{last ? reportSummary(last) : t('checks.reportSub')}</span></span>
|
||||
<button className="btn btn-sm" onClick={run} disabled={busy}><Icon name={busy ? 'refresh' : 'external'} className={busy ? 'spin' : undefined} />{t('checks.reportOpen')}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -143,46 +170,77 @@ export function ChecksTab({ project }: { project: Project }) {
|
|||
const problems = [...(e?.errors ?? []), ...(e?.check?.problems ?? [])];
|
||||
const warnings = e?.check?.warnings ?? [];
|
||||
const findings = [...(v.data?.findings ?? [])].sort((a, b) => ['blocker', 'warn', 'ok'].indexOf(a.level) - ['blocker', 'warn', 'ok'].indexOf(b.level));
|
||||
const vBlock = findings.filter(f => f.level === 'blocker'), vWarn = findings.filter(f => f.level === 'warn'), vOk = findings.filter(f => f.level === 'ok');
|
||||
const blockers = problems.length + vBlock.length;
|
||||
const warns = warnings.length + vWarn.length;
|
||||
const total = findings.length + problems.length + warnings.length;
|
||||
const passed = vOk.length;
|
||||
const checked = !!v.data && !!e;
|
||||
const state: 'bad' | 'warn' | 'ok' | 'neutral' = blockers ? 'bad' : !checked ? 'neutral' : warns ? 'warn' : 'ok';
|
||||
const headline = blockers ? t('v2ins.cBlocked', { count: blockers }) : !checked ? t('v2ins.cNotRun') : t('v2ins.cReady');
|
||||
const finding = (f: Finding, i: number) => {
|
||||
const [tone, icon] = LEVEL[f.level];
|
||||
return (
|
||||
<CheckCard key={`${f.level}${i}`} tone={tone} icon={icon} title={f.msg}
|
||||
tags={<span className="ck-store"><StoreGlyph store={f.store === 'apple' ? 'ios' : 'android'} className="ic13" />{f.store === 'apple' ? 'App Store' : 'Google Play'}</span>}
|
||||
actions={<><OpenLink url={f.url} />{f.level !== 'ok' && fixable(f.action) && <FixButton action={f.action} what={f.msg} w={w} />}</>}>
|
||||
{f.fix && <span className="ck-fix"><Icon name="wand" /><code className="selectable">{f.fix}</code></span>}
|
||||
</CheckCard>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="grid g2" style={{ alignItems: 'start' }}>
|
||||
<div style={{ gridColumn: '1 / -1' }}><ReportBar project={project} /></div>
|
||||
<div style={{ gridColumn: '1 / -1' }}><ScanCard project={project} /></div>
|
||||
<Card title={t('checks.offline')} icon="shield" sub={t('checks.offlineSub')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}><Icon name="refresh" className={d.portfolioBusy === 'offline' ? 'spin' : undefined} />{t('checks.again')}</button>}>
|
||||
{!e ? <div className="card-body"><Skeleton lines={4} /></div> : !problems.length && !warnings.length ? (
|
||||
<Empty compact illo="shield" title={t('checks.clean')}>{t('checks.cleanText')}</Empty>
|
||||
) : (
|
||||
<div className="list">
|
||||
{problems.map((p, i) => <div className="li" key={`p${i}`}><span className="li-icon tone-bg-bad"><Icon name="alertCircle" /></span><div className="li-main"><span className="li-title">{p}</span><span className="li-sub">{t('checks.problem')}</span></div></div>)}
|
||||
{warnings.map((w, i) => <div className="li" key={`w${i}`}><span className="li-icon tone-bg-warn"><Icon name="alert" /></span><div className="li-main"><span className="li-title">{w}</span><span className="li-sub">{t('checks.warning')}</span></div></div>)}
|
||||
<div className="scr-checks">
|
||||
<section className={`ck-hero tone-${state}`} aria-label={t('v2ins.cScore')}>
|
||||
<div className="ck-score">
|
||||
<span className={`ck-score-ic tone-bg-${state === 'neutral' ? 'info' : state}`}><Icon name={blockers ? 'alertCircle' : !checked ? 'shield' : warns ? 'alert' : 'check'} weight="fill" /></span>
|
||||
<div className="ck-score-t">
|
||||
<span className="ds-eyebrow">{t('v2ins.cScore')}</span>
|
||||
<h2>{headline}</h2>
|
||||
<p>{v.data ? t('v2ins.cScoreSub', { ok: passed, total }) : t('checks.validateSub')}{v.data ? ` · ${v.fromCache ? t('common.cachedFrom', { when: ago(v.at) }) : t('common.readWhen', { when: ago(v.at) })}` : ''}</p>
|
||||
{total > 0 && <Bar value={passed / total} tone={state === 'neutral' ? 'info' : state} label={t('v2ins.cScore')} />}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<Card title={t('checks.validate')} icon="layers" sub={v.data ? (v.fromCache ? t('common.cachedFrom', { when: ago(v.at) }) : t('common.readWhen', { when: ago(v.at) })) : t('checks.validateSub')} pad={false}
|
||||
actions={<ReadControl r={v} label={t('checks.run')} />}>
|
||||
{v.error && <div className="card-body"><ErrorNotice error={v.error} keep={!!v.data} /></div>}
|
||||
{!v.data ? (
|
||||
v.loading && !v.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('checks.validateEmpty')}>{t('checks.validateEmptyText')}</Empty>
|
||||
) : !findings.length ? <Empty compact illo="shield" title={t('checks.ready')}>{t('checks.readyText')}</Empty> : (
|
||||
<div className="list">
|
||||
{findings.map((f, i) => {
|
||||
const [tone, icon] = LEVEL[f.level];
|
||||
return (
|
||||
<div className="li" key={i}>
|
||||
<span className={`li-icon tone-bg-${tone}`}><Icon name={icon} /></span>
|
||||
<div className="li-main">
|
||||
<span className="li-title">{f.msg}</span>
|
||||
{f.fix && <span className="fix"><Icon name="wand" /><code className="selectable">{f.fix}</code></span>}
|
||||
</div>
|
||||
<OpenLink url={f.url} />
|
||||
{f.level !== 'ok' && fixable(f.action) && <FixButton action={f.action} what={f.msg} w={w} />}
|
||||
<Chip tone="outline">{f.store === 'apple' ? 'App Store' : 'Google Play'}</Chip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ck-counts">
|
||||
<span className="ck-count bad"><b className="num">{blockers}</b>{t('v2ins.cBlockers')}</span>
|
||||
<span className="ck-count warn"><b className="num">{warns}</b>{t('v2ins.cWarnings')}</span>
|
||||
<span className="ck-count ok"><b className="num">{passed}</b>{t('v2ins.cPassedN')}</span>
|
||||
</div>
|
||||
<div className="ck-hero-acts">
|
||||
<ReadControl r={v} label={t('checks.run')} />
|
||||
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}><Icon name="refresh" className={d.portfolioBusy === 'offline' ? 'spin' : undefined} />{t('checks.again')}</button>
|
||||
</div>
|
||||
</section>
|
||||
{v.error && <ErrorNotice error={v.error} keep={!!v.data} />}
|
||||
|
||||
<Section title={t('v2ins.cFixFirst')} icon="alertCircle" sub={`${t('checks.offline')} · ${t('checks.validate')}`}>
|
||||
<div className="ck-list">
|
||||
{!e ? <Skeleton lines={3} /> : null}
|
||||
{problems.map((p, i) => <CheckCard key={`p${i}`} tone="bad" icon="alertCircle" title={p}><span className="ck-sub">{t('checks.problem')}</span></CheckCard>)}
|
||||
{vBlock.map(finding)}
|
||||
{!v.data && !v.error && (v.loading ? <Skeleton lines={3} /> : <EmptyState icon="cloud"><b>{t('checks.validateEmpty')}</b><br />{t('checks.validateEmptyText')}</EmptyState>)}
|
||||
{e && v.data && !blockers && <EmptyState icon="shield" tone="ok"><b>{t('checks.ready')}</b><br />{t('checks.readyText')}</EmptyState>}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<ScanCard project={project} />
|
||||
|
||||
{(warnings.length > 0 || vWarn.length > 0) && (
|
||||
<Section title={t('v2ins.cWarnings')} icon="alert" sub={t('checks.offlineSub')}>
|
||||
<div className="ck-list">
|
||||
{warnings.map((x, i) => <CheckCard key={`w${i}`} tone="warn" icon="alert" title={x}><span className="ck-sub">{t('checks.warning')}</span></CheckCard>)}
|
||||
{vWarn.map(finding)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{vOk.length > 0 && (
|
||||
<details className="ck-passed ck-passed-top">
|
||||
<summary><Icon name="check" />{t('v2ins.cPassed', { count: vOk.length })}</summary>
|
||||
<div className="ck-list">{vOk.map(finding)}</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<ReportBar project={project} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import { useNav } from '../../nav';
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
|
||||
import { ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatTile, StatusPill, tintStyle, useTint } from '../../ui/ds';
|
||||
import '../../styles/growth.css';
|
||||
import { LineChart } from '../../ui/LineChart';
|
||||
import { RankChart } from '../../ui/RankChart';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
|
@ -43,21 +45,39 @@ function Analytics({ projectId, hasApple, hasPlay, name, playReports }: { projec
|
|||
);
|
||||
const sum = (xs: number[]) => xs.reduce((n, v) => n + v, 0);
|
||||
const reach = s ? sum(s.reach) : 0, inst = s ? sum(s.installs) : 0;
|
||||
// deltas: the later half of the window against the earlier half (same number of days), in percent
|
||||
const half = s ? Math.floor(s.dates.length / 2) : 0;
|
||||
const change = (xs: number[]): number | null => {
|
||||
if (half < 1) return null;
|
||||
const before = sum(xs.slice(0, half)), after = sum(xs.slice(-half));
|
||||
return before ? Math.round(((after - before) / before) * 1000) / 10 : null;
|
||||
};
|
||||
const convChange = (): number | null => {
|
||||
if (!s || half < 1) return null;
|
||||
const rb = sum(s.reach.slice(0, half)), ra = sum(s.reach.slice(-half));
|
||||
if (!rb || !ra) return null;
|
||||
const cb = sum(s.installs.slice(0, half)) / rb, ca = sum(s.installs.slice(-half)) / ra;
|
||||
return Math.round((ca - cb) * 1000) / 10;
|
||||
};
|
||||
const deltaSub = half ? t('v2ins.gVsPrev', { count: half }) : t('gr.days', { count: s?.dates.length ?? 0 });
|
||||
const effects = (a?.effects ?? []).filter(e => e.store === st).slice(-8).reverse();
|
||||
return (
|
||||
<Card title={t('gr.analytics')} icon="chart" sub={a ? t('gr.analyticsSub', { count: a.snapshots, since: date(a.since) }) : t('gr.analyticsSubNone')} pad={false}
|
||||
<Section title={t('gr.analytics')} icon="chart" sub={a ? t('gr.analyticsSub', { count: a.snapshots, since: date(a.since) }) : t('gr.analyticsSubNone')}
|
||||
actions={<>{stores.length > 1 && <Seg value={st} onChange={setStore} label={t('col.store')} options={[['apple', 'App Store'], ['play', 'Google Play']]} />}{actions}</>}>
|
||||
{none ? (
|
||||
<Empty illo="pulse" title={t('gr.noneTitle')}>{t('gr.noneText')}{hasPlay && !playReports ? ` ${t('gr.noBucket')}` : ''}</Empty>
|
||||
) : r.error ? <div className="card-body"><ErrorNotice error={r.error} /></div> : !a || !s ? <div className="card-body"><Skeleton lines={5} block /></div> : !s.dates.length ? (
|
||||
<Empty illo="pulse" title={t('gr.noDataTitle', { store: st === 'apple' ? 'App Store' : 'Google Play' })}>{a.errors?.[st] ?? t('gr.noDataText')}{st === 'play' && !playReports ? ` ${t('gr.noBucket')}` : ''}</Empty>
|
||||
<EmptyState icon="chart"><b>{t('gr.noneTitle')}</b><br />{t('gr.noneText')}{hasPlay && !playReports ? ` ${t('gr.noBucket')}` : ''}</EmptyState>
|
||||
) : r.error ? <ErrorNotice error={r.error} /> : !a || !s ? <Skeleton lines={5} block /> : !s.dates.length ? (
|
||||
<EmptyState icon="chart"><b>{t('gr.noDataTitle', { store: st === 'apple' ? 'App Store' : 'Google Play' })}</b><br />{a.errors?.[st] ?? t('gr.noDataText')}{st === 'play' && !playReports ? ` ${t('gr.noBucket')}` : ''}</EmptyState>
|
||||
) : (
|
||||
<div className="card-body stack" style={{ gap: 16 }}>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="eye" label={st === 'apple' ? t('gr.impressions') : t('gr.visitors')} value={num(reach)} sub={t('gr.days', { count: s.dates.length })} />
|
||||
<Kpi icon="download" label={st === 'apple' ? t('gr.downloads') : t('gr.acquisitions')} value={num(inst)} sub={t('gr.days', { count: s.dates.length })} />
|
||||
<Kpi icon="activity" label={t('gr.conversion')} value={reach ? pct(inst / reach, 1) : '–'} sub={t('gr.conversionSub')} />
|
||||
<div className="gr-body">
|
||||
<div className="gr-funnel" role="group" aria-label={t('v2ins.gFunnel')}>
|
||||
<StatTile icon="eye" label={st === 'apple' ? t('gr.impressions') : t('gr.visitors')} value={num(reach)} delta={change(s.reach)} sub={deltaSub} />
|
||||
<span className="gr-step" aria-hidden="true"><Icon name="arrowRight" /></span>
|
||||
<StatTile icon="download" label={st === 'apple' ? t('gr.downloads') : t('gr.acquisitions')} value={num(inst)} delta={change(s.installs)} sub={deltaSub} />
|
||||
<span className="gr-step" aria-hidden="true"><Icon name="arrowRight" /></span>
|
||||
<StatTile icon="activity" label={t('gr.conversion')} value={reach ? pct(inst / reach, 1) : '–'} delta={convChange()} sub={half ? t('v2ins.gPts', { count: half }) : t('gr.conversionSub')} />
|
||||
</div>
|
||||
<p className="gr-note">{t('gr.days', { count: s.dates.length })} · {t('gr.conversionSub')}</p>
|
||||
{s.dates.length > 1 ? (
|
||||
<LineChart dates={s.dates} label={t('gr.chartAria', { store: st === 'apple' ? 'App Store' : 'Google Play' })} eventsLabel={t('gr.changes', { count: s.events.length })} events={s.events}
|
||||
indexLabel={t('gr.indexLabel')}
|
||||
|
|
@ -68,7 +88,7 @@ function Analytics({ projectId, hasApple, hasPlay, name, playReports }: { projec
|
|||
]} />
|
||||
) : <Notice tone="info">{t('gr.oneDay')}</Notice>}
|
||||
{effects.length > 0 && (
|
||||
<table className="table">
|
||||
<div className="ds-table-wrap"><table className="table v2">
|
||||
<thead><tr><th>{t('gr.change')}</th><th>{t('col.date')}</th><th className="right">{t('gr.before')}</th><th className="right">{t('gr.after')}</th><th className="right">{t('col.diff')}</th></tr></thead>
|
||||
<tbody>
|
||||
{effects.map((e, i) => {
|
||||
|
|
@ -79,17 +99,17 @@ function Analytics({ projectId, hasApple, hasPlay, name, playReports }: { projec
|
|||
<td className="nowrap">{date(e.event.date)}</td>
|
||||
<td className="right num">{pct(e.before.conversion ?? null, 1)}<div className="small muted">{t('gr.daysN', { count: e.daysBefore })}</div></td>
|
||||
<td className="right num">{pct(e.after.conversion ?? null, 1)}<div className="small muted">{t('gr.daysN', { count: e.daysAfter })}</div></td>
|
||||
<td className="right">{dlt === null ? '–' : <Chip tone={dlt >= 0 ? 'ok' : 'warn'} dot={false}>{`${dlt >= 0 ? '+' : ''}${num(dlt, 1)} %`}</Chip>}</td>
|
||||
<td className="right">{dlt === null ? '–' : <StatusPill tone={dlt >= 0 ? 'ok' : 'warn'}>{`${dlt >= 0 ? '+' : ''}${num(dlt, 1)} %`}</StatusPill>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
)}
|
||||
<p className="small muted">{t('gr.reading')}</p>
|
||||
<p className="gr-note">{t('gr.reading')}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -106,20 +126,20 @@ function Aso({ projectId }: { projectId: string }) {
|
|||
ranks: a.ranks.series.find(s => s.storefront === row.storefront && s.keyword === row.keyword)?.ranks ?? [],
|
||||
})) : [];
|
||||
return (
|
||||
<Card title={t('gr.aso')} icon="search" sub={a ? t('gr.asoSub', { date: date(a.generated) }) : t('gr.asoSubNone')} pad={false}
|
||||
<Section title={t('gr.aso')} icon="search" sub={a ? t('gr.asoSub', { date: date(a.generated) }) : t('gr.asoSubNone')}
|
||||
actions={<ReadControl r={r} />}>
|
||||
{r.error ? <div className="card-body"><ErrorNotice error={r.error} /></div> : !a ? <div className="card-body"><Skeleton lines={4} block /></div> : (
|
||||
<div className="card-body stack" style={{ gap: 16 }}>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="text" label={t('gr.asoField')} value={a.keywordField.errors ? t('gr.asoErrors', { count: a.keywordField.errors }) : t('gr.asoWarnings', { count: a.keywordField.warnings })}
|
||||
{r.error ? <ErrorNotice error={r.error} /> : !a ? <Skeleton lines={4} block /> : (
|
||||
<div className="gr-body">
|
||||
<div className="gr-tiles">
|
||||
<StatTile icon="text" label={t('gr.asoField')} value={a.keywordField.errors ? t('gr.asoErrors', { count: a.keywordField.errors }) : t('gr.asoWarnings', { count: a.keywordField.warnings })}
|
||||
tone={a.keywordField.errors ? 'bad' : a.keywordField.warnings ? 'warn' : 'ok'} sub={a.drafts.length ? t('gr.asoDrafts', { locales: a.drafts.join(', ') }) : t('gr.asoFieldSub')} />
|
||||
<Kpi icon="activity" label={t('gr.asoRanked')} value={a.ranks.snapshots ? `${num(ranked.length)} / ${num(a.ranks.rows.length)}` : '–'}
|
||||
<StatTile icon="activity" label={t('gr.asoRanked')} value={a.ranks.snapshots ? `${num(ranked.length)} / ${num(a.ranks.rows.length)}` : '–'}
|
||||
sub={a.ranks.latest ? t('gr.asoLatest', { date: date(a.ranks.latest) }) : t('gr.asoNoRanks')} />
|
||||
<Kpi icon="search" label={t('gr.asoGaps')} value={a.competitors.date ? num(gaps) : '–'} sub={a.competitors.date ? t('gr.asoGapsSub', { date: date(a.competitors.date) }) : t('gr.asoNoCompetitors')} />
|
||||
<StatTile icon="search" label={t('gr.asoGaps')} value={a.competitors.date ? num(gaps) : '–'} sub={a.competitors.date ? t('gr.asoGapsSub', { date: date(a.competitors.date) }) : t('gr.asoNoCompetitors')} />
|
||||
</div>
|
||||
{a.ranks.dates.length > 1 && lines.length > 0 && <RankChart dates={a.ranks.dates} lines={lines} label={t('gr.asoChartAria')} noRank={t('gr.asoNotRanked')} />}
|
||||
{a.ranks.rows.length > 0 && (
|
||||
<table className="table">
|
||||
<div className="ds-table-wrap"><table className="table v2">
|
||||
<thead><tr><th>{t('gr.asoKeyword')}</th><th>{t('col.store')}</th><th className="right">{t('gr.asoRank')}</th><th className="right">{t('col.diff')}</th><th className="right">{t('gr.asoBest')}</th></tr></thead>
|
||||
<tbody>
|
||||
{a.ranks.rows.slice(0, 8).map(x => (
|
||||
|
|
@ -127,25 +147,25 @@ function Aso({ projectId }: { projectId: string }) {
|
|||
<td className="strong">{x.keyword}</td>
|
||||
<td className="mono-id">{x.storefront}</td>
|
||||
<td className="right num">{x.rank === null ? '–' : `#${x.rank}`}</td>
|
||||
<td className="right"><Chip tone={arrowTone(x.arrow)} dot={false}>{x.change === null ? x.arrow : `${x.arrow} ${x.change > 0 ? '+' : ''}${x.change}`}</Chip></td>
|
||||
<td className="right"><StatusPill tone={arrowTone(x.arrow)}>{x.change === null ? x.arrow : `${x.arrow} ${x.change > 0 ? '+' : ''}${x.change}`}</StatusPill></td>
|
||||
<td className="right num">{x.best === null ? '–' : `#${x.best}`}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
)}
|
||||
{a.next.length > 0 && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="act-head"><Icon name="check" />{t('gr.asoNext')}</div>
|
||||
<ol className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{a.next.map((x, i) => <li key={i}><span className="strong">{x.what}</span><div className="muted">{x.why}{x.command ? <> · <code className="mono">{x.command}</code></> : null}</div></li>)}
|
||||
<div className="gr-next">
|
||||
<div className="gr-next-h"><Icon name="check" />{t('gr.asoNext')}</div>
|
||||
<ol>
|
||||
{a.next.map((x, i) => <li key={i}><span className="gr-next-n num">{i + 1}</span><span className="gr-next-t"><b>{x.what}</b><span>{x.why}{x.command ? <> · <code className="mono">{x.command}</code></> : null}</span></span></li>)}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
<p className="small muted">{t('gr.asoReading')}</p>
|
||||
<p className="gr-note">{t('gr.asoReading')}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -164,21 +184,22 @@ function Experiments({ app }: { app: UnifiedApp }) {
|
|||
const editable = tests.find(x => !started(x) && !DONE.includes(x.state));
|
||||
const asc = app.ios ? ascUrl(app.ios.app.id) : ascUrl(d.statusFor(app)?.app?.platforms.ios?.ascAppId);
|
||||
return (
|
||||
<Card title={t('gr.ppo')} icon="flask" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}</span>} pad={false}
|
||||
<Section className="gr-ppo" title={t('gr.ppo')} icon="flask" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}</span>}
|
||||
actions={<>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(asc).catch(() => {})}><Icon name="external" />{t('gr.results')}</button>
|
||||
<ReadControl r={r} label={t('gr.ppoRead')} />
|
||||
</>}>
|
||||
{r.error && <div className="card-body"><ErrorNotice error={r.error} keep={!!r.data} /></div>}
|
||||
{!r.data ? (r.loading && !r.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('gr.ppoNotRead')}>{t('gr.ppoNotReadText')}</Empty>)
|
||||
: !tests.length ? <Empty compact illo="text" title={t('gr.ppoNone')}>{variants.length ? t('gr.ppoNoneText') : t('gr.ppoNoVariants')}</Empty> : (
|
||||
<table className="table">
|
||||
<div className="gr-body">
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
{!r.data ? (r.loading && !r.error ? <Skeleton lines={4} /> : <EmptyState icon="cloud"><b>{t('gr.ppoNotRead')}</b><br />{t('gr.ppoNotReadText')}</EmptyState>)
|
||||
: !tests.length ? <EmptyState icon="flask"><b>{t('gr.ppoNone')}</b><br />{variants.length ? t('gr.ppoNoneText') : t('gr.ppoNoVariants')}</EmptyState> : (
|
||||
<div className="ds-table-wrap"><table className="table v2">
|
||||
<thead><tr><th>{t('gr.test')}</th><th>{t('gr.state')}</th><th className="right">{t('gr.traffic')}</th><th>{t('gr.dates')}</th><th>{t('gr.treatments')}</th><th className="right">{t('gr.owner')}</th></tr></thead>
|
||||
<tbody>
|
||||
{tests.map(x => (
|
||||
<tr key={x.id}>
|
||||
<td className="strong">{x.name}</td>
|
||||
<td><Chip tone={stateTone(x.state)}>{x.state.replace(/_/g, ' ').toLowerCase()}</Chip></td>
|
||||
<td><StatusPill tone={stateTone(x.state)}>{x.state.replace(/_/g, ' ').toLowerCase()}</StatusPill></td>
|
||||
<td className="right num">{x.trafficProportion !== undefined ? `${x.trafficProportion} %` : '–'}</td>
|
||||
<td className="small nowrap">{x.startDate ? `${date(x.startDate)} → ${x.endDate ? date(x.endDate) : t('gr.running')}` : t('gr.notStarted')}</td>
|
||||
<td className="small">{x.treatments.length ? x.treatments.map(tr => t('gr.treatment', { name: tr.name, count: tr.locales })).join(' · ') : '–'}</td>
|
||||
|
|
@ -190,10 +211,10 @@ function Experiments({ app }: { app: UnifiedApp }) {
|
|||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
)}
|
||||
{app.project ? (
|
||||
<div className="card-body" style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<div className="gr-ppo-act">
|
||||
{!variants.length ? <Notice tone="info" icon="info">{t('gr.ppoNoVariants')}</Notice> : (
|
||||
<div className="act-grid">
|
||||
<div className="act-col">
|
||||
|
|
@ -215,37 +236,39 @@ function Experiments({ app }: { app: UnifiedApp }) {
|
|||
)}
|
||||
<p className="small muted act-note"><Icon name="shield" />{t('gr.ppoNote')}</p>
|
||||
</div>
|
||||
) : <div className="card-foot"><Icon name="info" />{t('gr.ppoStoreOnly')}</div>}
|
||||
</Card>
|
||||
) : <div className="gr-foot"><Icon name="info" />{t('gr.ppoStoreOnly')}</div>}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayExperiments({ app }: { app: UnifiedApp }) {
|
||||
const nav = useNav();
|
||||
return (
|
||||
<Card title={t('gr.playExp')} icon="flask" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('gr.playExpSub')}</span>}
|
||||
<Section title={t('gr.playExp')} icon="flask" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('gr.playExpSub')}</span>}
|
||||
actions={<button className="btn btn-sm" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>}>
|
||||
<div className="stack" style={{ gap: 10 }}>
|
||||
<p className="small">{t('gr.playExpText')}</p>
|
||||
<div className="gr-body">
|
||||
<p className="gr-text">{t('gr.playExpText')}</p>
|
||||
{app.project && <div><button className="btn btn-sm" onClick={() => nav.go({ screen: 'app', appId: app.key, tab: 'art' })}><Icon name="grid" />{t('gr.seeVariants')}</button></div>}
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GrowthTab({ app }: { app: UnifiedApp }) {
|
||||
const d = useData();
|
||||
const hue = useTint(d.iconFor(app), app.name);
|
||||
const s = d.statusFor(app)?.summary;
|
||||
const hasApple = !!(s?.apple ?? app.ios), hasPlay = !!(s?.play ?? app.android);
|
||||
if (app.project && !s) return <Card><Skeleton lines={5} block /></Card>;
|
||||
if (app.project && !s) return <div className="scr-growth" style={tintStyle(hue)}><Section><Skeleton lines={5} block /></Section></div>;
|
||||
return (
|
||||
<>
|
||||
<div className="scr-growth" style={tintStyle(hue)}>
|
||||
{app.project && <Analytics projectId={app.project.id} hasApple={hasApple} hasPlay={hasPlay} name={s?.name ?? app.name} playReports={!!s?.playReports} />}
|
||||
{app.project && (hasApple || hasPlay) && <Aso projectId={app.project.id} />}
|
||||
<div className={`grid ${hasApple && hasPlay ? 'g-main-side' : ''}`} style={{ alignItems: 'start' }}>
|
||||
<div className={`gr-exp${hasApple && hasPlay ? ' two' : ''}`}>
|
||||
{hasApple && <Experiments app={app} />}
|
||||
{hasPlay && <PlayExperiments app={app} />}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,31 +9,56 @@ import { api } from '../../api';
|
|||
import { appleSignatureView, hintFor, type IssueView, issueMarkdown, playIssueView } from '../../lib/issues';
|
||||
import type { IssueSample } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit';
|
||||
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
|
||||
import { Bar, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds';
|
||||
import '../../styles/health.css';
|
||||
import { exportName, healthMd } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
import { StoreReadNotice } from '../../ui/StoreError';
|
||||
|
||||
function Metric({ title, s }: { title: string; s: Series }) {
|
||||
const toneWord = (tone: Series['tone']): string => (tone === 'bad' ? t('health.over') : tone === 'warn' ? t('health.near') : tone === 'ok' ? t('health.under') : t('common.unknown'));
|
||||
|
||||
/** One big metric tile: the 28-day value, its state against the threshold, a large daily sparkline and its axis. */
|
||||
function Metric({ title, s, icon }: { title: string; s: Series; icon: IconName }) {
|
||||
return (
|
||||
<Card title={title} icon="activity" sub={t('health.window', { from: date(s.dates[0]), to: date(s.dates.at(-1)) })}
|
||||
actions={<Chip tone={s.tone === 'neutral' ? 'neutral' : s.tone}>{s.tone === 'bad' ? t('health.over') : s.tone === 'warn' ? t('health.near') : s.tone === 'ok' ? t('health.under') : t('common.unknown')}</Chip>}>
|
||||
<div className="metric">
|
||||
<div className="metric-top">
|
||||
<div>
|
||||
<div className={`metric-val ${s.tone}`}>{pct(s.current)}</div>
|
||||
<div className="small muted">{t('health.current28')}</div>
|
||||
</div>
|
||||
<div className="metric-legend">
|
||||
<span className="lg"><span className={`sw ${s.tone}`} />{t('health.daily')}</span>
|
||||
<span className="lg"><span className="sw thr" />{t('health.threshold', { value: pct(s.threshold) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline values={s.values} dates={s.dates} threshold={s.threshold} tone={s.tone} label={t('health.chartAria', { metric: title, value: pct(s.current) })} />
|
||||
<div className="axis"><span>{date(s.dates[0])}</span><span>{date(s.dates[Math.floor(s.dates.length / 2)])}</span><span>{date(s.dates.at(-1))}</span></div>
|
||||
<section className={`hl-tile tone-${s.tone}`} aria-label={title}>
|
||||
<div className="hl-tile-h">
|
||||
<span className="hl-tile-l"><Icon name={icon} />{title}</span>
|
||||
<StatusPill tone={s.tone}>{toneWord(s.tone)}</StatusPill>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="hl-tile-v">
|
||||
<span className={`hl-big num ${s.tone}`}>{pct(s.current)}</span>
|
||||
<span className="hl-tile-sub">{t('health.current28')}</span>
|
||||
</div>
|
||||
<Sparkline values={s.values} dates={s.dates} threshold={s.threshold} tone={s.tone} label={t('health.chartAria', { metric: title, value: pct(s.current) })} />
|
||||
<div className="hl-axis"><span>{date(s.dates[0])}</span><span>{date(s.dates[Math.floor(s.dates.length / 2)])}</span><span>{date(s.dates.at(-1))}</span></div>
|
||||
<div className="hl-legend">
|
||||
<span className="lg"><span className={`sw ${s.tone}`} />{t('health.daily')}</span>
|
||||
<span className="lg"><span className="sw thr" />{t('health.threshold', { value: pct(s.threshold) })}</span>
|
||||
<span className="hl-win">{t('health.window', { from: date(s.dates[0]), to: date(s.dates.at(-1)) })}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Launch (startup) time from the App Store power and performance metrics, as the third big tile. */
|
||||
function Startup({ h }: { h: AppleHealth }) {
|
||||
const m = h.metrics.find(x => /launch/i.test(`${x.category} ${x.metric}`));
|
||||
if (!m) return null;
|
||||
const tone = m.goal === 'poor' ? 'bad' : m.goal === 'fair' ? 'warn' : m.goal ? 'ok' : 'neutral';
|
||||
return (
|
||||
<section className={`hl-tile tone-${tone}`} aria-label={t('v2ins.hStartup')}>
|
||||
<div className="hl-tile-h">
|
||||
<span className="hl-tile-l"><Icon name="rocket" />{t('v2ins.hStartup')}</span>
|
||||
{m.goal ? <StatusPill tone={tone}>{m.goal}</StatusPill> : null}
|
||||
</div>
|
||||
<div className="hl-tile-v">
|
||||
<span className={`hl-big num ${tone}`}>{num(m.value, m.value < 10 ? 2 : 0)}<small>{m.unit}</small></span>
|
||||
<span className="hl-tile-sub">{m.metric}</span>
|
||||
</div>
|
||||
<div className="hl-startup-meta"><StoreGlyph store="ios" className="ic13" />{t('v2ins.hStartupSub', { version: m.version })}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -76,66 +101,58 @@ function IssueDetail({ app, v }: { app: UnifiedApp; v: IssueView }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const crash = healthSeries(h.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
|
||||
const anr = healthSeries(h.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr);
|
||||
const devices = (h.devices ?? []).slice(0, 6);
|
||||
const worst = Math.max(PLAY_THRESHOLDS.perDevice, ...devices.map(x => Math.max(x.crash ?? 0, x.anr ?? 0)));
|
||||
/** An issue as a card: a header button (type, title, where, counts) that opens its detail below (Enter / Space). */
|
||||
function IssueCard({ open, onToggle, tone, kind, title, where, stats, detail }: { open: boolean; onToggle: () => void; tone: 'bad' | 'warn' | 'info'; kind: string; title: string; where?: string; stats: React.ReactNode; detail: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid g2">
|
||||
<Metric title={t('health.crash')} s={crash} />
|
||||
<Metric title={t('health.anr')} s={anr} />
|
||||
</div>
|
||||
<div className="grid g2">
|
||||
<Card title={t('health.devices')} icon="phone" sub={t('health.devicesSub', { value: pct(PLAY_THRESHOLDS.perDevice, 0) })}>
|
||||
{devices.length ? (
|
||||
<div className="hbars">
|
||||
{devices.map(x => {
|
||||
const v = Math.max(x.crash ?? 0, x.anr ?? 0);
|
||||
return (
|
||||
<div className="hbar" key={x.model}>
|
||||
<span className="nm">{x.model}</span>
|
||||
<Bar value={v / worst} tone={v >= PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} />
|
||||
<span className="num right small">{pct(v)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <p className="muted">{t('health.noDevices')}</p>}
|
||||
</Card>
|
||||
<Card title={t('health.issues')} icon="alert" sub={t('health.issuesSub')} pad={false}>
|
||||
{h.issues.length ? (
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.issue')}</th><th className="right">{t('col.reports')}</th><th className="right">{t('col.users')}</th></tr></thead>
|
||||
<tbody>
|
||||
{h.issues.slice(0, 6).map((i, n) => (
|
||||
<IssueRows key={n} open={open === n} onToggle={() => setOpen(open === n ? null : n)} cols={3}
|
||||
head={<><div className="row" style={{ gap: 8, minWidth: 0 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong ellipsis" title={i.cause || '?'}>{i.cause || '?'}</span></div>
|
||||
<div className="mono-id muted ellipsis" style={{ marginTop: 4 }} title={i.location}>{i.location}</div></>}
|
||||
cells={<><td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td></>}
|
||||
detail={<IssueDetail app={app} v={playIssueView(i)} />} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <Empty compact illo="shield" title={t('health.noIssues')} />}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
<div className={`hl-issue tone-${tone}${open ? ' open' : ''}`}>
|
||||
<button type="button" className="hl-issue-h issue-row" onClick={onToggle} aria-expanded={open}>
|
||||
<Icon name="chevronRight" className={`ic13 chev${open ? ' rot' : ''}`} />
|
||||
<span className="hl-issue-t">
|
||||
<span className="hl-issue-top"><StatusPill tone={tone}>{kind}</StatusPill><span className="hl-issue-title" title={title}>{title}</span></span>
|
||||
{where && <span className="hl-issue-where mono-id" title={where}>{where}</span>}
|
||||
</span>
|
||||
<span className="hl-issue-stats">{stats}</span>
|
||||
</button>
|
||||
{open && <div className="hl-issue-b">{detail}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A table row that opens its detail row below it (click or Enter / Space). */
|
||||
function IssueRows({ open, onToggle, head, cells, detail, cols }: { open: boolean; onToggle: () => void; head: React.ReactNode; cells: React.ReactNode; detail: React.ReactNode; cols: number }) {
|
||||
function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const devices = (h.devices ?? []).slice(0, 6);
|
||||
const worst = Math.max(PLAY_THRESHOLDS.perDevice, ...devices.map(x => Math.max(x.crash ?? 0, x.anr ?? 0)));
|
||||
return (
|
||||
<>
|
||||
<tr className={`issue-row${open ? ' open' : ''}`} onClick={onToggle} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }} tabIndex={0} aria-expanded={open} role="button">
|
||||
<td style={{ maxWidth: 380 }}><div className="row" style={{ gap: 6, minWidth: 0 }}><Icon name="chevronRight" className={`ic13 chev${open ? ' rot' : ''}`} /><div style={{ minWidth: 0 }}>{head}</div></div></td>
|
||||
{cells}
|
||||
</tr>
|
||||
{open && <tr className="edit-row"><td colSpan={cols}>{detail}</td></tr>}
|
||||
</>
|
||||
<div className="hl-two">
|
||||
<Section title={t('health.issues')} icon="alert" sub={t('health.issuesSub')}>
|
||||
{h.issues.length ? (
|
||||
<div className="hl-issues">
|
||||
{h.issues.slice(0, 6).map((i, n) => (
|
||||
<IssueCard key={n} open={open === n} onToggle={() => setOpen(open === n ? null : n)} tone={i.type === 'ANR' ? 'warn' : 'bad'}
|
||||
kind={i.type === 'ANR' ? 'ANR' : t('health.crashShort')} title={i.cause || '?'} where={i.location}
|
||||
stats={<><span><b className="num">{num(i.reports)}</b>{t('col.reports')}</span><span><b className="num">{num(i.users)}</b>{t('col.users')}</span></>}
|
||||
detail={<IssueDetail app={app} v={playIssueView(i)} />} />
|
||||
))}
|
||||
</div>
|
||||
) : <EmptyState icon="shield" tone="ok">{t('health.noIssues')}</EmptyState>}
|
||||
</Section>
|
||||
<Section title={t('health.devices')} icon="phone" sub={t('health.devicesSub', { value: pct(PLAY_THRESHOLDS.perDevice, 0) })}>
|
||||
{devices.length ? (
|
||||
<div className="hl-bars">
|
||||
{devices.map(x => {
|
||||
const v = Math.max(x.crash ?? 0, x.anr ?? 0);
|
||||
return (
|
||||
<div className="hl-bar" key={x.model}>
|
||||
<span className="nm">{x.model}</span>
|
||||
<Bar value={v / worst} tone={v >= PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} />
|
||||
<span className="num">{pct(v)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <EmptyState icon="phone">{t('health.noDevices')}</EmptyState>}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -143,70 +160,83 @@ function Apple({ h, app }: { h: AppleHealth; app: UnifiedApp }) {
|
|||
const [open, setOpen] = useState<number | null>(null);
|
||||
const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count));
|
||||
return (
|
||||
<div className="grid g2">
|
||||
<Card title={t('health.appleMetrics')} icon="activity" sub={t('health.appleMetricsSub')} pad={false}>
|
||||
{h.metrics.length ? (
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.metric')}</th><th className="right">{t('col.value')}</th><th>{t('col.version')}</th><th>{t('col.goal')}</th></tr></thead>
|
||||
<tbody>
|
||||
<div className="hl-two">
|
||||
<Section title={t('health.signatures')} icon="layers" sub={t('health.signaturesSub')}>
|
||||
{h.signatures.length ? (
|
||||
<div className="hl-issues">
|
||||
{h.signatures.slice(0, 5).map((s, i) => (
|
||||
<IssueCard key={i} open={open === i} onToggle={() => setOpen(open === i ? null : i)} tone="bad" kind={s.type}
|
||||
title={s.signature || s.type} where={t('health.build', { build: s.build })}
|
||||
stats={<span><b className="num">{num(s.weight * 100, 1)}%</b></span>}
|
||||
detail={<IssueDetail app={app} v={appleSignatureView(s)} />} />
|
||||
))}
|
||||
</div>
|
||||
) : <EmptyState icon="shield" tone="ok">{t('health.noSignatures')}</EmptyState>}
|
||||
</Section>
|
||||
<div className="hl-col">
|
||||
<Section title={t('health.appleMetrics')} icon="activity" sub={t('health.appleMetricsSub')}>
|
||||
{h.metrics.length ? (
|
||||
<div className="hl-mets">
|
||||
{h.metrics.slice(0, 8).map((m, i) => (
|
||||
<tr key={i}><td><div className="strong">{m.metric}</div><div className="small muted">{m.category}</div></td><td className="right num strong">{num(m.value, m.value < 10 ? 2 : 0)} <span className="muted small">{m.unit}</span></td>
|
||||
<td className="num muted">{m.version}</td><td>{m.goal ? <Chip tone={m.goal === 'poor' ? 'bad' : m.goal === 'fair' ? 'warn' : 'ok'}>{m.goal}</Chip> : '–'}</td></tr>
|
||||
<div className="hl-met" key={i}>
|
||||
<span className="hl-met-t"><b>{m.metric}</b><small>{m.category} · {m.version}</small></span>
|
||||
<span className="hl-met-v num">{num(m.value, m.value < 10 ? 2 : 0)} <small>{m.unit}</small></span>
|
||||
{m.goal ? <StatusPill tone={m.goal === 'poor' ? 'bad' : m.goal === 'fair' ? 'warn' : 'ok'}>{m.goal}</StatusPill> : <span className="muted">–</span>}
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <Empty compact illo="pulse" title={t('health.noMetrics')} />}
|
||||
</Card>
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Card title={t('health.signatures')} icon="layers" sub={t('health.signaturesSub')} pad={false}>
|
||||
{h.signatures.length ? (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
{h.signatures.slice(0, 5).map((s, i) => (
|
||||
<IssueRows key={i} open={open === i} onToggle={() => setOpen(open === i ? null : i)} cols={2}
|
||||
head={<><div className="strong ellipsis" title={s.signature || s.type}>{s.signature || s.type}</div><div className="small muted">{s.type} · {t('health.build', { build: s.build })}</div></>}
|
||||
cells={<td className="right num small strong">{num(s.weight * 100, 1)}%</td>}
|
||||
detail={<IssueDetail app={app} v={appleSignatureView(s)} />} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <Empty compact illo="shield" title={t('health.noSignatures')} />}
|
||||
</Card>
|
||||
<Card title={t('health.betaCrashes')} icon="plane">
|
||||
</div>
|
||||
) : <EmptyState icon="activity">{t('health.noMetrics')}</EmptyState>}
|
||||
</Section>
|
||||
<Section title={t('health.betaCrashes')} icon="plane">
|
||||
{h.betaCrashes.length ? (
|
||||
<div className="hbars">{h.betaCrashes.slice(0, 5).map(b => (
|
||||
<div className="hbar" key={b.build}><span className="nm">{t('health.build', { build: b.build })}</span><Bar value={b.count / maxCrash} tone="warn" label={b.build} /><span className="num right small">{num(b.count)}</span></div>
|
||||
<div className="hl-bars">{h.betaCrashes.slice(0, 5).map(b => (
|
||||
<div className="hl-bar" key={b.build}><span className="nm">{t('health.build', { build: b.build })}</span><Bar value={b.count / maxCrash} tone="warn" label={b.build} /><span className="num">{num(b.count)}</span></div>
|
||||
))}</div>
|
||||
) : <p className="muted">{t('health.noBetaCrashes')}</p>}
|
||||
</Card>
|
||||
) : <EmptyState icon="plane">{t('health.noBetaCrashes')}</EmptyState>}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthTab({ app }: { app: UnifiedApp }) {
|
||||
const d = useData();
|
||||
const hue = useTint(d.iconFor(app), app.name);
|
||||
const r = useRead<HealthSnapshot>(app.target, 'health', { network: true, title: t('tab.health'), appName: app.name });
|
||||
useExports('health', [{ id: 'health-md', label: t('export.health'), icon: 'activity', kind: 'md', file: exportName(app.name, 'health'), disabled: r.data ? false : t('export.readFirst'), build: () => healthMd(app.name, r.data!) }]);
|
||||
if (!r.data) {
|
||||
if (r.loading && !r.error) return <Card><Skeleton lines={4} block /></Card>;
|
||||
if (r.loading && !r.error) return <div className="scr-health" style={tintStyle(hue)}><Section><Skeleton lines={4} block /></Section></div>;
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<Empty illo="pulse" title={t('health.emptyTitle')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('health.read')}</button>}>{t('health.emptyText')}</Empty>
|
||||
{r.error && <ErrorFoot error={r.error} />}
|
||||
</Card>
|
||||
<div className="scr-health" style={tintStyle(hue)}>
|
||||
<Section>
|
||||
<EmptyState icon="activity" action={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('health.read')}</button>}>
|
||||
<b>{t('health.emptyTitle')}</b><br />{t('health.emptyText')}
|
||||
</EmptyState>
|
||||
{r.error && <ErrorFoot error={r.error} />}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const h = r.data;
|
||||
const play = h.play !== undefined && !isError(h.play) ? h.play : null;
|
||||
const apple = h.apple !== undefined && !isError(h.apple) ? h.apple : null;
|
||||
const crash = play ? healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash) : null;
|
||||
const anr = play ? healthSeries(play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr) : null;
|
||||
const tiles = !!crash || !!(apple && apple.metrics.some(x => /launch/i.test(`${x.category} ${x.metric}`)));
|
||||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span />
|
||||
<ReadControl r={r} />
|
||||
</div>
|
||||
<h2 className="label">Google Play</h2>
|
||||
<div className="scr-health" style={tintStyle(hue)}>
|
||||
<div className="hl-top"><span className="grow" /><ReadControl r={r} /></div>
|
||||
{tiles && (
|
||||
<div className="hl-tiles">
|
||||
{crash && <Metric title={t('health.crash')} s={crash} icon="alertCircle" />}
|
||||
{anr && <Metric title={t('health.anr')} s={anr} icon="clock" />}
|
||||
{apple && <Startup h={apple} />}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="hl-store"><StoreGlyph store="android" />Google Play</h2>
|
||||
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <>{h.play.issuesError && <ErrorNotice error={t('health.issuesUnread', { error: h.play.issuesError })} />}<Play h={h.play} app={app} /></>}
|
||||
<h2 className="label">App Store</h2>
|
||||
<h2 className="hl-store"><StoreGlyph store="ios" />App Store</h2>
|
||||
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} app={app} />}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Listings: per language and store, which listing fields are written and within the store limits (from the listing
|
||||
// files in the app folder), and how many screenshots are rendered per language.
|
||||
import '../../styles/listings.css';
|
||||
import { useState } from 'react';
|
||||
import type { ListingField, ListingLocale, ListingsResult, Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
|
|
@ -7,7 +8,8 @@ import { ago, num } from '../../lib/format';
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, Skeleton } from '../../ui/kit';
|
||||
import { Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatTile, StatusPill } from '../../ui/ds';
|
||||
import { reads } from '../../api';
|
||||
import { exportName, listingsMd } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
|
|
@ -39,25 +41,41 @@ function Ring({ value }: { value: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: ListingLocale[] }) {
|
||||
const fields = locales.find(l => l[store])?.[store]?.fields.map(f => f.field) ?? [];
|
||||
const done = (l: ListingLocale) => {
|
||||
const fs = l[store]?.fields.filter(f => f.required) ?? [];
|
||||
return fs.length ? fs.filter(f => f.state === 'ok').length / fs.length : 0;
|
||||
};
|
||||
/** One language as the store page shows it: the fields in store order, each with its length against the limit. */
|
||||
function LangPage({ store, l }: { store: 'play' | 'apple'; l: ListingLocale }) {
|
||||
const fs = l[store]?.fields ?? [];
|
||||
const req = fs.filter(f => f.required);
|
||||
const v = req.length ? req.filter(f => f.state === 'ok').length / req.length : 0;
|
||||
const bad = fs.filter(f => f.state === 'missing' || f.state === 'over').length;
|
||||
return (
|
||||
<Card title={store === 'play' ? 'Google Play' : 'App Store'} icon="text" sub={store === 'play' ? t('lst.playSub') : t('lst.appleSub')} pad={false}>
|
||||
<div className="lg-grid" style={{ ['--cols' as string]: fields.length }}>
|
||||
<div className="lg-row head"><span>{t('col.language')}</span>{fields.map(f => <span key={f}>{t(FIELD_KEY[f] ?? 'lst.title')}</span>)}<span style={{ textAlign: 'right' }}>{t('lst.complete')}</span></div>
|
||||
{locales.map(l => (
|
||||
<div className="lg-row" key={l.lang}>
|
||||
<div className="lg-lang"><span className="mono-id strong">{l.lang}</span><span className="small muted">{l[store]?.code ?? ''}</span>{l.rtl && <Chip tone="outline">RTL</Chip>}</div>
|
||||
{fields.map(f => <FieldCell key={f} f={l[store]?.fields.find(x => x.field === f)} />)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}><Ring value={done(l)} /></div>
|
||||
<article className={`lst-page${bad ? ' bad' : v >= 1 ? ' done' : ''}`} data-lang={l.lang} aria-label={`${store === 'play' ? 'Google Play' : 'App Store'} ${l.lang}`}>
|
||||
<header className="lst-page-h">
|
||||
<span className="lst-lang mono-id">{l.lang}</span>
|
||||
<span className="small muted">{l[store]?.code ?? ''}</span>
|
||||
{l.rtl && <Chip tone="outline">RTL</Chip>}
|
||||
<span className="grow" />
|
||||
{bad > 0 ? <StatusPill tone="bad">{t('v2lst.toFix', { count: bad })}</StatusPill> : v >= 1 ? <StatusPill tone="ok">{t('v2lst.ready')}</StatusPill> : null}
|
||||
<Ring value={v} />
|
||||
</header>
|
||||
<div className="lst-fields">
|
||||
{fs.map(f => (
|
||||
<div className={`lst-f f-${f.field}`} key={f.field}>
|
||||
<span className="lst-f-l">{t(FIELD_KEY[f.field] ?? 'lst.title')}</span>
|
||||
<FieldCell f={f} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: ListingLocale[] }) {
|
||||
return (
|
||||
<Section title={<><StoreGlyph store={store === 'play' ? 'android' : 'ios'} />{store === 'play' ? 'Google Play' : 'App Store'}</>} icon="text" sub={store === 'play' ? t('lst.playSub') : t('lst.appleSub')}>
|
||||
<div className="lst-pages">
|
||||
{locales.filter(l => l[store]).map(l => <LangPage key={l.lang} store={store} l={l} />)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +89,7 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] })
|
|||
const ios = !!e?.app?.platforms.ios, android = !!e?.app?.platforms.android;
|
||||
const only = lang ? { lang } : {};
|
||||
return (
|
||||
<Card title={t('act.storeTitle')} icon="upload" sub={t('act.storeSub')}>
|
||||
<Section title={t('act.storeTitle')} icon="upload" sub={t('act.storeSub')} className="lst-actions">
|
||||
<div className="act-grid">
|
||||
{android && (
|
||||
<div className="act-col">
|
||||
|
|
@ -99,7 +117,7 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] })
|
|||
</div>
|
||||
</div>
|
||||
<p className="small muted act-note"><Icon name="shield" />{t('act.note')}</p>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +128,7 @@ export function ListingsTab({ project }: { project: Project }) {
|
|||
build: async () => { const x = await reads.listingText(project.id); if (!x.data) throw new Error(x.output || t('export.failed')); return listingsMd(project.name, x.data); } }]);
|
||||
if (!r.data) {
|
||||
if (r.error) return <ErrorNotice error={r.error} tone="bad" />;
|
||||
return <Card><Skeleton lines={6} block /></Card>;
|
||||
return <div className="scr-listings"><Section><Skeleton lines={6} block /></Section></div>;
|
||||
}
|
||||
const L = r.data;
|
||||
const missing = L.locales.reduce((n, l) => n + [...(l.play?.fields ?? []), ...(l.apple?.fields ?? [])].filter(f => f.state === 'missing' || f.state === 'over').length, 0);
|
||||
|
|
@ -118,20 +136,19 @@ export function ListingsTab({ project }: { project: Project }) {
|
|||
const shots = L.locales.reduce((n, l) => n + (l.screenshots ?? 0), 0);
|
||||
const noFiles = !L.files.play && !L.files.apple;
|
||||
return (
|
||||
<>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="globe" label={t('lst.languages')} value={L.locales.length} sub={L.locales.map(l => l.lang).join(' · ')} />
|
||||
<Kpi icon="check" label={t('lst.completeKpi')} value={noFiles ? '–' : `${Math.round(avg * 100)}%`} sub={t('lst.completeSub')} tone={!noFiles && avg < 1 ? 'warn' : undefined} />
|
||||
<Kpi icon="alert" label={t('lst.toFix')} value={noFiles ? '–' : missing} sub={t('lst.toFixSub')} tone={missing ? 'bad' : undefined} />
|
||||
<Kpi icon="image" label={t('lst.screenshots')} value={L.locales.some(l => l.screenshots !== null) ? shots : '–'} sub={L.locales.some(l => l.screenshots !== null) ? t('lst.screenshotsSub') : t('lst.noRenders')} />
|
||||
<div className="scr-listings">
|
||||
<div className="ds-grid lst-stats">
|
||||
<StatTile icon="globe" label={t('lst.languages')} value={L.locales.length} sub={L.locales.map(l => l.lang).join(' · ')} />
|
||||
<StatTile icon="check" label={t('lst.completeKpi')} value={noFiles ? '–' : `${Math.round(avg * 100)}%`} sub={t('lst.completeSub')} tone={!noFiles && avg < 1 ? 'warn' : undefined} />
|
||||
<StatTile icon="alert" label={t('lst.toFix')} value={noFiles ? '–' : missing} sub={t('lst.toFixSub')} tone={missing ? 'bad' : undefined} />
|
||||
<StatTile icon="image" label={t('lst.screenshots')} value={L.locales.some(l => l.screenshots !== null) ? shots : '–'} sub={L.locales.some(l => l.screenshots !== null) ? t('lst.screenshotsSub') : t('lst.noRenders')} />
|
||||
</div>
|
||||
{L.problems.map((p, i) => <Notice key={i} tone="warn">{p}</Notice>)}
|
||||
{!noFiles && <StoreActions project={project} langs={L.locales.map(l => l.lang)} />}
|
||||
{noFiles ? (
|
||||
<Card pad={false}>
|
||||
<Empty illo="text" title={t('lst.noFilesTitle')}>{t('lst.noFilesText')}</Empty>
|
||||
<div className="card-foot"><Icon name="terminal" /><code className="selectable">storebound init</code><span>{t('lst.noFilesHint')}</span></div>
|
||||
</Card>
|
||||
<Section>
|
||||
<EmptyState icon="text" action={<div className="card-foot lst-foot-cmd"><Icon name="terminal" /><code className="selectable">storebound init</code><span>{t('lst.noFilesHint')}</span></div>}><b>{t('lst.noFilesTitle')}</b> {t('lst.noFilesText')}</EmptyState>
|
||||
</Section>
|
||||
) : (
|
||||
<>
|
||||
{L.files.play && <StoreGrid store="play" locales={L.locales} />}
|
||||
|
|
@ -139,6 +156,6 @@ export function ListingsTab({ project }: { project: Project }) {
|
|||
</>
|
||||
)}
|
||||
<p className="small muted">{t('lst.foot', { when: ago(r.at) })}</p>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@
|
|||
// thumbnails (art.thumb: one data: URL each, loaded as they scroll into view; the window loads no remote images
|
||||
// itself) and, for an app folder, whether the folder has the same file. "Pull from stores" runs art.pull (dry run,
|
||||
// native dialog, then the files are written: local only, read-only on the stores). Store-only apps: read-only.
|
||||
import '../../styles/art.css';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, type ArtThumb, type LiveArtResult, type LiveArtTile, type Project, type ToolResult } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { num } from '../../lib/format';
|
||||
import { useRead, useWrite } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { Section, StatusPill } from '../../ui/ds';
|
||||
|
||||
// thumbnails by target + URL, three at a time
|
||||
const cache = new Map<string, string | null>();
|
||||
|
|
@ -56,12 +58,12 @@ function LiveThumb({ target, tile, name }: { target: string; tile: LiveArtTile;
|
|||
const w = tile.w ?? 9, h = tile.h ?? 16;
|
||||
const state = tile.state === 'n/a' ? null : tile.state;
|
||||
return (
|
||||
<figure className={`art-tile${state === 'differs' ? ' warn' : ''}`} style={{ ['--ar' as string]: `${w} / ${h}` }} data-live-tile={tile.state}>
|
||||
<figure className={`art-tile${state === 'differs' ? ' warn' : ''} ${h > w ? 'portrait' : 'landscape'}`} style={{ ['--ar' as string]: `${w} / ${h}` }} data-live-tile={tile.state}>
|
||||
<div className="art-pic" ref={ref} role="img" aria-label={name}>
|
||||
{src ? <img src={src} alt="" draggable={false} /> : <span className="art-ph">{src === null && tile.thumb ? <><Icon name="image" />{t('art.liveNoThumb')}</> : <span className="sk sk-block" />}</span>}
|
||||
</div>
|
||||
<figcaption><span className="mono-id">{name}</span><span className="muted">{tile.w && tile.h ? `${tile.w}×${tile.h}` : ''}</span></figcaption>
|
||||
{state && <span className={`small ${state === 'same' ? 'ok-ink' : state === 'differs' ? 'warn-ink' : 'muted'}`}>{t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}</span>}
|
||||
{state && <StatusPill tone={state === 'same' ? 'ok' : state === 'differs' ? 'warn' : 'neutral'}>{t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}</StatusPill>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
|
@ -84,10 +86,10 @@ export function LiveArt({ target, project }: { target: string; project?: Project
|
|||
const actions = <>{pull}<ReadControl r={r} /></>;
|
||||
if (!data) {
|
||||
return (
|
||||
<Card title={t('art.liveTitle')} icon="cloud" actions={actions} id="live-art" pad={false}>
|
||||
<Section className="scr-art art-live" title={t('art.liveTitle')} icon="cloud" actions={actions} id="live-art" pad={false}>
|
||||
{r.loading && !r.error ? <div className="card-body"><Skeleton lines={4} block /></div>
|
||||
: <Empty compact illo="text" title={t('art.liveEmpty')} actions={<button className="btn btn-primary btn-sm" onClick={() => { r.refresh(); }}><Icon name="refresh" />{t('art.liveRead')}</button>}>{r.error ?? t('art.liveEmptyText')}</Empty>}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
const total = data.sets.reduce((n, s) => n + s.tiles.length, 0);
|
||||
|
|
@ -95,7 +97,7 @@ export function LiveArt({ target, project }: { target: string; project?: Project
|
|||
const missing = data.sets.reduce((n, s) => n + s.tiles.filter(x => x.state === 'missing').length, 0);
|
||||
const sub = data.stores.map(s => `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.ok ? t('art.liveCount', { count: s.images }) : t('art.liveFailed')}${s.version ? ` · ${s.version}` : ''}`).join(' · ');
|
||||
return (
|
||||
<Card title={t('art.liveTitle')} icon="cloud" sub={sub} actions={actions} id="live-art" pad={false}>
|
||||
<Section className="scr-art art-live" title={t('art.liveTitle')} icon="cloud" sub={sub} actions={actions} id="live-art" pad={false}>
|
||||
{data.stores.filter(s => !s.ok).map(s => <div className="card-body" key={s.store}><ErrorNotice error={`${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.error ?? ''}`} /></div>)}
|
||||
<div className="card-body stack" style={{ gap: 10 }}>
|
||||
{!project && <Notice tone="info" icon="info">{t('art.liveReadOnly')}</Notice>}
|
||||
|
|
@ -120,7 +122,7 @@ export function LiveArt({ target, project }: { target: string; project?: Project
|
|||
{sets.map(s => (
|
||||
<section className="art-row" key={`${s.store}-${s.type}`} data-live-set={`${s.store}:${s.locale}:${s.type}`}>
|
||||
<h3><span>{s.label}</span>{s.lang && <code>{s.lang}/{s.size}</code>}<Chip tone="outline" dot={false}>{s.store === 'apple' ? 'App Store' : 'Google Play'}</Chip><span className="muted small">{t('art.count', { count: s.tiles.length })}</span></h3>
|
||||
<div className="art-tiles" style={{ ['--tw' as string]: s.size === 'feature' ? '300px' : s.tiles[0] && (s.tiles[0].w ?? 0) > (s.tiles[0].h ?? 1) ? '260px' : '150px' }}>
|
||||
<div className="art-tiles" style={{ ['--tw' as string]: s.size === 'feature' ? '300px' : s.tiles[0] && (s.tiles[0].w ?? 0) > (s.tiles[0].h ?? 1) ? '260px' : '170px' }}>
|
||||
{s.tiles.map(tile => <LiveThumb key={`${tile.index}-${tile.thumb}`} target={target} tile={tile} name={String(tile.index + 1).padStart(2, '0')} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -128,6 +130,6 @@ export function LiveArt({ target, project }: { target: string; project?: Project
|
|||
</div>
|
||||
)}
|
||||
<div className="card-foot"><Icon name="info" />{project ? t('art.liveFoot') : t('art.liveFootStoreOnly')}</div>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Prices: what every product costs in every territory on both stores (`prices export --json`, read-only), the price
|
||||
// sheet as a CSV (save it anywhere, or write store/prices.csv for the terminal), and a price sheet compared with the
|
||||
// stores (`prices import --dry --json`) and applied as an owner action: dry run, native dialog, then the real run.
|
||||
import '../../styles/prices.css';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorText, type PricesCurrent, type PricesDiff, type Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
|
|
@ -10,7 +11,11 @@ import { changePct, diffSummary, priceMatrix, priceStats, type RegionRow, region
|
|||
import { type ReadState, useData, useRead, useWrite } from '../../store';
|
||||
import { type ExportItem, useExports, useRunExport } from '../../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { Section, StatTile } from '../../ui/ds';
|
||||
|
||||
/** A region code as a small badge (the stores use ISO country codes; longer names stay as text). */
|
||||
const Region = ({ code }: { code: string }) => <span className={`px-region${code.length <= 3 ? ' code' : ''}`}>{code}</span>;
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
||||
const GAP = 10;
|
||||
|
|
@ -18,7 +23,7 @@ const GAP = 10;
|
|||
function PriceStack({ r, apple, play }: { r: RegionRow | undefined; apple: boolean; play: boolean }) {
|
||||
if (!r) return <span className="muted small">–</span>;
|
||||
return (
|
||||
<div className="px-stack">
|
||||
<div className={`px-stack${r.gapPct !== null && r.gapPct >= GAP ? (r.gapPct > 25 ? ' gap-bad' : ' gap-warn') : ''}`}>
|
||||
{apple && <span className={`px-line${r.apple?.kind === 'auto' ? ' auto' : ''}`} title={r.apple?.kind === 'auto' ? t('prices.auto') : t('prices.manual')}><StoreGlyph store="ios" />{r.apple ? money(r.apple.price, r.apple.currency) : '–'}</span>}
|
||||
{play && <span className="px-line"><StoreGlyph store="android" />{r.play ? money(r.play.price, r.play.currency) : '–'}</span>}
|
||||
{r.gapPct !== null && r.gapPct >= GAP && <Chip tone={r.gapPct > 25 ? 'bad' : 'warn'} dot={false}>{`Δ ${num(r.gapPct, 0)} %`}</Chip>}
|
||||
|
|
@ -34,7 +39,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff
|
|||
const sum = d ? diffSummary(d.diff, d.changed) : null;
|
||||
const name = sheet.csv ? sheet.csv.split(/[\\/]/).pop() : d?.csv.split(/[\\/]/).pop() ?? 'prices.csv';
|
||||
return (
|
||||
<Card title={t('prices.diffTitle', { file: name ?? '' })} icon="layers" sub={d ? (diff.fromCache ? t('common.cachedFrom', { when: ago(diff.at) }) : t('common.readWhen', { when: ago(diff.at) })) : t('prices.diffSub')} pad={false}
|
||||
<Section title={t('prices.diffTitle', { file: name ?? '' })} icon="layers" sub={d ? (diff.fromCache ? t('common.cachedFrom', { when: ago(diff.at) }) : t('common.readWhen', { when: ago(diff.at) })) : t('prices.diffSub')} pad={false}
|
||||
actions={<>
|
||||
<button className="btn btn-sm" onClick={diff.refresh} disabled={diff.loading}><Icon name="refresh" className={diff.loading ? 'spin' : undefined} />{t('prices.compareAgain')}</button>
|
||||
{sum && sum.pairs > 0 && <WriteButton run={w} primary icon="upload" w={{ method: 'owner.prices_import', args: sheet, title: t('prices.apply', { count: sum.pairs }), done: t('prices.applied'), after: onApplied }}>{t('prices.apply', { count: sum.pairs })}</WriteButton>}
|
||||
|
|
@ -51,8 +56,8 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff
|
|||
{sum!.down > 0 && <Chip tone="ok" icon="arrowRight">{t('prices.dDown', { count: sum!.down })}</Chip>}
|
||||
<span className="small muted">{t('prices.dProducts', { list: sum!.products.join(', ') })}</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<div className="ds-table-wrap">
|
||||
<table className="table v2">
|
||||
<thead><tr><th>{t('col.item')}</th><th>{t('col.store')}</th><th>{t('col.region')}</th><th>{t('prices.colStarts')}</th><th className="right">{t('prices.colNow')}</th><th className="right">{t('prices.colNew')}</th><th className="right">{t('col.diff')}</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 200).map((r, i) => {
|
||||
|
|
@ -61,7 +66,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff
|
|||
<tr key={i}>
|
||||
<td className="mono-id strong">{r.product}</td>
|
||||
<td><span className="row small" style={{ gap: 6 }}><StoreGlyph store={r.store === 'apple' ? 'ios' : 'android'} className="ic13" />{r.store === 'apple' ? 'App Store' : 'Google Play'}</span></td>
|
||||
<td>{r.region}</td>
|
||||
<td><Region code={r.region} /></td>
|
||||
<td className="small muted">{r.startDate ?? t('prices.now')}</td>
|
||||
<td className="right num">{r.from === null ? '–' : money(r.from, r.currency)}</td>
|
||||
<td className="right num strong">{money(r.to, r.currency)}</td>
|
||||
|
|
@ -76,7 +81,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff
|
|||
<div className="card-foot"><Icon name="shield" />{t('prices.applyNote')}</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -109,18 +114,18 @@ export function PricesTab({ project }: { project: Project }) {
|
|||
try { const f = await api.pickPriceSheet(project.id); if (f) setSheet({ csv: f }); } catch (x) { d.toast({ kind: 'bad', title: t('prices.pickFailed'), detail: errorText(x) }); }
|
||||
};
|
||||
|
||||
if (!e) return <Card><Skeleton lines={5} block /></Card>;
|
||||
if (!e) return <div className="scr-prices"><Section><Skeleton lines={5} block /></Section></div>;
|
||||
if (!s?.products) {
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<Section className="scr-prices">
|
||||
<Empty illo="catalog" title={t('prices.emptyTitle')}>{t('prices.emptyText')}</Empty>
|
||||
<div className="card-foot"><Icon name="terminal" /><code className="selectable">products: [{'{'} id, type, usd {'}'}]</code><span>{t('prices.emptyHint')}</span></div>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Card title={t('prices.sheetTitle')} icon="coins" sub={t('prices.sheetSub')}
|
||||
<div className="scr-prices">
|
||||
<Section title={t('prices.sheetTitle')} icon="coins" sub={t('prices.sheetSub')}
|
||||
actions={<button className="btn btn-sm btn-primary" onClick={() => runExport(exportItem)} disabled={!p}><Icon name="download" />{t('prices.exportCsv')}</button>}>
|
||||
<div className="act-grid">
|
||||
<div className="act-col">
|
||||
|
|
@ -136,33 +141,33 @@ export function PricesTab({ project }: { project: Project }) {
|
|||
</div>
|
||||
</div>
|
||||
<p className="small muted act-note"><Icon name="shield" />{t('prices.sheetNote')}</p>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
{sheet && <SheetDiff project={project} diff={diff} sheet={sheet} onApplied={() => { cur.refresh(); diff.refresh(); }} />}
|
||||
|
||||
{cur.error && <ErrorNotice error={cur.error} keep={!!p} />}
|
||||
{!p ? (
|
||||
cur.loading && !cur.error ? <Card><Skeleton lines={6} block /></Card> : (
|
||||
<Card pad={false}>
|
||||
cur.loading && !cur.error ? <Section><Skeleton lines={6} block /></Section> : (
|
||||
<Section>
|
||||
<Empty illo="cloud" title={t('prices.notReadTitle')} actions={<button className="btn btn-primary" onClick={cur.refresh} disabled={cur.loading}><Icon name="refresh" />{t('prices.readNow')}</button>}>{t('prices.notReadText')}</Empty>
|
||||
</Card>
|
||||
</Section>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="tag" label={t('prices.kProducts')} value={stats!.products} sub={t('prices.kProductsSub', { count: stats!.rows })} />
|
||||
<Kpi icon="globe" label={t('prices.kRegions')} value={stats!.regions} sub={withAuto ? t('prices.kRegionsAuto') : t('prices.kRegionsManual')} />
|
||||
<Kpi icon="alert" label={t('prices.kGaps')} value={stats!.gaps} sub={t('prices.kGapsSub', { pct: GAP })} tone={stats!.gaps ? 'warn' : undefined} />
|
||||
<Kpi icon="x" label={t('prices.kMissing')} value={stats!.missing} sub={stats!.missing ? p.missing.map(m => `${m.product} (${m.store === 'apple' ? 'App Store' : 'Play'})`).join(', ') : t('prices.kMissingNone')} tone={stats!.missing ? 'bad' : undefined} />
|
||||
<div className="ds-grid px-stats">
|
||||
<StatTile icon="tag" label={t('prices.kProducts')} value={stats!.products} sub={t('prices.kProductsSub', { count: stats!.rows })} />
|
||||
<StatTile icon="globe" label={t('prices.kRegions')} value={stats!.regions} sub={withAuto ? t('prices.kRegionsAuto') : t('prices.kRegionsManual')} />
|
||||
<StatTile icon="alert" label={t('prices.kGaps')} value={stats!.gaps} sub={t('prices.kGapsSub', { pct: GAP })} tone={stats!.gaps ? 'warn' : undefined} />
|
||||
<StatTile icon="x" label={t('prices.kMissing')} value={stats!.missing} sub={stats!.missing ? p.missing.map(m => `${m.product} (${m.store === 'apple' ? 'App Store' : 'Play'})`).join(', ') : t('prices.kMissingNone')} tone={stats!.missing ? 'bad' : undefined} />
|
||||
</div>
|
||||
<Card title={t('prices.overview')} icon="grid" sub={cur.fromCache ? t('common.cachedFrom', { when: ago(cur.at) }) : t('common.readWhen', { when: ago(cur.at) })} pad={false}
|
||||
<Section title={t('prices.overview')} icon="grid" sub={cur.fromCache ? t('common.cachedFrom', { when: ago(cur.at) }) : t('common.readWhen', { when: ago(cur.at) })} pad={false}
|
||||
actions={<>
|
||||
<label className="check"><input type="checkbox" checked={withAuto} onChange={ev => setWithAuto(ev.target.checked)} />{t('prices.withAuto')}</label>
|
||||
<ReadControl r={cur} />
|
||||
</>}>
|
||||
<div className="table-wrap">
|
||||
<table className="table px-matrix">
|
||||
<thead><tr><th>{t('col.item')}</th>{matrix!.regions.map(r => <th key={r}>{r}</th>)}</tr></thead>
|
||||
<div className="ds-table-wrap">
|
||||
<table className="table v2 px-matrix">
|
||||
<thead><tr><th>{t('col.item')}</th>{matrix!.regions.map(r => <th key={r}><Region code={r} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{matrix!.rows.map(row => (
|
||||
<tr key={row.product} className={`clickable${row.product === sel ? ' sel' : ''}`} onClick={() => setProduct(row.product)}>
|
||||
|
|
@ -174,19 +179,19 @@ export function PricesTab({ project }: { project: Project }) {
|
|||
</table>
|
||||
</div>
|
||||
<div className="card-foot legend-row"><span><StoreGlyph store="ios" className="ic13" />App Store</span><span><StoreGlyph store="android" className="ic13" />Google Play</span><span className="px-line auto">{t('prices.autoLegend')}</span><span>{t('prices.gapLegend', { pct: GAP })}</span></div>
|
||||
</Card>
|
||||
<Card title={t('prices.perRegion', { product: sel })} icon="globe" sub={t('prices.perRegionSub', { count: regions.length })} pad={false}
|
||||
</Section>
|
||||
<Section title={t('prices.perRegion', { product: sel })} icon="globe" sub={t('prices.perRegionSub', { count: regions.length })} pad={false}
|
||||
actions={<>
|
||||
<select className="input input-sm" value={sel} onChange={ev => setProduct(ev.target.value)} aria-label={t('col.item')}>{p.products.map(x => <option key={x} value={x}>{x}</option>)}</select>
|
||||
<div className="search-input"><Icon name="search" /><input className="input input-sm" style={{ paddingLeft: 28 }} value={q} onChange={ev => setQ(ev.target.value)} placeholder={t('prices.searchRegion')} aria-label={t('prices.searchRegion')} /></div>
|
||||
</>}>
|
||||
<div className="table-wrap" style={{ maxHeight: 520, overflowY: 'auto' }}>
|
||||
<table className="table">
|
||||
<div className="ds-table-wrap" style={{ ['--ds-table-max' as string]: '520px' }}>
|
||||
<table className="table v2">
|
||||
<thead><tr><th>{t('col.region')}</th>{apple && <th className="right">App Store</th>}{apple && <th>{t('prices.colKind')}</th>}{play && <th className="right">Google Play</th>}<th className="right">{t('col.priceGap')}</th></tr></thead>
|
||||
<tbody>
|
||||
{regions.map(r => (
|
||||
<tr key={r.region}>
|
||||
<td className="strong">{r.region}</td>
|
||||
<tr key={r.region} className={r.gapPct !== null && r.gapPct >= GAP ? (r.gapPct > 25 ? 'gap-bad' : 'gap-warn') : undefined}>
|
||||
<td className="strong"><Region code={r.region} /></td>
|
||||
{apple && <td className="right num">{r.apple ? money(r.apple.price, r.apple.currency) : <span className="muted">–</span>}{r.scheduled && <div className="small muted">{t('prices.scheduled', { price: money(r.scheduled.price, r.scheduled.currency), date: r.scheduled.startDate ?? '' })}</div>}</td>}
|
||||
{apple && <td>{r.apple ? <Chip tone={r.apple.kind === 'auto' ? 'neutral' : 'accent'}>{r.apple.kind === 'auto' ? t('prices.auto') : t('prices.manual')}</Chip> : null}</td>}
|
||||
{play && <td className="right num">{r.play ? money(r.play.price, r.play.currency) : <span className="muted">–</span>}</td>}
|
||||
|
|
@ -196,10 +201,10 @@ export function PricesTab({ project }: { project: Project }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
{p.missing.length > 0 && <Notice tone="warn">{t('prices.missingNote', { list: p.missing.map(m => `${m.product} (${m.store === 'apple' ? 'App Store' : 'Google Play'})`).join(', ') })}</Notice>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API
|
||||
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
|
||||
import { plainError } from '../../../../../cli/lib/google-errors';
|
||||
import { useState } from 'react';
|
||||
import { type CSSProperties, type ReactNode, useState } from 'react';
|
||||
import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api';
|
||||
import { laneGap, storesDiffer } from '../../../../../cli/lib/train';
|
||||
import type { UnifiedApp } from '../../lib/identity';
|
||||
|
|
@ -14,8 +14,10 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory }
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { RolloutBar } from '../../ui/charts';
|
||||
import { Card, Chip, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
|
||||
import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds';
|
||||
import { AppleSubmitCard, PlayProductionCard } from './Submit';
|
||||
import '../../styles/releases.css';
|
||||
|
||||
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
|
||||
|
||||
|
|
@ -39,32 +41,55 @@ function gapText(lane: Lane, version: string, st: AppStatus): { who: string; lin
|
|||
return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') };
|
||||
}
|
||||
|
||||
function LaneRow({ lane, version, st }: { lane: Lane; version: string; st: AppStatus }) {
|
||||
/** One store's card of a release train: store mark, version (build), the current state as a pill, the five stages
|
||||
* as a stepper, a big rollout ring when it is rolling out, and that store's rollout controls. */
|
||||
function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: AppStatus; app: UnifiedApp }) {
|
||||
const r = lane.release;
|
||||
const gap = lane.at < 0 ? gapText(lane, version, st) : null;
|
||||
const ph = r ? phaseLabel(r) : null;
|
||||
const rolling = lane.at === STAGES.length - 1 && lane.share !== null;
|
||||
const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent';
|
||||
return (
|
||||
<div className="tl-lane">
|
||||
<div className="tl-who">
|
||||
<StoreGlyph store={lane.store} />
|
||||
<div>
|
||||
<div className="w1">{storeName(lane.store)}</div>
|
||||
<div className="w2">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}</div>
|
||||
<article className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
|
||||
<header className="rel-store-h">
|
||||
<span className="rel-mark"><StoreGlyph store={lane.store} /></span>
|
||||
<div className="rel-who">
|
||||
<div className="rel-store-name">{storeName(lane.store)}</div>
|
||||
<div className="rel-store-ver num">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}</div>
|
||||
</div>
|
||||
</div>
|
||||
{gap ? <div className="tl-none" data-gap={gap.kind}>{gap.line}</div> : STAGES.map((s, i) => {
|
||||
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : '';
|
||||
const last = s === 'rollout';
|
||||
return (
|
||||
<div key={s} className={`tl-step ${cls}${i === 0 ? ' first' : ''}`} title={i === lane.at ? r?.state : undefined}>
|
||||
<span className="tl-node">{i < lane.at && <Icon name="check" />}</span>
|
||||
{last && i === lane.at && lane.share !== null ? (
|
||||
<div className="tl-roll"><RolloutBar value={lane.share} tone={lane.tone === 'bad' ? 'bad' : lane.share >= 1 ? 'ok' : 'accent'} label={t('tl.rolloutAria')} />
|
||||
<span className="pct">{lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}</span></div>
|
||||
) : <span className="tl-cap">{stageCaption(lane, i)}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{ph && <StatusPill tone={ph.tone} title={r?.state} solid={lane.tone === 'bad'}>{ph.text}</StatusPill>}
|
||||
</header>
|
||||
{gap ? <div className="tl-none rel-gap" data-gap={gap.kind}><Icon name={gap.kind === 'unreadable' ? 'alert' : 'info'} />{gap.line}</div> : (
|
||||
<div className={`rel-body${rolling ? ' has-ring' : ''}`}>
|
||||
<ol className="rel-steps" aria-label={t('v2rel.stages', { store: storeName(lane.store) })}>
|
||||
{STAGES.map((s, i) => {
|
||||
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : 'todo';
|
||||
const cap = stageCaption(lane, i);
|
||||
return (
|
||||
<li key={s} className={`rel-step ${cls}`} title={i === lane.at ? r?.state : undefined} aria-current={i === lane.at ? 'step' : undefined}>
|
||||
<span className="rel-node">{i < lane.at ? <Icon name="check" /> : <span className="num">{i + 1}</span>}</span>
|
||||
<span className="rel-step-l">{t(STAGE_KEY[s])}</span>
|
||||
{cap && <span className="rel-step-c">{cap}</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{rolling && lane.share !== null && (
|
||||
<div className={`tl-roll rel-ring-wrap ${ringTone}`}>
|
||||
<div className="rel-ring" style={{ '--p': String(Math.round(Math.min(1, lane.share) * 100)) } as CSSProperties} aria-hidden="true">
|
||||
<span className="rel-ring-v num">{lane.share >= 1 ? '100' : Math.round(lane.share * 100)}<small>%</small></span>
|
||||
</div>
|
||||
<div className="rel-ring-side">
|
||||
<span className="pct">{lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}</span>
|
||||
<span className="rel-ring-sub">{t('v2rel.ofUsers')}</span>
|
||||
<RolloutBar value={lane.share} tone={ringTone} label={t('tl.rolloutAria')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{r && app.project && <RolloutControls app={app} r={r} />}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -122,19 +147,22 @@ function Train({ title, train, tf, tone, app, st }: { title: string; train: Rele
|
|||
// "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not
|
||||
const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title;
|
||||
const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version;
|
||||
const created = train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null;
|
||||
return (
|
||||
<Card pad={false} title={<span className="train-head"><span className="ver">{ver}</span><Chip tone={differ ? 'neutral' : tone === 'ok' && label !== title ? 'info' : tone}>{label}</Chip></span>}
|
||||
sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
|
||||
<div className="tl" style={{ paddingTop: 12 }}>
|
||||
<div className="tl-head"><span>{t('tl.store')}</span>{STAGES.map(s => <span key={s}>{t(STAGE_KEY[s])}</span>)}</div>
|
||||
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} st={st} />)}
|
||||
</div>
|
||||
{lanes.some(l => l.release) && app.project && (
|
||||
<div className="card-foot roll-foot">
|
||||
{lanes.map(l => l.release && <RolloutControls key={l.store} app={app} r={l.release} />)}
|
||||
<section className={`rel-train ${tone}`} aria-label={`${label} ${ver}`}>
|
||||
<header className="rel-train-h">
|
||||
<div className="rel-train-title">
|
||||
<span className="rel-train-eyebrow">{t('v2rel.version')}</span>
|
||||
<span className="rel-train-ver num">{ver}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<StatusPill tone={differ ? 'neutral' : tone === 'ok' && label !== title ? 'info' : tone} solid={tone === 'accent' && !differ}>{label}</StatusPill>
|
||||
<span className="grow" />
|
||||
{created && <span className="rel-train-meta"><Icon name="clock" />{created}</span>}
|
||||
</header>
|
||||
<div className="rel-lanes">
|
||||
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} st={st} app={app} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -142,22 +170,26 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
const d = useData();
|
||||
const l = d.liveFor(app);
|
||||
const project = app.project;
|
||||
const hue = useTint(d.iconFor(app), app.name);
|
||||
const tf = useRead<TestFlightStatus>(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight', skip: !app.ios && !d.statusFor(app)?.app?.platforms.ios });
|
||||
const sum = project ? d.statusOf(project)?.summary : null;
|
||||
const submit = project && (sum?.apple || sum?.play) ? (
|
||||
<section className="stack" aria-labelledby="sub-section" data-section="submit">
|
||||
<h2 id="sub-section" className="section-h row" style={{ gap: 8 }}><Icon name="send" />{t('sub.section')}</h2>
|
||||
<div className="grid g2" style={{ alignItems: 'start' }}>
|
||||
<section className="rel-submit stack" aria-labelledby="sub-section" data-section="submit">
|
||||
<div className="rel-sec-h">
|
||||
<h2 id="sub-section" className="rel-sec-title"><span className="ds-sec-ic"><Icon name="send" /></span>{t('sub.section')}</h2>
|
||||
<p className="rel-sec-sub">{t('releases.ownerNote')}</p>
|
||||
</div>
|
||||
<div className="rel-submit-grid">
|
||||
{sum?.apple && <AppleSubmitCard app={app} project={project} />}
|
||||
{sum?.play && <PlayProductionCard app={app} project={project} />}
|
||||
</div>
|
||||
<p className="small muted">{t('releases.ownerNote')}</p>
|
||||
</section>
|
||||
) : null;
|
||||
const wrap = (children: ReactNode) => <div className="scr-releases" style={tintStyle(hue)}>{children}</div>;
|
||||
|
||||
if (!l && d.portfolioBusy === 'live') return <><Card><Skeleton lines={5} block /></Card>{submit}</>;
|
||||
if (!l && d.portfolioBusy === 'live') return wrap(<><Card><Skeleton lines={5} block /></Card>{submit}</>);
|
||||
if (!l) {
|
||||
return (
|
||||
return wrap(
|
||||
<>
|
||||
<Card pad={false}>
|
||||
<Empty illo="cloud" title={t('releases.notReadTitle')} actions={<>
|
||||
|
|
@ -166,12 +198,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
</>}>{t('releases.notReadText')}</Empty>
|
||||
</Card>
|
||||
{submit}
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
const history = versionHistory(l.release.all ?? []);
|
||||
const errs = Object.entries(l.storeErrors);
|
||||
return (
|
||||
return wrap(
|
||||
<>
|
||||
{errs.map(([s, msg]) => {
|
||||
const when = l.staleStores?.[s as 'ios'];
|
||||
|
|
@ -179,17 +211,18 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
})}
|
||||
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} st={l} />}
|
||||
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} st={l} />}
|
||||
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}
|
||||
{!l.release.next && !l.release.current && <Section><EmptyState icon="layers">{t('releases.noVersions')} {t('releases.noVersionsText')}</EmptyState></Section>}
|
||||
|
||||
<Card title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false}>
|
||||
<Section title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false} className="rel-history"
|
||||
actions={history.length ? <span className="rel-count">{t('v2rel.versions', { count: history.length })}</span> : undefined}>
|
||||
{history.length ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<div className="ds-table-wrap">
|
||||
<table className="table v2">
|
||||
<thead><tr><th>{t('col.version')}</th><th>App Store</th><th>{t('col.build')}</th><th>Google Play</th><th>{t('col.versionCode')}</th><th>{t('col.track')}</th></tr></thead>
|
||||
<tbody>
|
||||
{history.map(h => (
|
||||
<tr key={h.version}>
|
||||
<td className="strong num">{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}</td>
|
||||
<td className="strong num rel-hv">{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}</td>
|
||||
<td>{h.ios ? <PhaseChip r={h.ios} /> : <span className="muted small">–</span>}</td>
|
||||
<td className="num muted">{h.ios?.build ?? '–'}</td>
|
||||
<td>{h.android ? <PhaseChip r={h.android} /> : <span className="muted small">–</span>}</td>
|
||||
|
|
@ -200,10 +233,10 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : <Empty compact illo="unknown" title={t('releases.noVersions')} />}
|
||||
</Card>
|
||||
) : <div className="ds-sec-b"><EmptyState icon="layers">{t('releases.noVersions')}</EmptyState></div>}
|
||||
</Section>
|
||||
|
||||
{submit}
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import type { UnifiedApp } from '../../lib/identity';
|
|||
import { t } from '../../i18n';
|
||||
import { date, num } from '../../lib/format';
|
||||
import { draftName, filterReviews, type Review, type ReviewFilter, starStats } from '../../lib/model';
|
||||
import { useRead, useWrite } from '../../store';
|
||||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit';
|
||||
import { Bar, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatusPill, tintStyle, Toolbar, useTint } from '../../ui/ds';
|
||||
import '../../styles/reviews.css';
|
||||
import { exportName, reviewsCsv } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
|
||||
|
|
@ -29,57 +31,60 @@ function Detail({ r, draft, w, onChanged }: { r: Review; draft: ReplyDraft | und
|
|||
const live = text && INCENTIVE.some(x => x.test(text)) ? t('reviews.incentive') : len > limit ? t('reviews.tooLong', { limit }) : null;
|
||||
const file = draft ? base(draft.file) : '';
|
||||
return (
|
||||
<Card className="rv-detail" title={<span className="row" style={{ gap: 10 }}><Stars n={r.rating} />{r.title || t('reviews.noTitle')}</span>}
|
||||
sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store={r.store === 'apple' ? 'ios' : 'android'} className="ic13" />{r.store === 'apple' ? 'App Store' : 'Google Play'}{r.where ? ` · ${r.where}` : ''} · {date(r.date)}{r.appVersion ? ` · ${t('reviews.version', { v: r.appVersion })}` : ''}</span>}>
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<p className="quote selectable">{r.body || t('reviews.noText')}</p>
|
||||
{r.reply && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<span className="label">{t('reviews.currentReply')}{r.reply.date ? ` · ${date(r.reply.date)}` : ''}</span>
|
||||
<div className="reply-box selectable">{r.reply.text}</div>
|
||||
</div>
|
||||
)}
|
||||
<hr className="divider" />
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<div className="row between">
|
||||
<span className="label">{t('reviews.draft')}</span>
|
||||
{draft && (draft.sent ? <Chip tone="ok">{t('reviews.sentOn', { date: date(draft.sent) })}</Chip> : draft.approved ? <Chip tone="ok" icon="check">{t('reviews.approved')}</Chip> : <Chip tone="neutral">{t('reviews.notApproved')}</Chip>)}
|
||||
</div>
|
||||
{!draft ? <p className="muted small">{t('reviews.noDraft')}</p> : draft.sent ? (
|
||||
<div className="reply-box selectable">{draft.reply}</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea className="input reply-edit" rows={5} value={text} onChange={e => setText(e.target.value)} aria-label={t('reviews.replyEditor')} placeholder={t('reviews.replyPlaceholder')} />
|
||||
<div className="row small muted" style={{ gap: 10 }}>
|
||||
<span className={`num${len > limit ? ' tbad' : ''}`}>{t('reviews.chars', { n: num(len), limit })}</span><span className="mono-id">{file}</span>
|
||||
{dirty && <Chip tone="warn" dot={false}>{t('reviews.unsaved')}</Chip>}
|
||||
</div>
|
||||
{live && <ul className="issues"><li className="e"><Icon name="alertCircle" />{live}</li></ul>}
|
||||
{!dirty && draft.issues.length > 0 && (
|
||||
<ul className="issues">
|
||||
{draft.issues.map((i, n) => <li key={n} className={i.level === 'error' ? 'e' : 'w'}><Icon name={i.level === 'error' ? 'alertCircle' : 'alert'} />{i.msg}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<div className="actions">
|
||||
<WriteButton run={w} icon="check" primary={dirty} disabled={!dirty || !text.trim() || text.trim().startsWith('-') || len > 6000}
|
||||
w={{ method: 'reviews.reply', args: { file, text: text.trim() }, title: t('reviews.save'), done: t('reviews.saved'), after: onChanged }}>{t('reviews.save')}</WriteButton>
|
||||
{draft.approved
|
||||
? <WriteButton run={w} icon="x" disabled={dirty} w={{ method: 'reviews.approve', args: { file, undo: true }, title: t('reviews.unapprove'), done: t('reviews.unapproved'), after: onChanged }}>{t('reviews.unapprove')}</WriteButton>
|
||||
: <WriteButton run={w} icon="check" disabled={dirty || !draft.reply || errors.length > 0} hint={errors.length ? t('reviews.fixFirst') : t('reviews.approveHint')}
|
||||
w={{ method: 'reviews.approve', args: { file }, title: t('reviews.approve'), done: t('reviews.approvedToast'), after: onChanged }}>{t('reviews.approve')}</WriteButton>}
|
||||
<WriteButton run={w} icon="send" primary={draft.approved && !dirty} disabled={!draft.approved || dirty || errors.length > 0} hint={t('reviews.sendHint')}
|
||||
w={{ method: 'owner.reply_send', args: { file }, title: t('reviews.send'), done: t('reviews.sent'), after: onChanged }}>{t('reviews.send')}</WriteButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="small muted">{t('reviews.sendNoteD3')}</p>
|
||||
</div>
|
||||
<section className="rv2-detail rv-detail" aria-label={r.title || t('reviews.noTitle')}>
|
||||
<div className="rv2-d-head">
|
||||
<Stars n={r.rating} />
|
||||
<h2>{r.title || t('reviews.noTitle')}</h2>
|
||||
<span className="rv2-meta"><StoreGlyph store={r.store === 'apple' ? 'ios' : 'android'} className="ic13" />{r.store === 'apple' ? 'App Store' : 'Google Play'}{r.where ? <span className="rv2-lang"><Icon name="globe" />{r.where}</span> : null}<span>{date(r.date)}</span>{r.appVersion ? <span>{t('reviews.version', { v: r.appVersion })}</span> : null}</span>
|
||||
</div>
|
||||
</Card>
|
||||
<p className="rv2-body quote selectable">{r.body || t('reviews.noText')}</p>
|
||||
{r.reply && (
|
||||
<div className="rv2-dev">
|
||||
<span className="rv2-dev-l"><Icon name="message" />{t('reviews.currentReply')}{r.reply.date ? ` · ${date(r.reply.date)}` : ''}</span>
|
||||
<div className="reply-box selectable">{r.reply.text}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rv2-draft">
|
||||
<div className="rv2-draft-h">
|
||||
<span className="rv2-draft-l"><Icon name="edit" />{t('reviews.draft')}</span>
|
||||
{draft && (draft.sent ? <StatusPill tone="ok" solid>{t('reviews.sentOn', { date: date(draft.sent) })}</StatusPill> : draft.approved ? <StatusPill tone="ok" icon="check">{t('reviews.approved')}</StatusPill> : <StatusPill tone="neutral">{t('reviews.notApproved')}</StatusPill>)}
|
||||
</div>
|
||||
{!draft ? <p className="muted small">{t('reviews.noDraft')}</p> : draft.sent ? (
|
||||
<div className="reply-box selectable">{draft.reply}</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea className="input reply-edit" rows={5} value={text} onChange={e => setText(e.target.value)} aria-label={t('reviews.replyEditor')} placeholder={t('reviews.replyPlaceholder')} />
|
||||
<div className="rv2-count">
|
||||
<span className={`num${len > limit ? ' tbad' : ''}`}>{t('reviews.chars', { n: num(len), limit })}</span><span className="mono-id">{file}</span>
|
||||
{dirty && <StatusPill tone="warn">{t('reviews.unsaved')}</StatusPill>}
|
||||
</div>
|
||||
{live && <ul className="issues"><li className="e"><Icon name="alertCircle" />{live}</li></ul>}
|
||||
{!dirty && draft.issues.length > 0 && (
|
||||
<ul className="issues">
|
||||
{draft.issues.map((i, n) => <li key={n} className={i.level === 'error' ? 'e' : 'w'}><Icon name={i.level === 'error' ? 'alertCircle' : 'alert'} />{i.msg}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<div className="actions">
|
||||
<WriteButton run={w} icon="check" primary={dirty} disabled={!dirty || !text.trim() || text.trim().startsWith('-') || len > 6000}
|
||||
w={{ method: 'reviews.reply', args: { file, text: text.trim() }, title: t('reviews.save'), done: t('reviews.saved'), after: onChanged }}>{t('reviews.save')}</WriteButton>
|
||||
{draft.approved
|
||||
? <WriteButton run={w} icon="x" disabled={dirty} w={{ method: 'reviews.approve', args: { file, undo: true }, title: t('reviews.unapprove'), done: t('reviews.unapproved'), after: onChanged }}>{t('reviews.unapprove')}</WriteButton>
|
||||
: <WriteButton run={w} icon="check" disabled={dirty || !draft.reply || errors.length > 0} hint={errors.length ? t('reviews.fixFirst') : t('reviews.approveHint')}
|
||||
w={{ method: 'reviews.approve', args: { file }, title: t('reviews.approve'), done: t('reviews.approvedToast'), after: onChanged }}>{t('reviews.approve')}</WriteButton>}
|
||||
<WriteButton run={w} icon="send" primary={draft.approved && !dirty} disabled={!draft.approved || dirty || errors.length > 0} hint={t('reviews.sendHint')}
|
||||
w={{ method: 'owner.reply_send', args: { file }, title: t('reviews.send'), done: t('reviews.sent'), after: onChanged }}>{t('reviews.send')}</WriteButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="small muted">{t('reviews.sendNoteD3')}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewsTab({ app }: { app: UnifiedApp }) {
|
||||
const d = useData();
|
||||
const hue = useTint(d.iconFor(app), app.name);
|
||||
const project = app.project;
|
||||
const list = useRead<Review[]>(app.target, 'reviews.list', { network: true, args: { days: 30 }, title: t('tab.reviews'), appName: app.name });
|
||||
const drafts = useRead<DraftsResult>(app.target, 'reviews.drafts', { network: false, skip: !project });
|
||||
|
|
@ -98,69 +103,79 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
|
|||
disabled: list.data ? false : t('export.readFirst'), build: () => reviewsCsv(shown.length ? shown : all) }]);
|
||||
|
||||
if (!list.data) {
|
||||
if (list.loading && !list.error) return <Card><Skeleton lines={5} block /></Card>;
|
||||
if (list.loading && !list.error) return <div className="scr-reviews" style={tintStyle(hue)}><Section><Skeleton lines={5} block /></Section></div>;
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<Empty illo="reviews" title={t('reviews.emptyTitle')} actions={<button className="btn btn-primary" onClick={list.refresh}><Icon name="refresh" />{t('reviews.read')}</button>}>{t('reviews.emptyText')}</Empty>
|
||||
{list.error && <ErrorFoot error={list.error} />}
|
||||
</Card>
|
||||
<div className="scr-reviews" style={tintStyle(hue)}>
|
||||
<Section>
|
||||
<EmptyState icon="message" action={<button className="btn btn-primary" onClick={list.refresh}><Icon name="refresh" />{t('reviews.read')}</button>}>
|
||||
<b>{t('reviews.emptyTitle')}</b><br />{t('reviews.emptyText')}
|
||||
</EmptyState>
|
||||
{list.error && <ErrorFoot error={list.error} />}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleStar = (n: number) => setF(x => ({ ...x, stars: x.stars.includes(n) ? x.stars.filter(s => s !== n) : [...x.stars, n] }));
|
||||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<ReadControl r={list} />
|
||||
<div className="actions">
|
||||
<div className="scr-reviews" style={tintStyle(hue)}>
|
||||
<section className="rv2-sum" aria-label={t('reviews.ratings')}>
|
||||
<div className="rv2-avg">
|
||||
<b className="num">{stats.average !== null ? num(stats.average, 1) : '–'}</b>
|
||||
<Stars n={Math.round(stats.average ?? 0)} />
|
||||
<span>{t('reviews.count', { count: stats.count })}</span>
|
||||
</div>
|
||||
<div className="rv2-dist" role="group" aria-label={t('reviews.ratings')}>
|
||||
{[5, 4, 3, 2, 1].map(n => (
|
||||
<button key={n} type="button" className="rv2-dist-row" onClick={() => toggleStar(n)} aria-pressed={f.stars.includes(n)} data-dim={f.stars.length > 0 && !f.stars.includes(n) ? '' : undefined}>
|
||||
<span className="num">{n}<Icon name="star" weight="fill" /></span><Bar value={stats.count ? stats.byStar[n - 1]! / stats.count : 0} tone="warn" label={`${n}`} /><span className="num">{stats.byStar[n - 1]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rv2-side">
|
||||
<div className="rv2-unans"><b className="num">{num(stats.unanswered)}</b><span>{t('reviews.unansweredN', { count: stats.unanswered })}</span></div>
|
||||
<ReadControl r={list} />
|
||||
{project && <WriteButton run={w} icon="text" hint={t('reviews.makeDraftsHint')} w={{ method: 'reviews.draft', args: { days: 30 }, title: t('reviews.makeDrafts'), done: t('reviews.draftsMade'), after: changed }}>{t('reviews.makeDrafts')}</WriteButton>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rv">
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Card title={t('reviews.ratings')} icon="star">
|
||||
<div className="stack" style={{ gap: 14 }}>
|
||||
<div className="big-rating"><b>{stats.average !== null ? num(stats.average, 1) : '–'}</b><Stars n={Math.round(stats.average ?? 0)} /></div>
|
||||
<span className="small muted">{t('reviews.count', { count: stats.count })} · {t('reviews.unansweredN', { count: stats.unanswered })}</span>
|
||||
<div className="dist">
|
||||
{[5, 4, 3, 2, 1].map(n => (
|
||||
<button key={n} className="dist-row" onClick={() => toggleStar(n)} aria-pressed={f.stars.includes(n)} style={{ all: 'unset', display: 'grid', gridTemplateColumns: '26px 1fr 30px', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12, opacity: f.stars.length && !f.stars.includes(n) ? 0.45 : 1 }}>
|
||||
<span className="num">{n}★</span><Bar value={stats.count ? stats.byStar[n - 1]! / stats.count : 0} tone="warn" label={`${n}`} /><span className="num right">{stats.byStar[n - 1]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('reviews.filter')} icon="search">
|
||||
<div className="form">
|
||||
<Seg value={f.store} onChange={store => setF(x => ({ ...x, store }))} label={t('reviews.store')} options={[['all', t('reviews.all')], ['apple', 'App Store'], ['play', 'Google Play']]} />
|
||||
<label className="check"><input type="checkbox" checked={f.unanswered} onChange={e => setF(x => ({ ...x, unanswered: e.target.checked }))} />{t('reviews.onlyUnanswered')}</label>
|
||||
<div className="search-input"><Icon name="search" /><input className="input" value={f.query} onChange={e => setF(x => ({ ...x, query: e.target.value }))} placeholder={t('reviews.search')} aria-label={t('reviews.search')} /></div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card title={t('reviews.inbox')} icon="message" sub={t('reviews.shown', { count: shown.length })} pad={false}>
|
||||
</section>
|
||||
<Toolbar label={t('reviews.filter')}>
|
||||
<div className="search-input"><Icon name="search" /><input className="input" value={f.query} onChange={e => setF(x => ({ ...x, query: e.target.value }))} placeholder={t('reviews.search')} aria-label={t('reviews.search')} /></div>
|
||||
<Seg value={f.store} onChange={store => setF(x => ({ ...x, store }))} label={t('reviews.store')} options={[['all', t('reviews.all')], ['apple', 'App Store'], ['play', 'Google Play']]} />
|
||||
<label className="check"><input type="checkbox" checked={f.unanswered} onChange={e => setF(x => ({ ...x, unanswered: e.target.checked }))} />{t('reviews.onlyUnanswered')}</label>
|
||||
<span className="grow" />
|
||||
<span className="rv2-shown">{t('reviews.shown', { count: shown.length })}</span>
|
||||
</Toolbar>
|
||||
<div className="rv2">
|
||||
<div className="rv2-feed">
|
||||
{shown.length ? (
|
||||
<div className="inbox" role="listbox" aria-label={t('reviews.inbox')}>
|
||||
<div className="rv2-list inbox" role="listbox" aria-label={t('reviews.inbox')}>
|
||||
{shown.map(r => {
|
||||
const dr = byFile.get(draftName(r));
|
||||
const k = `${r.store}:${r.id}`;
|
||||
return (
|
||||
<button key={k} role="option" aria-selected={cur === r} className="inbox-item" onClick={() => setSel(k)}>
|
||||
<span className="t1"><Stars n={r.rating} /><StoreGlyph store={r.store === 'apple' ? 'ios' : 'android'} className="ic13" /><span>{r.where ?? ''}</span><span className="grow" />
|
||||
{r.reply ? <Chip tone="ok" dot={false}>{t('reviews.answered')}</Chip> : dr?.approved ? <Chip tone="accent" dot={false}>{t('reviews.approved')}</Chip> : dr ? <Chip tone="neutral" dot={false}>{t('reviews.drafted')}</Chip> : null}
|
||||
<span>{date(r.date)}</span></span>
|
||||
{r.title && <span className="t2">{r.title}</span>}
|
||||
<span className="t3">{r.body}</span>
|
||||
<button key={k} type="button" role="option" aria-selected={cur === r} className="rv2-card inbox-item" onClick={() => setSel(k)}>
|
||||
<span className="rv2-c-top">
|
||||
<Stars n={r.rating} />
|
||||
<span className="rv2-c-date">{date(r.date)}</span>
|
||||
<span className="grow" />
|
||||
{r.reply ? <StatusPill tone="ok" icon="check">{t('reviews.answered')}</StatusPill> : dr?.approved ? <StatusPill tone="accent">{t('reviews.approved')}</StatusPill> : dr ? <StatusPill tone="neutral">{t('reviews.drafted')}</StatusPill> : <StatusPill tone="warn">{t('v2ins.rvOpen')}</StatusPill>}
|
||||
</span>
|
||||
{r.title && <span className="rv2-c-title">{r.title}</span>}
|
||||
<span className="rv2-c-body">{r.body}</span>
|
||||
<span className="rv2-c-foot">
|
||||
<StoreGlyph store={r.store === 'apple' ? 'ios' : 'android'} className="ic13" />{r.store === 'apple' ? 'App Store' : 'Google Play'}
|
||||
{r.where && <span className="rv2-lang"><Icon name="globe" />{r.where}</span>}
|
||||
{r.appVersion && <span>{t('reviews.version', { v: r.appVersion })}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <Empty compact illo="reviews" title={t('reviews.noMatch')} />}
|
||||
</Card>
|
||||
) : <Section><EmptyState icon="search">{t('reviews.noMatch')}</EmptyState></Section>}
|
||||
</div>
|
||||
{cur ? <Detail r={cur} draft={byFile.get(draftName(cur))} w={w} onChanged={changed} />
|
||||
: <Card className="rv-detail"><Empty compact illo="reviews" title={t('reviews.pick')} /></Card>}
|
||||
: <Section className="rv2-detail rv-detail"><EmptyState icon="message">{t('reviews.pick')}</EmptyState></Section>}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
// versionCodes on Play to pick by number and Send to production (`play release --version-code n --production ...`),
|
||||
// the managed-publishing warning, and the console steps Play has no API for (production countries, "Send changes for
|
||||
// review" in Publishing overview) as console plans with a deep link.
|
||||
import { useState } from 'react';
|
||||
// v2: each store is a numbered checklist (ready / blocked / to do per step) that ends in its one primary button.
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { api, type Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
import { ago, date } from '../../lib/format';
|
||||
|
|
@ -15,15 +16,57 @@ import type { UnifiedApp } from '../../lib/identity';
|
|||
import { useData, useRead } from '../../store';
|
||||
import { ConsolePlanButton } from '../../ui/ConsolePlan';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
|
||||
import { Chip, ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
|
||||
import { EmptyState, StatusPill } from '../../ui/ds';
|
||||
import { OwnerAction } from '../../ui/OwnerAction';
|
||||
import { ascUrl, PLAY_CONSOLE } from '../AppView';
|
||||
import '../../styles/submit.css';
|
||||
|
||||
const SUB_KIND: Record<SubmissionKind, ['ok' | 'warn' | 'bad' | 'info' | 'neutral', Key]> = {
|
||||
waiting: ['info', 'sub.kWaiting'], inReview: ['info', 'sub.kInReview'], unresolved: ['bad', 'sub.kUnresolved'], draft: ['neutral', 'sub.kDraft'],
|
||||
emptyDraft: ['neutral', 'sub.kEmptyDraft'], done: ['ok', 'sub.kDone'], other: ['neutral', 'sub.kOther'],
|
||||
};
|
||||
|
||||
type StepState = 'ready' | 'blocked' | 'todo' | 'warn';
|
||||
const STEP_PILL: Record<StepState, ['ok' | 'bad' | 'neutral' | 'warn', Key]> = {
|
||||
ready: ['ok', 'v2rel.ready'], blocked: ['bad', 'v2rel.blocked'], todo: ['neutral', 'v2rel.todo'], warn: ['warn', 'v2rel.attention'],
|
||||
};
|
||||
|
||||
/** One numbered step of a send-for-review checklist: node (number, check or alert), title, state pill, body. */
|
||||
function Step({ n, state: st, title, children }: { n: number; state: StepState | undefined; title: string; children?: ReactNode }) {
|
||||
const state = st ?? 'todo';
|
||||
const [tone, key] = STEP_PILL[state];
|
||||
return (
|
||||
<li className={`sub-step ${state}`} data-step-state={state}>
|
||||
<span className="sub-node" aria-hidden="true">{state === 'ready' ? <Icon name="check" /> : state === 'blocked' ? <Icon name="alertCircle" /> : <span className="num">{n}</span>}</span>
|
||||
<div className="sub-step-b">
|
||||
<div className="sub-step-h"><span className="sub-step-t">{title}</span><StatusPill tone={tone}>{t(key)}</StatusPill></div>
|
||||
{children}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The head of a store's send card: store mark, title, read state line, ready count, read / console buttons. */
|
||||
function SubHead({ store, title, sub, actions, done, total }: { store: 'ios' | 'android'; title: string; sub: ReactNode; actions: ReactNode; done: number | null; total: number }) {
|
||||
return (
|
||||
<header className="sub-head">
|
||||
<span className="sub-mark"><StoreGlyph store={store} /></span>
|
||||
<div className="sub-head-t">
|
||||
<h3>{title}</h3>
|
||||
<div className="sub-head-s">{sub}</div>
|
||||
</div>
|
||||
{done !== null && (
|
||||
<span className={`sub-count${done === total ? ' all' : ''}`} title={t('v2rel.readyCount', { done, total })}>
|
||||
<span aria-hidden="true"><span className="num">{done}</span><span className="of">/{total}</span></span>
|
||||
<span className="sr">{t('v2rel.readyCount', { done, total })}</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="sub-head-a actions">{actions}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** The App Store card: submission state, build picker, Submit / Resubmit / in-app purchases. */
|
||||
export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Project }) {
|
||||
const d = useData();
|
||||
|
|
@ -39,92 +82,112 @@ export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Pr
|
|||
const rejectedIap = s?.unresolved?.items.some(i => i.rejected && i.kind !== 'appStoreVersion') ?? false;
|
||||
const args = { build, ...(version ? { version: version.version, platform: platformFlag(version.platform) } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) };
|
||||
const refresh = () => { r.refresh(); };
|
||||
const open = s ? s.submissions.filter(x => x.kind !== 'done') : [];
|
||||
const states: StepState[] = s ? [
|
||||
'ready',
|
||||
s.busy ? 'blocked' : s.unresolved ? 'warn' : 'ready',
|
||||
picked ? 'ready' : s.builds.length ? 'todo' : 'blocked',
|
||||
version ? 'ready' : 'blocked',
|
||||
picked && version && !s.busy ? 'ready' : 'todo',
|
||||
] : [];
|
||||
const done = s ? states.filter(x => x === 'ready').length : null;
|
||||
return (
|
||||
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.appleSub')}</span>}
|
||||
actions={<>
|
||||
<ReadControl r={{ ...r, refresh: async () => refresh() }} label={t('sub.read')} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(ascUrl(app.ios?.app.id ?? summary?.apple?.appId)).catch(() => {})}><Icon name="external" />App Store Connect</button>
|
||||
</>}>
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
{!s ? (r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <Empty compact illo="cloud" title={t('sub.notRead')}>{t('sub.notReadText')}</Empty>) : (
|
||||
<div className="stack">
|
||||
{s.submissions.length > 0 && (
|
||||
<div className="list sub-list" aria-label={t('sub.submissions')}>
|
||||
{s.submissions.filter(x => x.kind !== 'done').slice(0, 6).map(x => {
|
||||
const [tone, key] = SUB_KIND[x.kind];
|
||||
return (
|
||||
<div className="li" key={x.id} data-sub-kind={x.kind}>
|
||||
<span className={`li-icon tone-bg-${tone === 'neutral' ? 'info' : tone}`}><Icon name={x.kind === 'unresolved' ? 'alertCircle' : x.kind === 'emptyDraft' ? 'info' : 'clock'} /></span>
|
||||
<div className="li-main">
|
||||
<span className="li-title">{t(key)}</span>
|
||||
<span className="li-sub"><code className="selectable">{x.id}</code> · {x.state}{x.date ? ` · ${date(x.date)}` : ''}{x.kind === 'emptyDraft' ? ` · ${t('sub.harmless')}` : ''}</span>
|
||||
{x.items.length > 0 && (
|
||||
<ul className="note-list">
|
||||
{x.items.map(i => (
|
||||
<li key={i.id || i.label}><b>{i.label}</b>: {i.state}{i.rejected ? ` · ${i.kind === 'appStoreVersion' ? t('sub.markVersion') : t('sub.markIap')}` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<article className="card sub-card" data-store="ios">
|
||||
<SubHead store="ios" title={t('sub.appleTitle')} done={done} total={5}
|
||||
sub={r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.appleSub')}
|
||||
actions={<>
|
||||
<ReadControl r={{ ...r, refresh: async () => refresh() }} label={t('sub.read')} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(ascUrl(app.ios?.app.id ?? summary?.apple?.appId)).catch(() => {})}><Icon name="external" />App Store Connect</button>
|
||||
</>} />
|
||||
<div className="sub-body">
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
{!s ? (r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <EmptyState icon="cloud"><b>{t('sub.notRead')}</b> {t('sub.notReadText')}</EmptyState>) : (
|
||||
<ol className="sub-steps" aria-label={t('v2rel.steps')}>
|
||||
<Step n={1} state={states[0]} title={t('v2rel.stepRead')}>
|
||||
{open.length > 0 ? (
|
||||
<div className="sub-list" aria-label={t('sub.submissions')}>
|
||||
{open.slice(0, 6).map(x => {
|
||||
const [tone, key] = SUB_KIND[x.kind];
|
||||
return (
|
||||
<div className={`sub-item tone-${tone}`} key={x.id} data-sub-kind={x.kind}>
|
||||
<span className="sub-item-ic"><Icon name={x.kind === 'unresolved' ? 'alertCircle' : x.kind === 'emptyDraft' ? 'info' : 'clock'} /></span>
|
||||
<div className="sub-item-m">
|
||||
<span className="sub-item-t">{t(key)}</span>
|
||||
<span className="sub-item-s"><code className="selectable">{x.id}</code> · {x.state}{x.date ? ` · ${date(x.date)}` : ''}{x.kind === 'emptyDraft' ? ` · ${t('sub.harmless')}` : ''}</span>
|
||||
{x.items.length > 0 && (
|
||||
<ul className="note-list">
|
||||
{x.items.map(i => (
|
||||
<li key={i.id || i.label}><b>{i.label}</b>: {i.state}{i.rejected ? ` · ${i.kind === 'appStoreVersion' ? t('sub.markVersion') : t('sub.markIap')}` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Chip tone={tone}>{x.state}</Chip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <span className="sub-hint">{t('sub.noOpen')}</span>}
|
||||
</Step>
|
||||
<Step n={2} state={states[1]} title={t('v2rel.stepNoOpen')}>
|
||||
{s.busy && <Notice tone="info" icon="clock">{t('sub.busy', { state: s.busy.state })}</Notice>}
|
||||
{!s.busy && s.unresolved && <Notice tone="bad" icon="alertCircle">{t('sub.unresolvedText')}</Notice>}
|
||||
</Step>
|
||||
{!s.busy && (
|
||||
<>
|
||||
<Step n={3} state={states[2]} title={t('v2rel.stepBuild')}>
|
||||
<label className="sub-field">
|
||||
<span className="sr">{t('sub.build')}</span>
|
||||
<select className="input" value={build} onChange={e => setBuild(e.target.value)} aria-describedby="sub-build-hint">
|
||||
<option value="">{t('sub.pickBuild')}</option>
|
||||
{s.builds.map(b => (
|
||||
<option key={b.id} value={b.number} disabled={!b.valid}>
|
||||
{`${b.number}${b.version ? ` · ${b.version}` : ''} · ${b.state}${b.uploaded ? ` · ${date(b.uploaded)}` : ''}${b.attachedTo ? ` · ${t('sub.attachedTo', { version: b.attachedTo })}` : ''}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span id="sub-build-hint" className="sub-hint">{s.builds.length ? t('sub.buildHint') : t('sub.noBuilds')}</span>
|
||||
</Step>
|
||||
<Step n={4} state={states[3]} title={t('v2rel.stepVersion')}>
|
||||
<div className="sub-version">
|
||||
<span className="sr">{t('sub.version')}</span>
|
||||
{version ? <><span className="sub-version-v num">{version.version}</span><span className="sub-hint">{platformLabel(version.platform)} · {version.state}</span></> : <Chip tone="warn" icon="question">{t('sub.noVersion')}</Chip>}
|
||||
</div>
|
||||
</Step>
|
||||
<Step n={5} state={states[4]} title={s.unresolved ? t('sub.resubmit') : t('v2rel.stepSend')}>
|
||||
{s.unresolved ? (
|
||||
<>
|
||||
<label className="row small sub-check"><input type="checkbox" checked={resolveIaps} onChange={e => setResolveIaps(e.target.checked)} />{t('sub.resolveIaps')}{rejectedIap ? ` ${t('sub.resolveIapsNeeded')}` : ''}</label>
|
||||
<div className="actions sub-go">
|
||||
<OwnerAction project={project} method="owner.apple_resubmit" args={args} label={t('sub.resubmit')} icon="send" primary disabled={!picked || !version}
|
||||
hint={!picked ? t('sub.pickFirst') : undefined} done={t('sub.resubmitted')} lead={t('sub.resubmitLead')} after={refresh} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="actions sub-go">
|
||||
<OwnerAction project={project} method="owner.apple_submit" args={args} label={t('sub.submit')} icon="send" primary disabled={!picked || !version}
|
||||
hint={!picked ? t('sub.pickFirst') : undefined} done={t('sub.submitted')} lead={t('sub.submitLead')} after={refresh} />
|
||||
</div>
|
||||
<Chip tone={tone}>{x.state}</Chip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</Step>
|
||||
</>
|
||||
)}
|
||||
</ol>
|
||||
)}
|
||||
{s && !s.busy && iaps > 0 && (
|
||||
<div className="sub-extra">
|
||||
<div className="sub-extra-h"><Icon name="tag" /><span className="strong">{t('sub.iapTitle', { count: iaps })}</span></div>
|
||||
<span className="sub-hint">{t('sub.iapText')}</span>
|
||||
<div className="row" style={{ gap: 10, flexWrap: 'wrap' }}>
|
||||
<label className="row small" style={{ gap: 8 }}><input type="checkbox" checked={withVersion} onChange={e => setWithVersion(e.target.checked)} />{t('sub.withVersion')}</label>
|
||||
<OwnerAction project={project} method="owner.apple_iap_submit" args={withVersion ? { with_version: true } : {}} label={t('sub.iapSubmit')} icon="tag"
|
||||
done={t('sub.submitted')} lead={t('sub.iapLead')} after={refresh} />
|
||||
</div>
|
||||
)}
|
||||
{!s.submissions.some(x => x.kind !== 'done') && <span className="small muted">{t('sub.noOpen')}</span>}
|
||||
{s.busy && <Notice tone="info" icon="clock">{t('sub.busy', { state: s.busy.state })}</Notice>}
|
||||
{!s.busy && (
|
||||
<>
|
||||
<div className="row" style={{ gap: 10, flexWrap: 'wrap', alignItems: 'end' }}>
|
||||
<label className="stack" style={{ gap: 4 }}>
|
||||
<span className="small strong">{t('sub.build')}</span>
|
||||
<select className="input input-sm" value={build} onChange={e => setBuild(e.target.value)} aria-describedby="sub-build-hint">
|
||||
<option value="">{t('sub.pickBuild')}</option>
|
||||
{s.builds.map(b => (
|
||||
<option key={b.id} value={b.number} disabled={!b.valid}>
|
||||
{`${b.number}${b.version ? ` · ${b.version}` : ''} · ${b.state}${b.uploaded ? ` · ${date(b.uploaded)}` : ''}${b.attachedTo ? ` · ${t('sub.attachedTo', { version: b.attachedTo })}` : ''}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="stack small" style={{ gap: 4 }}>
|
||||
<span className="strong">{t('sub.version')}</span>
|
||||
<span>{version ? `${version.version} · ${platformLabel(version.platform)} · ${version.state}` : <Chip tone="warn" icon="question">{t('sub.noVersion')}</Chip>}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span id="sub-build-hint" className="small muted">{s.builds.length ? t('sub.buildHint') : t('sub.noBuilds')}</span>
|
||||
{s.unresolved ? (
|
||||
<>
|
||||
<Notice tone="bad" icon="alertCircle">{t('sub.unresolvedText')}</Notice>
|
||||
<label className="row small" style={{ gap: 8 }}><input type="checkbox" checked={resolveIaps} onChange={e => setResolveIaps(e.target.checked)} />{t('sub.resolveIaps')}{rejectedIap ? ` ${t('sub.resolveIapsNeeded')}` : ''}</label>
|
||||
<div className="actions">
|
||||
<OwnerAction project={project} method="owner.apple_resubmit" args={args} label={t('sub.resubmit')} icon="send" primary disabled={!picked || !version}
|
||||
hint={!picked ? t('sub.pickFirst') : undefined} done={t('sub.resubmitted')} lead={t('sub.resubmitLead')} after={refresh} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="actions">
|
||||
<OwnerAction project={project} method="owner.apple_submit" args={args} label={t('sub.submit')} icon="send" primary disabled={!picked || !version}
|
||||
hint={!picked ? t('sub.pickFirst') : undefined} done={t('sub.submitted')} lead={t('sub.submitLead')} after={refresh} />
|
||||
</div>
|
||||
)}
|
||||
{iaps > 0 && (
|
||||
<div className="sub-iaps stack" style={{ gap: 6 }}>
|
||||
<span className="small strong">{t('sub.iapTitle', { count: iaps })}</span>
|
||||
<span className="small muted">{t('sub.iapText')}</span>
|
||||
<div className="row" style={{ gap: 10, flexWrap: 'wrap' }}>
|
||||
<label className="row small" style={{ gap: 8 }}><input type="checkbox" checked={withVersion} onChange={e => setWithVersion(e.target.checked)} />{t('sub.withVersion')}</label>
|
||||
<OwnerAction project={project} method="owner.apple_iap_submit" args={withVersion ? { with_version: true } : {}} label={t('sub.iapSubmit')} icon="tag"
|
||||
done={t('sub.submitted')} lead={t('sub.iapLead')} after={refresh} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -141,57 +204,76 @@ export function PlayProductionCard({ app, project }: { app: UnifiedApp; project:
|
|||
const [share, setShare] = useState('10');
|
||||
const picked = s?.codes.find(c => c.code === code) ?? null;
|
||||
const args = { version_code: code, status, ...(status === 'inProgress' ? { percent: Number(share) } : {}) };
|
||||
const managed = pr && pr.managedPublishing !== true ? <Notice tone="warn" icon="alert">{pr.managedPublishing === false ? t('sub.managedOff') : t('sub.managedUnset')}</Notice>
|
||||
: pr && pr.managedPublishing === true ? <Notice tone="info" icon="info">{t('sub.managedOn')}</Notice> : null;
|
||||
const states: StepState[] = s ? [
|
||||
pr && pr.managedPublishing !== true ? 'warn' : 'ready',
|
||||
picked ? 'ready' : s.codes.length ? 'todo' : 'blocked',
|
||||
'ready',
|
||||
picked ? 'ready' : 'todo',
|
||||
] : [];
|
||||
const done = s ? states.filter(x => x === 'ready').length : null;
|
||||
return (
|
||||
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.playSub')}</span>}
|
||||
actions={<>
|
||||
<ReadControl r={r} label={t('sub.read')} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>
|
||||
</>}>
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
<div className="stack">
|
||||
{pr && pr.managedPublishing !== true && <Notice tone="warn" icon="alert">{pr.managedPublishing === false ? t('sub.managedOff') : t('sub.managedUnset')}</Notice>}
|
||||
{pr && pr.managedPublishing === true && <Notice tone="info" icon="info">{t('sub.managedOn')}</Notice>}
|
||||
{!s ? (r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <Empty compact illo="cloud" title={t('sub.notRead')}>{t('sub.playNotReadText')}</Empty>) : (
|
||||
<article className="card sub-card" data-store="android">
|
||||
<SubHead store="android" title={t('sub.playTitle')} done={done} total={4}
|
||||
sub={r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.playSub')}
|
||||
actions={<>
|
||||
<ReadControl r={r} label={t('sub.read')} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>
|
||||
</>} />
|
||||
<div className="sub-body">
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
{!s ? (
|
||||
<>
|
||||
{s.production.length > 0 && (
|
||||
<span className="small">{t('sub.prodNow')}: {s.production.map(p => `${p.name ?? p.codes.join(', ')} · ${p.status}${p.fraction !== null ? ` · ${Math.round(p.fraction * 100)} %` : ''}`).join('; ')}</span>
|
||||
)}
|
||||
<div className="row" style={{ gap: 10, flexWrap: 'wrap', alignItems: 'end' }}>
|
||||
<label className="stack" style={{ gap: 4 }}>
|
||||
<span className="small strong">{t('sub.versionCode')}</span>
|
||||
<select className="input input-sm" value={code} onChange={e => setCode(e.target.value)} aria-describedby="sub-code-hint">
|
||||
{managed}
|
||||
{r.loading && r.data === null && !r.error ? <Skeleton lines={3} /> : <EmptyState icon="cloud"><b>{t('sub.notRead')}</b> {t('sub.playNotReadText')}</EmptyState>}
|
||||
</>
|
||||
) : (
|
||||
<ol className="sub-steps" aria-label={t('v2rel.steps')}>
|
||||
<Step n={1} state={states[0]} title={t('v2rel.stepRead')}>
|
||||
{managed}
|
||||
{s.production.length > 0 && (
|
||||
<span className="sub-hint">{t('sub.prodNow')}: {s.production.map(p => `${p.name ?? p.codes.join(', ')} · ${p.status}${p.fraction !== null ? ` · ${Math.round(p.fraction * 100)} %` : ''}`).join('; ')}</span>
|
||||
)}
|
||||
</Step>
|
||||
<Step n={2} state={states[1]} title={t('v2rel.stepCode')}>
|
||||
<label className="sub-field">
|
||||
<span className="sr">{t('sub.versionCode')}</span>
|
||||
<select className="input" value={code} onChange={e => setCode(e.target.value)} aria-describedby="sub-code-hint">
|
||||
<option value="">{t('sub.pickCode')}</option>
|
||||
{s.codes.map(c => <option key={c.code} value={c.code} disabled={c.inProduction}>{`${c.code}${c.name ? ` · ${c.name}` : ''} · ${c.tracks.join(', ')}${c.inProduction ? ` · ${t('sub.inProduction')}` : ''}`}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<span className="small strong">{t('sub.as')}</span>
|
||||
<span id="sub-code-hint" className="sub-hint">{s.codes.length ? t('sub.codeHint') : t('sub.noCodes')}</span>
|
||||
</Step>
|
||||
<Step n={3} state={states[2]} title={t('sub.as')}>
|
||||
<div className="row" style={{ gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Seg value={status} onChange={setStatus} label={t('sub.as')} options={[['completed', t('sub.sCompleted')], ['inProgress', t('sub.sStaged')], ['draft', t('sub.sDraft')]]} />
|
||||
{status === 'inProgress' && (
|
||||
<label className="row small" style={{ gap: 8 }}>
|
||||
<span className="strong">{t('roll.share')}</span>
|
||||
<select className="input input-sm" value={share} onChange={e => setShare(e.target.value)}>{SHARES.map(x => <option key={x} value={x}>{x} %</option>)}</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{status === 'inProgress' && (
|
||||
<label className="stack" style={{ gap: 4 }}>
|
||||
<span className="small strong">{t('roll.share')}</span>
|
||||
<select className="input input-sm" value={share} onChange={e => setShare(e.target.value)}>{SHARES.map(x => <option key={x} value={x}>{x} %</option>)}</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<span id="sub-code-hint" className="small muted">{s.codes.length ? t('sub.codeHint') : t('sub.noCodes')}</span>
|
||||
<div className="actions">
|
||||
<OwnerAction project={project} method="owner.play_production" args={args} label={status === 'draft' ? t('sub.playDraft') : t('sub.playSend')} icon="send" primary={status !== 'draft'}
|
||||
disabled={!picked} hint={!picked ? t('sub.pickCodeFirst') : undefined} done={status === 'draft' ? t('sub.playDrafted') : t('sub.playSent')} lead={status === 'draft' ? t('sub.playDraftLead') : t('sub.playLead')} after={() => { r.refresh(); }} />
|
||||
</div>
|
||||
</>
|
||||
</Step>
|
||||
<Step n={4} state={states[3]} title={status === 'draft' ? t('sub.playDraft') : t('sub.playSend')}>
|
||||
<div className="actions sub-go">
|
||||
<OwnerAction project={project} method="owner.play_production" args={args} label={status === 'draft' ? t('sub.playDraft') : t('sub.playSend')} icon="send" primary={status !== 'draft'}
|
||||
disabled={!picked} hint={!picked ? t('sub.pickCodeFirst') : undefined} done={status === 'draft' ? t('sub.playDrafted') : t('sub.playSent')} lead={status === 'draft' ? t('sub.playDraftLead') : t('sub.playLead')} after={() => { r.refresh(); }} />
|
||||
</div>
|
||||
</Step>
|
||||
</ol>
|
||||
)}
|
||||
<Notice tone="info" icon="clipboard" action={<div className="actions">
|
||||
<ConsolePlanButton project={project} form="play-production-countries" label={t('cp.playCountries')} />
|
||||
<ConsolePlanButton project={project} form="play-production-release" label={t('sub.sendChanges')} />
|
||||
</div>}>
|
||||
<div className="stack" style={{ gap: 2 }}>
|
||||
<b>{t('sub.consoleStepTitle')}</b>
|
||||
<span className="small">{t('sub.consoleStepText', { countries: pr?.countries ? t('sub.countriesN', { count: pr.countries }) : t('sub.countriesAll') })}</span>
|
||||
<div className="sub-extra">
|
||||
<div className="sub-extra-h"><Icon name="clipboard" /><span className="strong">{t('sub.consoleStepTitle')}</span></div>
|
||||
<span className="sub-hint">{t('sub.consoleStepText', { countries: pr?.countries ? t('sub.countriesN', { count: pr.countries }) : t('sub.countriesAll') })}</span>
|
||||
<div className="actions">
|
||||
<ConsolePlanButton project={project} form="play-production-countries" label={t('cp.playCountries')} />
|
||||
<ConsolePlanButton project={project} form="play-production-release" label={t('sub.sendChanges')} />
|
||||
</div>
|
||||
</Notice>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// TestFlight: beta groups (internal / external, testers, builds, public link) and the recent builds with their
|
||||
// processing and beta review states.
|
||||
import { Fragment, useState } from 'react';
|
||||
// v2: totals as stat tiles, groups as cards with the tester count, builds as cards with the three states as pills.
|
||||
import { useState } from 'react';
|
||||
import type { UnifiedApp } from '../../lib/identity';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { t } from '../../i18n';
|
||||
|
|
@ -8,7 +9,9 @@ import { date, num } from '../../lib/format';
|
|||
import type { TestFlightStatus, TfBuild, TfGroup, Tone } from '../../lib/model';
|
||||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { Card, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatTile, StatusPill, tintStyle, useTint } from '../../ui/ds';
|
||||
import '../../styles/testflight.css';
|
||||
|
||||
/** Beta state word -> tone and a short label. */
|
||||
export function betaState(s: string | null): { tone: Tone; text: string } {
|
||||
|
|
@ -20,9 +23,15 @@ export function betaState(s: string | null): { tone: Tone; text: string } {
|
|||
return { tone: 'neutral', text: s.replace(/_/g, ' ').toLowerCase() };
|
||||
}
|
||||
|
||||
function State({ s }: { s: string | null }) {
|
||||
/** One labelled beta state of a build: label above, pill below (a dash when the store has none). */
|
||||
function State({ label, s }: { label: string; s: string | null }) {
|
||||
const b = betaState(s);
|
||||
return s ? <Chip tone={b.tone}>{b.text}</Chip> : <span className="muted small">–</span>;
|
||||
return (
|
||||
<div className="tf-state">
|
||||
<span className="tf-state-l">{label}</span>
|
||||
{s ? <StatusPill tone={b.tone} title={s}>{b.text}</StatusPill> : <span className="tf-dash">–</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One build's "What to Test" and its internal groups: confirm-gated writes (dry run, dialog, apply). */
|
||||
|
|
@ -33,7 +42,7 @@ function BuildEditor({ b, groups, w, mode, onDone }: { b: TfBuild; groups: TfGro
|
|||
const base = { build: b.build ?? '', ...(b.version ? { version: b.version } : {}) };
|
||||
if (mode === 'what') {
|
||||
return (
|
||||
<div className="build-edit">
|
||||
<div className="build-edit tf-edit">
|
||||
<label className="small strong" htmlFor={`wtt-${b.build}`}>{t('tf.whatToTest', { build: b.build ?? '' })}</label>
|
||||
<textarea id={`wtt-${b.build}`} className="input" rows={3} maxLength={4000} value={text} onChange={e => setText(e.target.value)} placeholder={t('tf.whatPlaceholder')} />
|
||||
<div className="actions">
|
||||
|
|
@ -44,11 +53,16 @@ function BuildEditor({ b, groups, w, mode, onDone }: { b: TfBuild; groups: TfGro
|
|||
);
|
||||
}
|
||||
return (
|
||||
<div className="build-edit">
|
||||
<div className="build-edit tf-edit">
|
||||
<span className="small strong">{t('tf.toInternal', { build: b.build ?? '' })}</span>
|
||||
{internal.length ? (
|
||||
<div className="row" style={{ gap: 14, flexWrap: 'wrap' }}>
|
||||
{internal.map(g => <label key={g.id} className="check"><input type="checkbox" checked={picked.includes(g.name)} onChange={e => setPicked(p => (e.target.checked ? [...p, g.name] : p.filter(x => x !== g.name)))} />{g.name}</label>)}
|
||||
<div className="tf-chips" role="group" aria-label={t('tf.toInternal', { build: b.build ?? '' })}>
|
||||
{internal.map(g => (
|
||||
<label key={g.id} className={`tf-chip${picked.includes(g.name) ? ' on' : ''}`}>
|
||||
<input type="checkbox" checked={picked.includes(g.name)} onChange={e => setPicked(p => (e.target.checked ? [...p, g.name] : p.filter(x => x !== g.name)))} />
|
||||
<Icon name="users" />{g.name}<span className="tf-chip-n num">{num(g.testers)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : <span className="small muted">{t('tf.noInternal')}</span>}
|
||||
<div className="actions">
|
||||
|
|
@ -62,89 +76,118 @@ function BuildEditor({ b, groups, w, mode, onDone }: { b: TfBuild; groups: TfGro
|
|||
function Testers({ groups, w }: { groups: TfGroup[]; w: ReturnType<typeof useWrite> }) {
|
||||
const [group, setGroup] = useState('');
|
||||
return (
|
||||
<Card title={t('tf.testersTitle')} icon="users" sub={t('tf.testersSub')}>
|
||||
<Section title={t('tf.testersTitle')} icon="users" sub={t('tf.testersSub')} className="tf-testers">
|
||||
<div className="row" style={{ gap: 10, flexWrap: 'wrap' }}>
|
||||
<select className="input input-sm" value={group} onChange={e => setGroup(e.target.value)} aria-label={t('tf.testersGroup')}>
|
||||
<select className="input" value={group} onChange={e => setGroup(e.target.value)} aria-label={t('tf.testersGroup')}>
|
||||
<option value="">{t('tf.testersAll')}</option>
|
||||
{groups.map(g => <option key={g.id} value={g.name}>{g.name}</option>)}
|
||||
</select>
|
||||
<WriteButton run={w} icon="users" w={{ method: 'owner.testflight_testers', args: group ? { groups: group } : {}, title: t('tf.testersAdd'), done: t('tf.testersDone') }} hint={t('tf.testersHint')}>{t('tf.testersAdd')}</WriteButton>
|
||||
</div>
|
||||
<p className="small muted act-note"><Icon name="shield" />{t('tf.testersNote')}</p>
|
||||
</Card>
|
||||
<p className="tf-note"><Icon name="shield" />{t('tf.testersNote')}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestFlightTab({ app }: { app: UnifiedApp }) {
|
||||
const d = useData();
|
||||
const e = d.statusFor(app);
|
||||
const hue = useTint(d.iconFor(app), app.name);
|
||||
const r = useRead<TestFlightStatus>(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight' });
|
||||
const w = useWrite(app.project?.id ?? null);
|
||||
const [open, setOpen] = useState<{ build: string; mode: 'what' | 'groups' } | null>(null);
|
||||
const writable = !!app.project;
|
||||
if ((e && !e.app?.platforms.ios) || (!e && !app.project && !app.ios)) return <Card pad={false}><Empty illo="plane" title={t('tf.noIos')}>{t('tf.noIosText')}</Empty></Card>;
|
||||
const tint = tintStyle(hue);
|
||||
if ((e && !e.app?.platforms.ios) || (!e && !app.project && !app.ios)) {
|
||||
return <div className="scr-testflight" style={tint}><Section><EmptyState icon="send"><b>{t('tf.noIos')}</b> {t('tf.noIosText')}</EmptyState></Section></div>;
|
||||
}
|
||||
if (!r.data) {
|
||||
if (r.loading && !r.error) return <Card><Skeleton lines={4} block /></Card>;
|
||||
if (r.loading && !r.error) return <div className="scr-testflight" style={tint}><Card><Skeleton lines={4} block /></Card></div>;
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<Empty illo="plane" title={t('tf.emptyTitle')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('tf.read')}</button>}>{t('tf.emptyText')}</Empty>
|
||||
{r.error && <div className="card-foot"><Icon name="alertCircle" />{r.error}</div>}
|
||||
</Card>
|
||||
<div className="scr-testflight" style={tint}>
|
||||
<Section>
|
||||
<EmptyState icon="send" tone={r.error ? 'bad' : undefined} action={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('tf.read')}</button>}>
|
||||
<b>{t('tf.emptyTitle')}</b> {t('tf.emptyText')}
|
||||
</EmptyState>
|
||||
{r.error && <p className="tf-err"><Icon name="alertCircle" />{r.error}</p>}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const s = r.data;
|
||||
const testers = s.groups.reduce((n, g) => n + g.testers, 0);
|
||||
const usableOf = (b: TfBuild) => !!b.build && b.processing === 'VALID' && !/EXPIRED/.test(`${b.internal ?? ''}`);
|
||||
const ready = s.builds.filter(usableOf).length;
|
||||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span />
|
||||
<ReadControl r={r} />
|
||||
<div className="scr-testflight" style={tint}>
|
||||
<div className="tf-top">
|
||||
<div className="ds-grid tf-kpis">
|
||||
<StatTile label={t('tf.testers')} icon="users" value={num(testers)} sub={t('v2rel.tfInGroups', { count: s.groups.length })} />
|
||||
<StatTile label={t('v2rel.tfGroups')} icon="layers" value={num(s.groups.length)} sub={t('v2rel.tfSplit', { internal: s.groups.filter(g => g.internal).length, external: s.groups.filter(g => !g.internal).length })} />
|
||||
<StatTile label={t('tf.builds')} icon="cube" value={num(s.builds.length)} />
|
||||
<StatTile label={t('v2rel.tfUsable')} icon="check" value={num(ready)} tone={ready ? 'ok' : undefined} />
|
||||
</div>
|
||||
<div className="tf-read"><ReadControl r={r} /></div>
|
||||
</div>
|
||||
{s.missingGroups.length > 0 && (
|
||||
<Notice tone="warn" action={writable ? <WriteButton run={w} icon="plus" w={{ method: 'apple.testflight_groups', args: {}, title: t('tf.groupsSync'), done: t('tf.groupsSynced'), after: r.refresh }} hint={t('tf.groupsSyncHint')}>{t('tf.groupsSync')}</WriteButton> : undefined}>
|
||||
{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}
|
||||
</Notice>
|
||||
)}
|
||||
<div className="grid-cards">
|
||||
{s.groups.map(g => (
|
||||
<div key={g.id} className="tf-group">
|
||||
<div className="row between"><span className="strong">{g.name}</span><Chip tone={g.internal ? 'info' : 'accent'}>{g.internal ? t('tf.internal') : t('tf.external')}</Chip></div>
|
||||
<div className="stat">
|
||||
<div><b>{num(g.testers)}</b><span className="small muted">{t('tf.testers')}</span></div>
|
||||
<div><b>{g.builds === 'all' ? t('tf.all') : num(g.builds)}</b><span className="small muted">{t('tf.builds')}</span></div>
|
||||
</div>
|
||||
<div className="row small muted" style={{ gap: 6 }}><Icon name={g.publicLink ? 'globe' : 'users'} className="ic13" />{g.publicLink ? t('tf.publicOn') : t('tf.publicOff')}</div>
|
||||
<Section title={t('v2rel.tfGroups')} icon="users" plain>
|
||||
{s.groups.length ? (
|
||||
<div className="tf-groups">
|
||||
{s.groups.map(g => (
|
||||
<article key={g.id} className={`tf-group ${g.internal ? 'internal' : 'external'}`} aria-label={g.name}>
|
||||
<div className="tf-group-h">
|
||||
<span className="tf-group-n">{g.name}</span>
|
||||
<StatusPill tone={g.internal ? 'info' : 'accent'}>{g.internal ? t('tf.internal') : t('tf.external')}</StatusPill>
|
||||
</div>
|
||||
<div className="tf-group-big">
|
||||
<span className="num">{num(g.testers)}</span>
|
||||
<span className="tf-group-l">{t('tf.testers')}</span>
|
||||
</div>
|
||||
<div className="tf-group-f">
|
||||
<span className="tf-tag"><Icon name="cube" /><b className="num">{g.builds === 'all' ? t('tf.all') : num(g.builds)}</b> {t('tf.builds')}</span>
|
||||
<span className={`tf-tag${g.publicLink ? ' on' : ''}`}><Icon name={g.publicLink ? 'globe' : 'users'} />{g.publicLink ? t('tf.publicOn') : t('tf.publicOff')}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{!s.groups.length && <Card><Empty compact illo="plane" title={t('tf.noGroups')} /></Card>}
|
||||
</div>
|
||||
<Card title={t('tf.buildsTitle')} icon="cube" sub={t('tf.buildsSub')} pad={false}>
|
||||
) : <EmptyState icon="users">{t('tf.noGroups')}</EmptyState>}
|
||||
</Section>
|
||||
<Section title={t('tf.buildsTitle')} icon="cube" sub={t('tf.buildsSub')}>
|
||||
{s.builds.length ? (
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.version')}</th><th>{t('col.build')}</th><th>{t('tf.processing')}</th><th>{t('tf.internalTesting')}</th><th>{t('tf.externalTesting')}</th><th>{t('tf.uploaded')}</th>{writable && <th className="right"><span className="sr">{t('col.actions')}</span></th>}</tr></thead>
|
||||
<tbody>
|
||||
{s.builds.map((b, i) => {
|
||||
const usable = !!b.build && b.processing === 'VALID' && !/EXPIRED/.test(`${b.internal ?? ''}`);
|
||||
const here = open?.build === b.build ? open.mode : null;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<tr>
|
||||
<td className="strong num">{b.version ?? '–'}</td><td className="num">{b.build ?? '–'}</td>
|
||||
<td><State s={b.processing} /></td><td><State s={b.internal} /></td><td><State s={b.external} /></td>
|
||||
<td className="muted">{date(b.uploaded)}</td>
|
||||
{writable && <td className="right nowrap">{usable && <div className="actions" style={{ justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-sm btn-ghost" aria-expanded={here === 'what'} onClick={() => setOpen(here === 'what' ? null : { build: b.build!, mode: 'what' })}><Icon name="text" />{t('tf.what')}</button>
|
||||
<button className="btn btn-sm btn-ghost" aria-expanded={here === 'groups'} onClick={() => setOpen(here === 'groups' ? null : { build: b.build!, mode: 'groups' })}><Icon name="users" />{t('tf.groupsBtn')}</button>
|
||||
</div>}</td>}
|
||||
</tr>
|
||||
{here && <tr className="edit-row"><td colSpan={7}><BuildEditor b={b} groups={s.groups} w={w} mode={here} onDone={() => { setOpen(null); r.refresh(); }} /></td></tr>}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <Empty compact illo="plane" title={t('tf.noBuilds')} />}
|
||||
</Card>
|
||||
<ul className="tf-builds">
|
||||
{s.builds.map((b, i) => {
|
||||
const usable = usableOf(b);
|
||||
const here = open?.build === b.build ? open.mode : null;
|
||||
return (
|
||||
<li key={i} className={`tf-build${here ? ' open' : ''}`}>
|
||||
<div className="tf-build-row">
|
||||
<div className="tf-build-id">
|
||||
<span className="tf-build-v num">{b.version ?? '–'}</span>
|
||||
<span className="tf-build-n">{t('col.build')} <b className="num">{b.build ?? '–'}</b></span>
|
||||
<span className="tf-build-d"><Icon name="upload" />{date(b.uploaded)}</span>
|
||||
</div>
|
||||
<div className="tf-build-states">
|
||||
<State label={t('tf.processing')} s={b.processing} />
|
||||
<State label={t('tf.internalTesting')} s={b.internal} />
|
||||
<State label={t('tf.externalTesting')} s={b.external} />
|
||||
</div>
|
||||
{writable && <div className="tf-build-a">{usable && <div className="actions">
|
||||
<button className="btn btn-sm" aria-expanded={here === 'what'} onClick={() => setOpen(here === 'what' ? null : { build: b.build!, mode: 'what' })}><Icon name="text" />{t('tf.what')}</button>
|
||||
<button className="btn btn-sm" aria-expanded={here === 'groups'} onClick={() => setOpen(here === 'groups' ? null : { build: b.build!, mode: 'groups' })}><Icon name="users" />{t('tf.groupsBtn')}</button>
|
||||
</div>}</div>}
|
||||
</div>
|
||||
{here && <BuildEditor b={b} groups={s.groups} w={w} mode={here} onDone={() => { setOpen(null); r.refresh(); }} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : <EmptyState icon="cube">{t('tf.noBuilds')}</EmptyState>}
|
||||
</Section>
|
||||
{writable && s.groups.length > 0 && <Testers groups={s.groups} w={w} />}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import { api, errorText, type MethodDescriptor, newJob, type Project, type ToolR
|
|||
import { type Key, t } from '../../i18n';
|
||||
import { useData, useWrite } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit';
|
||||
import { ErrorNotice, Skeleton } from '../../ui/kit';
|
||||
import { Section, StatusPill } from '../../ui/ds';
|
||||
import '../../styles/tools.css';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
|
@ -67,33 +69,37 @@ function Tool({ m, project }: { m: MethodDescriptor; project: Project }) {
|
|||
setBusy(false);
|
||||
};
|
||||
return (
|
||||
<div className="tool" data-testid={`tool-${m.method}`}>
|
||||
<div className="row between">
|
||||
<div className="stack" style={{ gap: 2 }}>
|
||||
<span className="strong">{m.title}</span>
|
||||
<span className="small muted">{m.description}</span>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Chip tone="outline" dot={false}>{m.kind === 'read' ? t('tools.read') : t('tools.write')}</Chip>
|
||||
{m.network && <Chip tone="outline" dot={false} icon="cloud">{t('tools.network')}</Chip>}
|
||||
<div className={`tl-cmd tool${res || err ? ' has-out' : ''}`} data-testid={`tool-${m.method}`}>
|
||||
<div className="tl-cmd-h">
|
||||
<span className={`tl-cmd-ic ${m.kind === 'read' ? 'read' : 'write'}`}><Icon name={m.kind === 'read' ? 'eye' : 'edit'} /></span>
|
||||
<div className="tl-cmd-t">
|
||||
<span className="tl-cmd-title">{m.title}</span>
|
||||
<span className="tl-cmd-desc">{m.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tl-cmd-tags">
|
||||
<StatusPill tone={m.kind === 'read' ? 'info' : 'warn'}>{m.kind === 'read' ? t('tools.read') : t('tools.write')}</StatusPill>
|
||||
{m.network && <StatusPill tone="neutral" icon="cloud">{t('tools.network')}</StatusPill>}
|
||||
<code className="tl-cmd-m mono-id">{m.method}</code>
|
||||
</div>
|
||||
{props.length > 0 && (
|
||||
<div className="tool-form">
|
||||
<div className="tool-form tl-form">
|
||||
{props.map(([k, s]) => <Field key={k} name={k} s={s} value={v[k]} required={required.has(k)} onChange={x => setV(p => ({ ...p, [k]: x }))} />)}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<div className="tl-cmd-run">
|
||||
{m.kind === 'read'
|
||||
? <button className="btn btn-sm" disabled={busy || missing} aria-busy={busy || undefined} onClick={() => { void read(); }}><Icon name={busy ? 'refresh' : 'play'} className={busy ? 'spin' : undefined} />{t('tools.run')}</button>
|
||||
: <WriteButton run={w} icon="play" disabled={missing} w={{ method: m.method, args, title: m.title, done: t('tools.done', { title: m.title }) }}>{t('tools.dryRun')}</WriteButton>}
|
||||
</div>
|
||||
{err && <ErrorNotice error={err} />}
|
||||
{res && (
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<span className="small muted">{res.command} · {t('tools.exit', { code: res.exitCode })}</span>
|
||||
<div className="tl-out">
|
||||
<div className="tl-out-h">
|
||||
<span>{res.command} · {t('tools.exit', { code: res.exitCode })}</span>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => { navigator.clipboard?.writeText(res.output).then(() => d.toast({ kind: 'ok', title: t('tools.copied') }), () => {}); }}><Icon name="copy" />{t('tools.copy')}</button>
|
||||
</div>
|
||||
<pre className="stack-trace" data-testid="tool-output">{res.output || res.error || (res.data !== undefined ? JSON.stringify(res.data, null, 2) : '')}</pre>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => { navigator.clipboard?.writeText(res.output).then(() => d.toast({ kind: 'ok', title: t('tools.copied') }), () => {}); }}><Icon name="copy" />{t('tools.copy')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -110,31 +116,43 @@ export function ToolsTab({ project }: { project: Project }) {
|
|||
}, []);
|
||||
const by = new Map((methods ?? []).map(m => [m.method, m] as const));
|
||||
const copy = (c: string) => { navigator.clipboard?.writeText(c).then(() => d.toast({ kind: 'ok', title: t('tools.copied') }), () => {}); };
|
||||
const groups = TOOLBOX.map(g => ({ g, ms: g.methods.map(x => by.get(x)).filter((x): x is MethodDescriptor => !!x) }));
|
||||
const cur = groups.find(x => x.g.id === open);
|
||||
return (
|
||||
<>
|
||||
<Notice tone="info" icon="info">{t('tools.lead')}</Notice>
|
||||
<div className="scr-tools">
|
||||
<p className="tl-lead"><Icon name="info" />{t('tools.lead')}</p>
|
||||
{err && <ErrorNotice error={err} />}
|
||||
{!methods && !err && <Skeleton lines={4} />}
|
||||
{methods && TOOLBOX.map(g => {
|
||||
const ms = g.methods.map(x => by.get(x)).filter((x): x is MethodDescriptor => !!x);
|
||||
const isOpen = open === g.id;
|
||||
return (
|
||||
<Card key={g.id} id={`tools-${g.id}`} title={t(`tools.g.${g.id}` as Key)} icon="sliders" sub={t('tools.count', { count: ms.length })}
|
||||
actions={<button className="btn btn-sm btn-ghost" aria-expanded={isOpen} onClick={() => setOpen(isOpen ? '' : g.id)}><Icon name={isOpen ? 'chevronDown' : 'chevronRight'} />{isOpen ? t('tools.hide') : t('tools.show')}</button>}>
|
||||
{isOpen && <div className="stack" style={{ gap: 14 }}>{ms.map(m => <Tool key={m.method} m={m} project={project} />)}</div>}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<Card title={t('tools.owner')} icon="lock" sub={t('tools.ownerSub')} id="tools-owner">
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
{methods && (
|
||||
<div className="tl-groups" role="group" aria-label={t('tab.tools')}>
|
||||
{groups.map(({ g, ms }) => {
|
||||
const isOpen = open === g.id;
|
||||
return (
|
||||
<button key={g.id} type="button" id={`tools-${g.id}`} className="tl-group" aria-expanded={isOpen} aria-controls="tools-open"
|
||||
onClick={() => setOpen(isOpen ? '' : g.id)}>
|
||||
<span className="tl-group-ic"><Icon name="sliders" /></span>
|
||||
<span className="tl-group-t"><b>{t(`tools.g.${g.id}` as Key)}</b><span>{t('tools.count', { count: ms.length })}</span></span>
|
||||
<span className="tl-group-x">{isOpen ? t('tools.hide') : t('tools.show')}<Icon name={isOpen ? 'chevronDown' : 'chevronRight'} /></span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{methods && cur && (
|
||||
<Section id="tools-open" title={t(`tools.g.${cur.g.id}` as Key)} icon="sliders" sub={t('tools.count', { count: cur.ms.length })}>
|
||||
<div className="tl-cmds">{cur.ms.map(m => <Tool key={m.method} m={m} project={project} />)}</div>
|
||||
</Section>
|
||||
)}
|
||||
<Section title={t('tools.owner')} icon="lock" sub={t('tools.ownerSub')} id="tools-owner">
|
||||
<div className="tl-owner">
|
||||
{OWNER_STEPS.map(s => (
|
||||
<div key={s.id} className="row between" data-testid={`owner-step-${s.id}`}>
|
||||
<div className="stack" style={{ gap: 2 }}><span>{t(`tools.o.${s.id}` as Key)}</span><code className="small mono-id">{s.command}</code></div>
|
||||
<div key={s.id} className="tl-step" data-testid={`owner-step-${s.id}`}>
|
||||
<span className="tl-step-t"><span>{t(`tools.o.${s.id}` as Key)}</span><code className="mono-id">{s.command}</code></span>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => copy(s.command)}><Icon name="copy" />{t('tools.copy')}</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
26
apps/desktop/src/styles/accounts.css
Normal file
26
apps/desktop/src/styles/accounts.css
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* Studios & accounts v2: KPI row, one section per studio, account cards per store with key status. */
|
||||
.scr-accounts { gap: 22px; }
|
||||
.scr-accounts .acct-kpis { --ds-min: 200px; }
|
||||
.scr-accounts .studio-card { overflow: hidden; scroll-margin-top: 16px; }
|
||||
.scr-accounts .studio-card .card-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 20px 22px 4px; }
|
||||
.scr-accounts .studio-card .card-head h2 { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); letter-spacing: -.015em; }
|
||||
.scr-accounts .studio-card .card-head .sub { color: var(--muted); font-size: var(--ds-t-meta); }
|
||||
.scr-accounts .studio-card.focus { box-shadow: var(--ds-e3), 0 0 0 2px var(--accent-line); }
|
||||
.scr-accounts .studio-card .card-body { padding: 14px 22px 20px; }
|
||||
.scr-accounts .acct-list { grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 14px; padding: 16px 22px 22px; }
|
||||
.scr-accounts .acct { border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); background: var(--ds-raised); box-shadow: var(--ds-e1); overflow: hidden; transition: box-shadow var(--speed) var(--ease); }
|
||||
.scr-accounts .acct:hover { box-shadow: var(--ds-e2); }
|
||||
.scr-accounts .acct-row { flex-wrap: wrap; align-items: flex-start; padding: 16px; gap: 14px; }
|
||||
.scr-accounts .acct-glyph { width: 42px; height: 42px; border-radius: 12px; border: 0; }
|
||||
.scr-accounts .acct-glyph svg, .scr-accounts .acct-glyph .store-mark { width: 20px; height: 20px; }
|
||||
.scr-accounts .acct-glyph.play { background: color-mix(in srgb, var(--ok-fg, var(--accent-solid)) 16%, transparent); color: var(--ok-fg, var(--accent-solid)); }
|
||||
.scr-accounts .acct-glyph.apple { background: color-mix(in srgb, var(--info-fg, var(--accent-solid)) 16%, transparent); color: var(--info-fg, var(--accent-solid)); }
|
||||
.scr-accounts .acct-main { flex: 1 1 200px; gap: 6px; }
|
||||
.scr-accounts .acct-main .strong { font-size: var(--ds-t-h3); }
|
||||
.scr-accounts .acct-main .small { white-space: normal; line-height: 1.5; }
|
||||
.scr-accounts .acct-row > .actions { flex: 1 1 100%; padding-top: 12px; border-top: 1px solid var(--ds-stroke); }
|
||||
.scr-accounts .acct-edit { padding: 0 16px 16px; grid-template-columns: 1fr 1fr; }
|
||||
.scr-accounts .acct-edit .actions { grid-column: 1 / -1; }
|
||||
.scr-accounts .acct-fix { padding: 0 16px 16px; }
|
||||
.scr-accounts .acct-add { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 18px; align-items: start; }
|
||||
.scr-accounts .acct-add .form { gap: 14px; }
|
||||
45
apps/desktop/src/styles/appinfo.css
Normal file
45
apps/desktop/src/styles/appinfo.css
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* App info tab v2: a store-info sheet (key facts), then the declarations and App Store info sections. Scoped to
|
||||
.scr-appinfo (tab) and .scr-appinfo-ed (its editor). */
|
||||
.scr-appinfo { display: grid; gap: 20px; min-width: 0; }
|
||||
|
||||
.scr-appinfo .ai-sheet {
|
||||
position: relative; overflow: hidden; display: grid; gap: 18px; padding: 22px 26px;
|
||||
border-radius: var(--ds-r-xl); background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e2);
|
||||
}
|
||||
.scr-appinfo .ai-sheet::before { content: ""; position: absolute; inset: 0 0 auto; height: 3px; background: var(--ds-grad); }
|
||||
.scr-appinfo .ai-sheet-h { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 14px; }
|
||||
.scr-appinfo .ai-sheet-h .ds-eyebrow { flex-basis: 100%; margin: 0; }
|
||||
.scr-appinfo .ai-sheet-h h2 { margin: 0; font-size: var(--ds-t-h1); font-weight: var(--ds-w-display); letter-spacing: -.02em; color: var(--ink); }
|
||||
.scr-appinfo .ai-sheet-stores { display: inline-flex; gap: 12px; font-size: var(--ds-t-meta); color: var(--muted); font-weight: 600; }
|
||||
.scr-appinfo .ai-sheet-stores span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.scr-appinfo .ai-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0; margin: 0; border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); overflow: hidden; background: var(--ds-inset); }
|
||||
.scr-appinfo .ai-fact { display: grid; gap: 6px; padding: 14px 16px; border-right: 1px solid var(--ds-stroke); min-width: 0; }
|
||||
.scr-appinfo .ai-fact:last-child { border-right: 0; }
|
||||
.scr-appinfo .ai-fact dt { display: flex; align-items: center; gap: 6px; font-size: var(--ds-t-micro); font-weight: 650; color: var(--muted); }
|
||||
.scr-appinfo .ai-fact dt svg { width: 13px; height: 13px; color: var(--accent-solid); }
|
||||
.scr-appinfo .ai-fact dd { margin: 0; font-size: var(--ds-t-h3); font-weight: var(--ds-w-strong); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scr-appinfo .ai-fact.tone-warn dd { color: var(--warn-ink); }
|
||||
.scr-appinfo .ai-fact.tone-warn dt svg { color: var(--warn-solid); }
|
||||
.scr-appinfo .ai-fact.tone-ok dd { color: var(--ok-ink); }
|
||||
|
||||
.scr-appinfo .ai-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start; }
|
||||
.scr-appinfo .ai-three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start; }
|
||||
@media (max-width: 1240px) { .scr-appinfo .ai-three { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 980px) { .scr-appinfo .ai-two, .scr-appinfo .ai-three { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
/* sections: the old card bodies inside v2 sections */
|
||||
.scr-appinfo .ds-sec .card-body { padding: 0 22px 18px; }
|
||||
.scr-appinfo .ds-sec .card-foot { margin: 0 22px 18px; padding: 10px 0 0; border-top: 1px solid var(--ds-stroke); background: none; }
|
||||
.scr-appinfo .ds-sec .ds-table-wrap { margin: 0 18px 18px; box-shadow: none; }
|
||||
.scr-appinfo .ds-sec > .list, .scr-appinfo .ds-sec > .ds-empty { margin: 0 12px 12px; }
|
||||
.scr-appinfo .ds-sec > .list { display: grid; gap: 8px; }
|
||||
.scr-appinfo .ds-sec > .list .li { border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); background: var(--ds-inset); }
|
||||
.scr-appinfo .ask-card { border-color: color-mix(in srgb, var(--warn-solid) 45%, var(--ds-stroke)); }
|
||||
.scr-appinfo .kv { row-gap: 12px; }
|
||||
.scr-appinfo .kv dt { color: var(--muted); font-weight: 600; }
|
||||
.scr-appinfo .kv dd { color: var(--ink); }
|
||||
.scr-appinfo .ai-foot { margin: 0; font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
|
||||
/* editor below the tab */
|
||||
.scr-appinfo-ed .ds-sec .ed-grid { gap: 14px 18px; }
|
||||
.scr-appinfo-ed .act-col { border-radius: var(--ds-r); background: var(--ds-inset); border-color: var(--ds-stroke); }
|
||||
38
apps/desktop/src/styles/art.css
Normal file
38
apps/desktop/src/styles/art.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* Store art tab, live art and art editor (desktop v2): a screenshot gallery in device frames. Scoped under .scr-art. */
|
||||
.scr-art { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
||||
section.scr-art { display: block; }
|
||||
.scr-art .ds-grid.art-stats { --ds-min: 200px; }
|
||||
.scr-art .art-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 22px; border-bottom: 1px solid var(--ds-stroke); }
|
||||
.scr-art .art-rows { display: grid; gap: 4px; padding: 6px 0; }
|
||||
.scr-art .art-row { padding: 16px 22px; }
|
||||
.scr-art .art-row + .art-row { border-top: 1px solid var(--ds-stroke); }
|
||||
.scr-art .art-row h3 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: var(--ds-t-h3); font-weight: var(--ds-w-strong); margin: 0 0 12px; }
|
||||
.scr-art .art-row h3 code { font-size: var(--ds-t-micro); color: var(--muted); background: var(--ds-inset); padding: 2px 7px; border-radius: var(--ds-r-xs); }
|
||||
.scr-art .art-tiles { gap: 18px; padding: 4px 2px 10px; }
|
||||
.scr-art .art-tile { gap: 8px; position: relative; }
|
||||
|
||||
/* device frames: portrait tiles sit in a phone bezel, landscape ones in a tablet / banner frame */
|
||||
.scr-art .art-tile .art-pic { border-radius: var(--ds-r-sm); border: 2px solid var(--ds-stroke); background: var(--ds-inset); box-shadow: var(--ds-e2); }
|
||||
.scr-art .art-tile.portrait .art-pic { border: 6px solid #15161a; border-radius: 24px; box-shadow: 0 0 0 1px var(--ds-stroke-strong), var(--ds-e2); }
|
||||
.scr-art .art-tile.landscape .art-pic { border: 5px solid #15161a; border-radius: 14px; box-shadow: 0 0 0 1px var(--ds-stroke-strong), var(--ds-e2); }
|
||||
.scr-art .art-tile.portrait .art-pic::after { content: ""; position: absolute; top: 5px; left: 50%; width: 28%; height: 9px; transform: translateX(-50%); border-radius: var(--ds-r-pill); background: #15161a; z-index: 1; pointer-events: none; }
|
||||
.scr-art .art-tile.warn .art-pic { border-color: var(--warn-solid); }
|
||||
.scr-art .art-tile.err .art-pic { border-color: var(--bad-solid); }
|
||||
.scr-art .art-pic:focus-visible { outline: 2px solid var(--tint, var(--accent-solid)); outline-offset: 3px; }
|
||||
.scr-art .art-pic img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
|
||||
.scr-art button.art-pic:hover { transform: translateY(-2px); box-shadow: 0 0 0 1px var(--ds-stroke-strong), var(--ds-e3); }
|
||||
}
|
||||
.scr-art .art-badge { position: absolute; top: 10px; right: 10px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px 2px 6px; border-radius: var(--ds-r-pill); font-size: var(--ds-t-micro); font-weight: 700; color: #fff; box-shadow: var(--ds-e1); }
|
||||
.scr-art .art-badge svg { width: 12px; height: 12px; }
|
||||
.scr-art .art-badge.err { background: var(--bad-solid); }
|
||||
.scr-art .art-badge.warn { background: var(--warn-solid); }
|
||||
.scr-art .art-tile figcaption { display: flex; justify-content: space-between; gap: 6px; font-size: var(--ds-t-micro); }
|
||||
.scr-art .art-tile .ds-pill { justify-self: start; }
|
||||
.scr-art .art-issues { font-size: var(--ds-t-micro); }
|
||||
.scr-art [data-live-icons] .art-tile .art-pic { border-radius: 22%; border: 1px solid var(--ds-stroke); }
|
||||
.scr-art [data-live-icons] .art-tile .art-pic::after { display: none; }
|
||||
|
||||
/* art editor: scene order as a numbered strip */
|
||||
.scr-art.art-editor .ed-scenes li > .num { width: 28px; height: 28px; border-radius: 50%; display: inline-grid; place-items: center; background: color-mix(in srgb, var(--tint, var(--accent-solid)) 16%, transparent); color: var(--tint, var(--accent-solid)); }
|
||||
.scr-art.art-editor [role="tablist"] .btn { border-radius: var(--ds-r-pill); }
|
||||
29
apps/desktop/src/styles/catalog.css
Normal file
29
apps/desktop/src/styles/catalog.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* Catalog tab + catalog editor (desktop v2): products as cards. Scoped under .scr-catalog. */
|
||||
.scr-catalog { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
||||
section.scr-catalog { display: block; }
|
||||
.scr-catalog .ds-grid.cat-stats { --ds-min: 200px; }
|
||||
.scr-catalog .cat-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
|
||||
.scr-catalog .cat-card { background: var(--ds-raised); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); box-shadow: var(--ds-e1); padding: 14px 16px 14px 18px; display: grid; gap: 12px; position: relative; overflow: hidden; min-width: 0; }
|
||||
.scr-catalog .cat-card::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--ok-solid); opacity: .7; }
|
||||
.scr-catalog .cat-card.t-warn::before { background: var(--warn-solid); }
|
||||
.scr-catalog .cat-card.t-bad::before { background: var(--bad-solid); }
|
||||
.scr-catalog .cat-card-h { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.scr-catalog .cat-card-h .grow { flex: 1; }
|
||||
.scr-catalog .cat-ic { flex: none; width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: color-mix(in srgb, var(--tint, var(--accent-solid)) 16%, transparent); color: var(--tint, var(--accent-solid)); }
|
||||
.scr-catalog .cat-ic svg { width: 19px; height: 19px; }
|
||||
.scr-catalog .cat-ic.sm { width: 26px; height: 26px; border-radius: 8px; }
|
||||
.scr-catalog .cat-ic.sm svg { width: 14px; height: 14px; }
|
||||
.scr-catalog .cat-names { display: grid; gap: 4px; min-width: 0; }
|
||||
.scr-catalog .cat-id { font-size: var(--ds-t-h3); overflow-wrap: anywhere; }
|
||||
.scr-catalog .cat-type { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.scr-catalog .cat-stores { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.scr-catalog .cat-store { background: var(--ds-inset); border-radius: var(--ds-r-sm); padding: 9px 11px; display: grid; gap: 4px; align-content: start; min-width: 0; }
|
||||
.scr-catalog .cat-store-l { display: inline-flex; align-items: center; gap: 6px; font-size: var(--ds-t-micro); color: var(--muted); }
|
||||
.scr-catalog .cat-store-l svg, .scr-catalog .cat-store-l .store-mark { width: 12px; height: 12px; }
|
||||
.scr-catalog .cat-store .mx-cell { font-weight: var(--ds-w-strong); }
|
||||
.scr-catalog .cat-store .note-list { font-size: var(--ds-t-micro); margin: 0; }
|
||||
.scr-catalog .ds-table-wrap { box-shadow: none; }
|
||||
|
||||
/* editor rows */
|
||||
.scr-catalog.cat-editor .ed-item-head { gap: 10px; }
|
||||
.scr-catalog.cat-editor .cat-price { font-weight: var(--ds-w-strong); padding: 2px 9px; border-radius: var(--ds-r-pill); background: var(--ds-inset); font-size: var(--ds-t-meta); }
|
||||
76
apps/desktop/src/styles/checks.css
Normal file
76
apps/desktop/src/styles/checks.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/* Checks tab v2: a readiness view. Big ready / blocked score, blockers first as cards with their fix, passed checks
|
||||
collapsed. Scoped to .scr-checks. */
|
||||
.scr-checks { display: grid; gap: 20px; min-width: 0; }
|
||||
|
||||
/* score hero */
|
||||
.scr-checks .ck-hero {
|
||||
position: relative; overflow: hidden; display: grid; grid-template-columns: minmax(0, 1.4fr) auto auto; gap: 28px; align-items: center;
|
||||
padding: 26px 28px; border-radius: var(--ds-r-xl); background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e2);
|
||||
}
|
||||
.scr-checks .ck-hero::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(120% 140% at 0% 0%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .14), transparent 55%); }
|
||||
.scr-checks .ck-hero.tone-bad::before { background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--bad-solid) 16%, transparent), transparent 55%); }
|
||||
.scr-checks .ck-hero.tone-ok::before { background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--ok-solid) 16%, transparent), transparent 55%); }
|
||||
.scr-checks .ck-hero.tone-warn::before { background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--warn-solid) 14%, transparent), transparent 55%); }
|
||||
.scr-checks .ck-hero > * { position: relative; }
|
||||
.scr-checks .ck-score { display: flex; align-items: center; gap: 20px; min-width: 0; }
|
||||
.scr-checks .ck-score-ic { display: grid; place-items: center; width: 72px; height: 72px; border-radius: 22px; flex: none; }
|
||||
.scr-checks .ck-score-ic svg { width: 36px; height: 36px; }
|
||||
.scr-checks .ck-score-t { display: grid; gap: 6px; min-width: 0; flex: 1; }
|
||||
.scr-checks .ck-score-t h2 { margin: 0; font-size: var(--ds-t-display); font-weight: var(--ds-w-display); letter-spacing: -.03em; line-height: 1.05; color: var(--ink); }
|
||||
.scr-checks .ck-hero.tone-bad .ck-score-t h2 { color: var(--bad-ink); }
|
||||
.scr-checks .ck-hero.tone-ok .ck-score-t h2 { color: var(--ok-ink); }
|
||||
.scr-checks .ck-score-t p { margin: 0; font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-checks .ck-score-t .bar { height: 8px; max-width: 420px; margin-top: 4px; }
|
||||
.scr-checks .ck-counts { display: flex; gap: 10px; }
|
||||
.scr-checks .ck-count { display: grid; justify-items: center; gap: 2px; min-width: 84px; padding: 12px 14px; border-radius: var(--ds-r); background: var(--ds-inset); font-size: var(--ds-t-micro); color: var(--muted); font-weight: 600; }
|
||||
.scr-checks .ck-count b { font-size: 26px; font-weight: var(--ds-w-display); line-height: 1.05; color: var(--ink); }
|
||||
.scr-checks .ck-count.bad b { color: var(--bad-ink); }
|
||||
.scr-checks .ck-count.warn b { color: var(--warn-ink); }
|
||||
.scr-checks .ck-count.ok b { color: var(--ok-ink); }
|
||||
.scr-checks .ck-hero-acts { display: grid; gap: 8px; justify-items: end; }
|
||||
@media (max-width: 1200px) {
|
||||
.scr-checks .ck-hero { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.scr-checks .ck-hero-acts { grid-column: 1 / -1; justify-items: start; grid-auto-flow: column; justify-content: start; align-items: center; }
|
||||
}
|
||||
|
||||
/* cards */
|
||||
.scr-checks .ck-list { display: grid; gap: 10px; min-width: 0; }
|
||||
.scr-checks .ck-card {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 14px; align-items: start;
|
||||
padding: 14px 16px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); border-left: 3px solid var(--ds-stroke-strong);
|
||||
}
|
||||
.scr-checks .ck-card.tone-bad { border-left-color: var(--bad-solid); background: color-mix(in srgb, var(--bad-solid) 5%, var(--ds-inset)); }
|
||||
.scr-checks .ck-card.tone-warn { border-left-color: var(--warn-solid); }
|
||||
.scr-checks .ck-card.tone-ok { border-left-color: var(--ok-solid); }
|
||||
.scr-checks .ck-card.tone-info { border-left-color: var(--info-solid); }
|
||||
.scr-checks .ck-ic { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; flex: none; }
|
||||
.scr-checks .ck-ic svg { width: 17px; height: 17px; }
|
||||
.scr-checks .ck-main { display: grid; gap: 6px; min-width: 0; }
|
||||
.scr-checks .ck-title { font-weight: var(--ds-w-strong); color: var(--ink); line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.scr-checks .ck-gl { display: inline-block; margin-right: 8px; padding: 0 7px; border-radius: var(--ds-r-pill); background: var(--ds-surface); border: 1px solid var(--ds-stroke); font-size: var(--ds-t-micro); font-weight: 650; color: var(--muted); vertical-align: 1px; }
|
||||
.scr-checks .ck-sub { font-size: var(--ds-t-meta); color: var(--muted); overflow-wrap: anywhere; }
|
||||
.scr-checks .ck-sub code { font-size: var(--ds-t-micro); }
|
||||
.scr-checks .ck-fix { display: flex; gap: 8px; align-items: flex-start; padding: 8px 10px; border-radius: var(--ds-r-sm); background: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .08); font-size: var(--ds-t-meta); color: var(--ink-2); overflow-wrap: anywhere; }
|
||||
.scr-checks .ck-fix svg { width: 14px; height: 14px; flex: none; margin-top: 2px; color: var(--tint); }
|
||||
.scr-checks .ck-ai { margin-top: 2px; }
|
||||
.scr-checks .ck-side { display: grid; gap: 8px; justify-items: end; }
|
||||
.scr-checks .ck-tags { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.scr-checks .ck-store { display: inline-flex; align-items: center; gap: 5px; font-size: var(--ds-t-micro); color: var(--muted); font-weight: 600; white-space: nowrap; }
|
||||
.scr-checks .ck-acts { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.scr-checks .ck-acts:empty { display: none; }
|
||||
.scr-checks .ck-notes { display: grid; gap: 4px; padding-top: 6px; }
|
||||
@media (max-width: 900px) { .scr-checks .ck-card { grid-template-columns: auto minmax(0, 1fr); } .scr-checks .ck-side { grid-column: 2; justify-items: start; } }
|
||||
|
||||
/* collapsed passed / accepted */
|
||||
.scr-checks .ck-passed { border-radius: var(--ds-r-lg); border: 1px solid var(--ds-stroke); background: var(--ds-surface); }
|
||||
.scr-checks .ck-list .ck-passed { background: none; border-style: dashed; }
|
||||
.scr-checks .ck-passed > summary { display: flex; align-items: center; gap: 8px; padding: 14px 18px; cursor: pointer; font-weight: var(--ds-w-strong); color: var(--ink); list-style: none; }
|
||||
.scr-checks .ck-passed > summary::-webkit-details-marker { display: none; }
|
||||
.scr-checks .ck-passed > summary svg { width: 16px; height: 16px; color: var(--ok-solid); }
|
||||
.scr-checks .ck-passed > summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: var(--ds-r-lg); }
|
||||
.scr-checks .ck-passed > .ck-list { padding: 0 14px 14px; }
|
||||
|
||||
/* report */
|
||||
.scr-checks .ck-report { display: flex; align-items: center; gap: 16px; padding: 14px 18px; border-radius: var(--ds-r-lg); background: var(--ds-surface); border: 1px solid var(--ds-stroke); }
|
||||
.scr-checks .ck-report-t { display: grid; gap: 2px; flex: 1; min-width: 0; font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-checks .ck-report-t b { color: var(--ink); font-size: var(--ds-t-body); }
|
||||
20
apps/desktop/src/styles/deps.css
Normal file
20
apps/desktop/src/styles/deps.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/* Dependencies v2: KPI row, tool cards with status / version / needs chips and the install control. */
|
||||
.scr-deps { gap: 22px; }
|
||||
.scr-deps .deps-kpis { --ds-min: 220px; }
|
||||
.scr-deps .deps-grid { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; padding: 16px 22px 22px; }
|
||||
.scr-deps .tool-row { flex-wrap: wrap; align-items: flex-start; gap: 14px; padding: 16px; border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); background: var(--ds-raised); box-shadow: var(--ds-e1); transition: box-shadow var(--speed) var(--ease); }
|
||||
.scr-deps .tool-row:hover { box-shadow: var(--ds-e2); }
|
||||
.scr-deps .tool-row .ti { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; flex: none; background: var(--ds-inset); color: var(--muted); }
|
||||
.scr-deps .tool-row .ti svg { width: 20px; height: 20px; }
|
||||
.scr-deps .tool-ok .ti { background: color-mix(in srgb, var(--ok-fg, var(--accent-solid)) 15%, transparent); color: var(--ok-fg, var(--accent-solid)); }
|
||||
.scr-deps .tool-warn .ti { background: color-mix(in srgb, var(--warn-fg, var(--accent-solid)) 15%, transparent); color: var(--warn-fg, var(--accent-solid)); }
|
||||
.scr-deps .tool-accent .ti { background: var(--accent-soft); color: var(--accent); }
|
||||
.scr-deps .tool-row .li-main { flex: 1 1 200px; gap: 6px; }
|
||||
.scr-deps .tool-row .li-title { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); }
|
||||
.scr-deps .tool-chips { flex-wrap: wrap; }
|
||||
.scr-deps .tool-acts { flex: 1 1 100%; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.scr-deps .tool-acts:empty { display: none; }
|
||||
.scr-deps .tool-acts .prog { flex: 1; min-width: 160px; }
|
||||
.scr-deps .deps-src { gap: 18px; }
|
||||
.scr-deps .kv { row-gap: 8px; }
|
||||
.scr-deps .cmd { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-radius: var(--ds-r-sm); background: var(--ds-inset); border: 1px solid var(--ds-stroke); }
|
||||
22
apps/desktop/src/styles/folders.css
Normal file
22
apps/desktop/src/styles/folders.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/* App folders v2: one card per folder (app icon, tint stripe, health pill, accounts, actions). Ignored: a quiet list. */
|
||||
.scr-folders { gap: 22px; }
|
||||
.scr-folders .list { grid-template-columns: repeat(auto-fill, minmax(460px, 1fr)); gap: 16px; padding: 16px 22px 22px; }
|
||||
.scr-folders .fold-card { position: relative; flex-wrap: wrap; align-items: flex-start; gap: 14px; padding: 18px; border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); background: var(--ds-raised); box-shadow: var(--ds-e1); overflow: hidden; transition: box-shadow var(--speed) var(--ease); }
|
||||
.scr-folders .fold-card::before { content: ""; position: absolute; inset: 0 0 auto; height: 3px; background: var(--tint); }
|
||||
.scr-folders .fold-card:hover { box-shadow: var(--ds-e3); }
|
||||
.scr-folders .fold-card .app-icon.fold-icon { width: 52px; height: 52px; border-radius: 14px; }
|
||||
.scr-folders .fold-card .li-main { flex: 1 1 220px; gap: 4px; }
|
||||
.scr-folders .fold-card .li-title { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); }
|
||||
.scr-folders .fold-accs { flex: 1 1 100%; display: grid !important; grid-template-columns: 1fr 1fr; gap: 8px !important; }
|
||||
.scr-folders .fold-acts { flex: 1 1 100%; flex-wrap: wrap; padding-top: 12px; border-top: 1px solid var(--ds-stroke); }
|
||||
.scr-folders .fold-acts > :last-child { margin-left: auto; }
|
||||
.scr-folders .log { max-height: 360px; border-radius: var(--ds-r-sm); }
|
||||
.scr-folders .act-note { display: flex; gap: 8px; align-items: center; margin-top: 14px; }
|
||||
|
||||
.scr-ignored { gap: 20px; }
|
||||
.scr-ignored .ds-sec { box-shadow: none; background: transparent; }
|
||||
.scr-ignored .list { padding: 6px 10px 10px; }
|
||||
.scr-ignored .li { border-top: 0; border-radius: var(--ds-r-sm); padding: 10px 12px; align-items: center; opacity: .82; transition: background var(--speed) var(--ease), opacity var(--speed) var(--ease); }
|
||||
.scr-ignored .li:hover, .scr-ignored .li:focus-within { background: var(--ds-inset); opacity: 1; }
|
||||
.scr-ignored .li .app-icon { width: 34px; height: 34px; filter: saturate(.55); }
|
||||
.scr-ignored .card-body { padding: 4px 22px 18px; }
|
||||
37
apps/desktop/src/styles/growth.css
Normal file
37
apps/desktop/src/styles/growth.css
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/* Growth tab v2: funnel tiles with deltas, ASO tiles, experiments. Scoped to .scr-growth. */
|
||||
.scr-growth { display: grid; gap: 20px; min-width: 0; }
|
||||
.scr-growth .gr-body { display: grid; gap: 18px; min-width: 0; }
|
||||
.scr-growth .gr-note, .scr-growth .gr-text { margin: 0; font-size: var(--ds-t-meta); color: var(--muted); line-height: 1.5; }
|
||||
.scr-growth .gr-text { color: var(--ink-2); font-size: var(--ds-t-body); }
|
||||
|
||||
/* reach -> installs -> conversion */
|
||||
.scr-growth .gr-funnel { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); align-items: stretch; gap: 10px; }
|
||||
.scr-growth .gr-step { display: grid; place-items: center; color: var(--tint); }
|
||||
.scr-growth .gr-step svg { width: 20px; height: 20px; }
|
||||
.scr-growth .gr-funnel .ds-stat, .scr-growth .gr-tiles .ds-stat { background: var(--ds-inset); box-shadow: none; }
|
||||
.scr-growth .gr-funnel .ds-stat-v { font-size: 36px; }
|
||||
@media (max-width: 900px) {
|
||||
.scr-growth .gr-funnel { grid-template-columns: minmax(0, 1fr); }
|
||||
.scr-growth .gr-step { transform: rotate(90deg); }
|
||||
}
|
||||
.scr-growth .gr-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||
.scr-growth .gr-tiles .ds-stat-v { font-size: 24px; }
|
||||
|
||||
/* next actions */
|
||||
.scr-growth .gr-next { display: grid; gap: 10px; padding: 16px 18px; border-radius: var(--ds-r); background: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .07); border: 1px solid hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .22); }
|
||||
.scr-growth .gr-next-h { display: flex; align-items: center; gap: 8px; font-weight: var(--ds-w-strong); color: var(--ink); }
|
||||
.scr-growth .gr-next-h svg { width: 16px; height: 16px; color: var(--tint); }
|
||||
.scr-growth .gr-next ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||
.scr-growth .gr-next li { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 10px; align-items: start; }
|
||||
.scr-growth .gr-next-n { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--tint); color: #fff; font-size: var(--ds-t-micro); font-weight: 700; }
|
||||
.scr-growth .gr-next-t { display: grid; gap: 2px; min-width: 0; font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-growth .gr-next-t b { color: var(--ink); font-size: var(--ds-t-body); font-weight: var(--ds-w-strong); }
|
||||
|
||||
/* experiments */
|
||||
.scr-growth .gr-exp { display: grid; gap: 20px; align-items: start; }
|
||||
.scr-growth .gr-exp.two { grid-template-columns: minmax(0, 1fr) 360px; }
|
||||
@media (max-width: 1180px) { .scr-growth .gr-exp.two { grid-template-columns: minmax(0, 1fr); } }
|
||||
.scr-growth .gr-ppo-act { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--ds-stroke); }
|
||||
.scr-growth .gr-ppo-act .act-col { border-radius: var(--ds-r); background: var(--ds-inset); border-color: var(--ds-stroke); padding: 14px 16px; }
|
||||
.scr-growth .gr-foot { display: flex; align-items: center; gap: 8px; font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-growth .gr-foot svg { width: 14px; height: 14px; }
|
||||
79
apps/desktop/src/styles/health.css
Normal file
79
apps/desktop/src/styles/health.css
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/* Health tab v2: big metric tiles (crash, ANR, startup) with large sparklines, issues as cards. Scoped to .scr-health. */
|
||||
.scr-health { display: grid; gap: 20px; min-width: 0; }
|
||||
.scr-health .hl-top { display: flex; align-items: center; gap: 12px; }
|
||||
.scr-health .hl-top .grow { flex: 1; }
|
||||
|
||||
.scr-health .hl-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
|
||||
.scr-health .hl-tile {
|
||||
position: relative; overflow: hidden; display: grid; gap: 12px; align-content: start; min-width: 0;
|
||||
padding: 20px 22px 18px; border-radius: var(--ds-r-lg); background: var(--ds-surface);
|
||||
border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e2);
|
||||
}
|
||||
.scr-health .hl-tile::before { content: ""; position: absolute; inset: 0 0 auto; height: 3px; background: var(--tint); opacity: .85; }
|
||||
.scr-health .hl-tile.tone-bad::before { background: var(--bad-solid); }
|
||||
.scr-health .hl-tile.tone-warn::before { background: var(--warn-solid); }
|
||||
.scr-health .hl-tile.tone-ok::before { background: var(--ok-solid); }
|
||||
.scr-health .hl-tile-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.scr-health .hl-tile-l { display: inline-flex; align-items: center; gap: 8px; font-size: var(--ds-t-h3); font-weight: var(--ds-w-strong); color: var(--ink); min-width: 0; }
|
||||
.scr-health .hl-tile-l svg { width: 18px; height: 18px; color: var(--tint); flex: none; }
|
||||
.scr-health .hl-tile-v { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.scr-health .hl-big { font-size: 44px; font-weight: var(--ds-w-display); letter-spacing: -.035em; line-height: 1; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.scr-health .hl-big small { font-size: var(--ds-t-h3); font-weight: 600; color: var(--muted); margin-left: 6px; letter-spacing: 0; }
|
||||
.scr-health .hl-big.bad { color: var(--bad-ink); }
|
||||
.scr-health .hl-big.warn { color: var(--warn-ink); }
|
||||
.scr-health .hl-big.ok { color: var(--ok-ink); }
|
||||
.scr-health .hl-tile-sub { font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-health .hl-tile .spark { height: 132px; }
|
||||
.scr-health .hl-axis { display: flex; justify-content: space-between; font-size: var(--ds-t-micro); color: var(--muted); font-variant-numeric: tabular-nums; margin-top: -4px; }
|
||||
.scr-health .hl-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; font-size: var(--ds-t-micro); color: var(--muted); padding-top: 10px; border-top: 1px solid var(--ds-stroke); }
|
||||
.scr-health .hl-legend .lg { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.scr-health .hl-legend .sw { width: 16px; height: 3px; border-radius: 2px; background: var(--accent-solid); }
|
||||
.scr-health .hl-legend .sw.ok { background: var(--ok-solid); }
|
||||
.scr-health .hl-legend .sw.warn { background: var(--warn-solid); }
|
||||
.scr-health .hl-legend .sw.bad { background: var(--bad-solid); }
|
||||
.scr-health .hl-legend .sw.thr { background: repeating-linear-gradient(90deg, var(--bad-solid) 0 4px, transparent 4px 7px); }
|
||||
.scr-health .hl-win { margin-left: auto; }
|
||||
.scr-health .hl-startup-meta { display: inline-flex; align-items: center; gap: 6px; font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
|
||||
.scr-health .hl-store { display: flex; align-items: center; gap: 10px; margin: 8px 0 -6px; font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); color: var(--ink); }
|
||||
.scr-health .hl-store svg { width: 18px; height: 18px; color: var(--tint); }
|
||||
|
||||
.scr-health .hl-two { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
.scr-health .hl-col { display: grid; gap: 16px; min-width: 0; }
|
||||
@media (max-width: 1100px) { .scr-health .hl-two { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
/* issues as cards */
|
||||
.scr-health .hl-issues { display: grid; gap: 10px; }
|
||||
.scr-health .hl-issue { border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); background: var(--ds-inset); overflow: hidden; border-left: 3px solid var(--bad-solid); }
|
||||
.scr-health .hl-issue.tone-warn { border-left-color: var(--warn-solid); }
|
||||
.scr-health .hl-issue.tone-info { border-left-color: var(--info-solid); }
|
||||
.scr-health .hl-issue.open { background: var(--ds-surface); box-shadow: var(--ds-e1); }
|
||||
.scr-health .hl-issue-h { all: unset; box-sizing: border-box; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; padding: 12px 14px; cursor: pointer; }
|
||||
.scr-health .hl-issue-h:hover { background: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .06); }
|
||||
.scr-health .hl-issue-h:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; border-radius: var(--ds-r); }
|
||||
.scr-health .hl-issue-h .chev { color: var(--muted); transition: transform var(--speed) var(--ease); }
|
||||
.scr-health .hl-issue-h .chev.rot { transform: rotate(90deg); }
|
||||
.scr-health .hl-issue-t { display: grid; gap: 4px; min-width: 0; }
|
||||
.scr-health .hl-issue-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.scr-health .hl-issue-title { font-weight: var(--ds-w-strong); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.scr-health .hl-issue-where { color: var(--muted); font-size: var(--ds-t-micro); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scr-health .hl-issue-stats { display: flex; gap: 14px; font-size: var(--ds-t-micro); color: var(--muted); }
|
||||
.scr-health .hl-issue-stats span { display: grid; justify-items: end; gap: 1px; }
|
||||
.scr-health .hl-issue-stats b { font-size: var(--ds-t-h3); color: var(--ink); font-weight: var(--ds-w-title); }
|
||||
.scr-health .hl-issue-b { padding: 4px 16px 16px; border-top: 1px solid var(--ds-stroke); }
|
||||
.scr-health .hl-issue-b .issue-detail { padding-top: 12px; }
|
||||
|
||||
/* bars and metric rows */
|
||||
.scr-health .hl-bars { display: grid; gap: 12px; }
|
||||
.scr-health .hl-bar { display: grid; grid-template-columns: minmax(90px, 160px) minmax(0, 1fr) 64px; align-items: center; gap: 12px; font-size: var(--ds-t-meta); }
|
||||
.scr-health .hl-bar .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 600; }
|
||||
.scr-health .hl-bar .num { text-align: right; color: var(--muted); }
|
||||
.scr-health .hl-mets { display: grid; gap: 2px; }
|
||||
.scr-health .hl-met { display: grid; grid-template-columns: minmax(0, 1fr) auto 72px; align-items: center; gap: 12px; padding: 10px 4px; border-bottom: 1px solid var(--ds-stroke); }
|
||||
.scr-health .hl-met:last-child { border-bottom: 0; }
|
||||
.scr-health .hl-met-t { display: grid; gap: 2px; min-width: 0; }
|
||||
.scr-health .hl-met-t b { font-weight: var(--ds-w-strong); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scr-health .hl-met-t small { font-size: var(--ds-t-micro); color: var(--muted); }
|
||||
.scr-health .hl-met-v { font-weight: var(--ds-w-title); color: var(--ink); text-align: right; }
|
||||
.scr-health .hl-met-v small { color: var(--muted); font-weight: 500; }
|
||||
.scr-health .hl-met > :last-child { justify-self: end; }
|
||||
36
apps/desktop/src/styles/listings.css
Normal file
36
apps/desktop/src/styles/listings.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/* Listings tab + listing editors (desktop v2): the store page per language. Scoped under .scr-listings. */
|
||||
.scr-listings { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
||||
.scr-listings .ds-grid.lst-stats { --ds-min: 200px; }
|
||||
.scr-listings .ds-sec-h h2 .store-mark, .scr-listings .ds-sec-h h2 > svg { width: 16px; height: 16px; }
|
||||
|
||||
.scr-listings .lst-pages { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
||||
.scr-listings .lst-page { background: var(--ds-raised); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); box-shadow: var(--ds-e1); padding: 14px 16px 16px; display: grid; gap: 12px; position: relative; overflow: hidden; }
|
||||
.scr-listings .lst-page::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--tint, var(--accent-solid)); opacity: .55; }
|
||||
.scr-listings .lst-page.done::before { background: var(--ok-solid); }
|
||||
.scr-listings .lst-page.bad::before { background: var(--bad-solid); opacity: .8; }
|
||||
.scr-listings .lst-page-h { display: flex; align-items: center; gap: 8px; }
|
||||
.scr-listings .lst-lang { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); letter-spacing: -.01em; }
|
||||
.scr-listings .lst-page-h .grow { flex: 1; }
|
||||
.scr-listings .lst-fields { display: grid; gap: 9px; }
|
||||
.scr-listings .lst-f { display: grid; grid-template-columns: 112px minmax(0, 1fr); align-items: center; gap: 10px; }
|
||||
.scr-listings .lst-f-l { font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-listings .lst-f.f-title .lst-f-l, .scr-listings .lst-f.f-name .lst-f-l { color: var(--text); font-weight: var(--ds-w-strong); font-size: var(--ds-t-h3); }
|
||||
.scr-listings .lst-f.f-short .lst-f-l, .scr-listings .lst-f.f-subtitle .lst-f-l { color: var(--text); font-weight: 600; }
|
||||
.scr-listings .lg-cell .bar { height: 6px; border-radius: var(--ds-r-pill); }
|
||||
.scr-listings .lst-foot-cmd { border: 0; background: none; padding: 6px 0 0; }
|
||||
|
||||
/* the store page preview in the editors */
|
||||
.scr-listings .lst-preview { background: var(--ds-inset); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); padding: 14px 16px; display: grid; gap: 10px; margin: 4px 0 8px; }
|
||||
.scr-listings .lst-pv-eyebrow { font-size: var(--ds-t-micro); color: var(--muted); font-weight: 650; letter-spacing: .02em; }
|
||||
.scr-listings .lst-pv-top { display: flex; gap: 14px; align-items: center; min-width: 0; }
|
||||
.scr-listings .lst-pv-icon { flex: none; width: 64px; height: 64px; border-radius: 15px; display: grid; place-items: center; font-size: 28px; font-weight: var(--ds-w-display); color: #fff; background: var(--ds-grad); box-shadow: var(--ds-e2); }
|
||||
.scr-listings .lst-preview.play .lst-pv-icon { border-radius: 20%; }
|
||||
.scr-listings .lst-pv-names { min-width: 0; display: grid; gap: 3px; }
|
||||
.scr-listings .lst-pv-title { font-size: 20px; font-weight: var(--ds-w-title); letter-spacing: -.015em; line-height: 1.2; overflow-wrap: anywhere; }
|
||||
.scr-listings .lst-pv-sub { font-size: var(--ds-t-body); color: var(--muted); line-height: 1.35; }
|
||||
.scr-listings .lst-pv-promo { margin: 0; font-size: var(--ds-t-body); line-height: 1.45; padding: 8px 12px; border-radius: var(--ds-r-sm); background: color-mix(in srgb, var(--tint, var(--accent-solid)) 10%, transparent); }
|
||||
.scr-listings .lst-pv-desc { margin: 0; font-size: var(--ds-t-body); line-height: 1.5; white-space: pre-line; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.scr-listings .lst-pv-more { justify-self: end; font-size: var(--ds-t-meta); font-weight: 650; color: var(--tint, var(--accent-solid)); margin-top: -6px; }
|
||||
.scr-listings .lst-langbar .btn { border-radius: var(--ds-r-pill); }
|
||||
.scr-listings .ed-cols { gap: 18px; }
|
||||
.scr-listings .ed-col { background: var(--ds-raised); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); padding: 14px 16px; }
|
||||
33
apps/desktop/src/styles/onboarding.css
Normal file
33
apps/desktop/src/styles/onboarding.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/* Onboarding v2: bigger, friendlier first run with a step progress rail. Scoped under .wz2. */
|
||||
.wz2 { backdrop-filter: blur(6px); }
|
||||
.wz2 .wizard { width: min(1080px, 100%); height: min(700px, 100%); grid-template-columns: 290px minmax(0, 1fr); border-radius: var(--ds-r-xl); border-color: var(--ds-stroke); background: var(--ds-surface); box-shadow: var(--ds-e3); overflow: hidden; }
|
||||
.wz2 .wz-side { background: var(--ds-inset); border-right-color: var(--ds-stroke); padding: 28px 20px; position: relative; }
|
||||
.wz2 .wz-side::before { content: ""; position: absolute; inset: 0 0 auto; height: 160px; background: var(--ds-grad-soft); opacity: .7; pointer-events: none; }
|
||||
.wz2 .wz-side > * { position: relative; }
|
||||
.wz2 .wz-side .brand { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); }
|
||||
.wz2 .wz-step { padding: 11px 12px; border-radius: var(--ds-r-sm); }
|
||||
.wz2 .wz-step[aria-current="step"] { background: var(--ds-raised); box-shadow: var(--ds-e2), inset 0 0 0 1px var(--ds-stroke); }
|
||||
.wz2 .wz-num { width: 28px; height: 28px; }
|
||||
.wz2 .wz-step[aria-current="step"] .wz-num { background: var(--ds-grad); border-color: transparent; color: #fff; }
|
||||
.wz2 .wz-progress { display: grid; gap: 10px; padding: 26px 44px 0; }
|
||||
.wz2 .wz-eyebrow { display: inline-flex; align-items: center; gap: 8px; font-size: var(--ds-t-meta); font-weight: var(--ds-w-strong); color: var(--accent); letter-spacing: .02em; }
|
||||
.wz2 .wz-eyebrow svg { width: 15px; height: 15px; }
|
||||
.wz2 .wz-bar { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; }
|
||||
.wz2 .wz-bar span { height: 6px; border-radius: var(--ds-r-pill); background: var(--ds-stroke); transition: background var(--speed) var(--ease); }
|
||||
.wz2 .wz-bar span.done { background: color-mix(in srgb, var(--accent-solid) 55%, var(--ds-stroke)); }
|
||||
.wz2 .wz-bar span.on { background: var(--ds-grad); }
|
||||
.wz2 .wz-body { padding: 22px 44px 24px; gap: 20px; }
|
||||
.wz2 .wz-body h2 { font-size: var(--ds-t-h1); font-weight: var(--ds-w-display); letter-spacing: -.025em; line-height: 1.15; }
|
||||
.wz2 .wz-body h2:focus { outline: none; }
|
||||
.wz2 .wz-body h2:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; border-radius: 6px; }
|
||||
.wz2 .wz-body .lead { font-size: 15px; }
|
||||
.wz2 .ck { padding: 14px 16px; border-color: var(--ds-stroke); border-radius: var(--ds-r); background: var(--ds-raised); box-shadow: var(--ds-e1); }
|
||||
.wz2 .ck .ck-ic { width: 36px; height: 36px; border-radius: 10px; }
|
||||
.wz2 .ck .ck-ic svg { width: 18px; height: 18px; }
|
||||
.wz2 .ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-solid) 18%, transparent); }
|
||||
.wz2 .choice { padding: 18px; border-color: var(--ds-stroke); border-radius: var(--ds-r-lg); background: var(--ds-raised); box-shadow: var(--ds-e1); }
|
||||
.wz2 .choice:hover { box-shadow: var(--ds-e3); }
|
||||
.wz2 .choice .ch-icon { width: 40px; height: 40px; border-radius: 12px; background: var(--ds-grad); color: #fff; }
|
||||
.wz2 .choice b { font-size: var(--ds-t-h3); }
|
||||
.wz2 .wz-foot { padding: 16px 24px; border-top-color: var(--ds-stroke); background: var(--ds-inset); }
|
||||
@media (prefers-reduced-motion: reduce) { .wz2 .wz-bar span { transition: none; } }
|
||||
17
apps/desktop/src/styles/prices.css
Normal file
17
apps/desktop/src/styles/prices.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/* Prices tab (desktop v2): a clean price matrix with region codes and the store gaps highlighted. Scoped under .scr-prices. */
|
||||
.scr-prices { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
||||
section.scr-prices { display: block; }
|
||||
.scr-prices .ds-grid.px-stats { --ds-min: 200px; }
|
||||
.scr-prices .ds-sec > .ds-table-wrap { margin: 14px 22px 16px; box-shadow: none; }
|
||||
.scr-prices .px-region { display: inline-block; font-weight: var(--ds-w-strong); }
|
||||
.scr-prices .px-region.code { font-family: var(--mono, ui-monospace, monospace); font-size: var(--ds-t-micro); letter-spacing: .04em; padding: 2px 7px; border-radius: var(--ds-r-xs); background: var(--ds-inset); border: 1px solid var(--ds-stroke); }
|
||||
.scr-prices .px-matrix th:first-child, .scr-prices .px-matrix td:first-child { position: sticky; left: 0; background: var(--ds-surface); z-index: 1; }
|
||||
.scr-prices .px-matrix thead th:first-child { z-index: 3; }
|
||||
.scr-prices .px-matrix th { text-align: left; }
|
||||
.scr-prices .px-matrix tr.sel td { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 9%, var(--ds-surface)); }
|
||||
.scr-prices .px-stack { padding: 4px 6px; border-radius: var(--ds-r-xs); }
|
||||
.scr-prices .px-stack.gap-warn { background: color-mix(in srgb, var(--warn-solid) 14%, transparent); }
|
||||
.scr-prices .px-stack.gap-bad { background: color-mix(in srgb, var(--bad-solid) 14%, transparent); }
|
||||
.scr-prices tr.gap-warn td:first-child { box-shadow: inset 3px 0 0 var(--warn-solid); }
|
||||
.scr-prices tr.gap-bad td:first-child { box-shadow: inset 3px 0 0 var(--bad-solid); }
|
||||
.scr-prices .legend-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
|
||||
94
apps/desktop/src/styles/releases.css
Normal file
94
apps/desktop/src/styles/releases.css
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/* Releases tab (v2): one big card per release train, a card per store inside with a stepper and a rollout ring. */
|
||||
.scr-releases { display: flex; flex-direction: column; gap: 24px; min-width: 0; }
|
||||
|
||||
/* train */
|
||||
.scr-releases .rel-train { position: relative; background: var(--ds-surface); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r-lg); box-shadow: var(--ds-e2); padding: 22px 24px 24px; overflow: hidden; }
|
||||
.scr-releases .rel-train::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--tint, var(--accent-solid)); opacity: .85; }
|
||||
.scr-releases .rel-train.ok::before { background: var(--ok-solid); }
|
||||
.scr-releases .rel-train.accent { background: linear-gradient(180deg, hsl(var(--tint-h, 250) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a2)), transparent 160px), var(--ds-surface); }
|
||||
.scr-releases .rel-train-h { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||
.scr-releases .rel-train-title { display: flex; flex-direction: column; gap: 2px; }
|
||||
.scr-releases .rel-train-eyebrow { font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); color: var(--muted); letter-spacing: .04em; }
|
||||
.scr-releases .rel-train-ver { font-size: var(--ds-t-display); font-weight: var(--ds-w-display); letter-spacing: -.03em; line-height: 1.05; color: var(--ink); }
|
||||
.scr-releases .rel-train-h .grow { flex: 1; }
|
||||
.scr-releases .rel-train-meta { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--ds-t-meta); }
|
||||
.scr-releases .rel-train-meta svg { width: 14px; height: 14px; }
|
||||
|
||||
.scr-releases .rel-lanes { display: grid; grid-template-columns: repeat(auto-fit, minmax(440px, 1fr)); gap: 16px; }
|
||||
|
||||
/* store card (neutralise the old .tl-lane / .tl-roll / .tl-none grid rules from styles.css) */
|
||||
.scr-releases .tl-lane.rel-store { grid-template-columns: none; align-items: stretch; }
|
||||
.scr-releases .tl-roll.rel-ring-wrap { position: static; width: auto; display: flex; top: auto; }
|
||||
.scr-releases .tl-roll .pct { text-align: left; font-size: var(--ds-t-h3); }
|
||||
.scr-releases .tl-none.rel-gap { grid-column: auto; text-align: left; border: 0; }
|
||||
.scr-releases .rel-store { display: flex; flex-direction: column; gap: 18px; background: var(--ds-raised); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); padding: 18px 20px; min-width: 0; box-shadow: var(--ds-e1); }
|
||||
.scr-releases .rel-store[data-tone="bad"] { border-color: color-mix(in srgb, var(--bad-solid) 45%, var(--ds-stroke)); }
|
||||
.scr-releases .rel-store-h { display: flex; align-items: center; gap: 12px; }
|
||||
.scr-releases .rel-mark { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: var(--ds-inset); border: 1px solid var(--ds-stroke); color: var(--ink); flex: none; }
|
||||
.scr-releases .rel-mark svg { width: 20px; height: 20px; }
|
||||
.scr-releases .rel-who { flex: 1; min-width: 0; }
|
||||
.scr-releases .rel-store-name { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); }
|
||||
.scr-releases .rel-store-ver { font-size: var(--ds-t-meta); color: var(--muted); margin-top: 1px; }
|
||||
|
||||
.scr-releases .rel-gap { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-radius: var(--ds-r-sm); background: var(--ds-inset); color: var(--muted); font-size: var(--ds-t-body); }
|
||||
.scr-releases .rel-gap svg { width: 16px; height: 16px; flex: none; }
|
||||
.scr-releases .rel-gap[data-gap="unreadable"] { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
|
||||
.scr-releases .rel-body { display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
/* stepper */
|
||||
.scr-releases .rel-steps { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.scr-releases .rel-step { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; min-width: 0; }
|
||||
.scr-releases .rel-step::before { content: ""; position: absolute; top: 15px; right: 50%; width: 100%; height: 3px; border-radius: 3px; background: var(--ds-stroke-strong); z-index: 0; }
|
||||
.scr-releases .rel-step:first-child::before { display: none; }
|
||||
.scr-releases .rel-step.done::before, .scr-releases .rel-step.cur::before { background: var(--tint, var(--accent-solid)); }
|
||||
.scr-releases .rel-node { position: relative; z-index: 1; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--ds-inset); border: 2px solid var(--ds-stroke-strong); color: var(--muted); font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); }
|
||||
.scr-releases .rel-node svg { width: 15px; height: 15px; }
|
||||
.scr-releases .rel-step.done .rel-node { background: var(--tint, var(--accent-solid)); border-color: var(--tint, var(--accent-solid)); color: #fff; }
|
||||
.scr-releases .rel-step.cur .rel-node { background: var(--ds-raised); border-color: var(--tint, var(--accent-solid)); color: var(--tint, var(--accent-solid)); box-shadow: 0 0 0 5px hsl(var(--tint-h, 250) var(--ds-tint-s) var(--ds-tint-l) / .22); }
|
||||
.scr-releases .rel-step.cur.ok .rel-node { border-color: var(--ok-solid); color: var(--ok-solid); box-shadow: 0 0 0 5px color-mix(in srgb, var(--ok-solid) 22%, transparent); }
|
||||
.scr-releases .rel-step.cur.warn .rel-node { border-color: var(--warn-solid); color: var(--warn-solid); box-shadow: 0 0 0 5px color-mix(in srgb, var(--warn-solid) 24%, transparent); }
|
||||
.scr-releases .rel-step.cur.bad .rel-node { border-color: var(--bad-solid); color: var(--bad-solid); box-shadow: 0 0 0 5px color-mix(in srgb, var(--bad-solid) 24%, transparent); }
|
||||
.scr-releases .rel-step.cur.info .rel-node { border-color: var(--info-solid); color: var(--info-solid); box-shadow: 0 0 0 5px color-mix(in srgb, var(--info-solid) 22%, transparent); }
|
||||
.scr-releases .rel-step-l { font-size: var(--ds-t-meta); color: var(--muted); font-weight: 560; }
|
||||
.scr-releases .rel-step.done .rel-step-l { color: var(--ink); }
|
||||
.scr-releases .rel-step.cur .rel-step-l { color: var(--ink); font-weight: var(--ds-w-title); }
|
||||
.scr-releases .rel-step-c { font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); padding: 2px 8px; border-radius: var(--ds-r-pill); background: var(--ds-inset); color: var(--ink); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* rollout ring (conic gradient, no svg) */
|
||||
.scr-releases .rel-ring-wrap { display: flex; align-items: center; gap: 18px; padding: 14px 16px; border-radius: var(--ds-r); background: var(--ds-inset); --ring: var(--tint, var(--accent-solid)); }
|
||||
.scr-releases .rel-ring-wrap.ok { --ring: var(--ok-solid); }
|
||||
.scr-releases .rel-ring-wrap.bad { --ring: var(--bad-solid); }
|
||||
.scr-releases .rel-ring { --p: 0; flex: none; width: 92px; height: 92px; border-radius: 50%; display: grid; place-items: center;
|
||||
background: conic-gradient(var(--ring) calc(var(--p) * 1%), var(--ds-stroke-strong) 0); }
|
||||
.scr-releases .rel-ring::before { content: ""; grid-area: 1 / 1; width: 74px; height: 74px; border-radius: 50%; background: var(--ds-raised); }
|
||||
.scr-releases .rel-ring-v { grid-area: 1 / 1; position: relative; font-size: 24px; font-weight: var(--ds-w-display); letter-spacing: -.02em; color: var(--ink); }
|
||||
.scr-releases .rel-ring-v small { font-size: 13px; font-weight: var(--ds-w-strong); color: var(--muted); margin-left: 1px; }
|
||||
.scr-releases .rel-ring-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.scr-releases .rel-ring-side .pct { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); color: var(--ink); }
|
||||
.scr-releases .rel-ring-sub { font-size: var(--ds-t-meta); color: var(--muted); margin-bottom: 6px; }
|
||||
.scr-releases .rel-ring-side .rbar { height: 6px; }
|
||||
.scr-releases .rel-ring-wrap .rbar .fill { fill: var(--ring); }
|
||||
|
||||
/* rollout controls */
|
||||
.scr-releases .roll-ctl { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding-top: 14px; border-top: 1px solid var(--ds-stroke); }
|
||||
.scr-releases .roll-ctl > .small.muted { flex-basis: 100%; }
|
||||
|
||||
/* history */
|
||||
.scr-releases .rel-count { font-size: var(--ds-t-meta); color: var(--muted); font-weight: var(--ds-w-strong); }
|
||||
.scr-releases .rel-history .ds-table-wrap { margin: 14px 22px 22px; --ds-table-max: 420px; }
|
||||
.scr-releases .rel-hv { font-size: var(--ds-t-h3); }
|
||||
|
||||
/* send for review */
|
||||
.scr-releases .rel-submit { gap: 14px; }
|
||||
.scr-releases .rel-sec-h { display: flex; flex-direction: column; gap: 4px; padding: 0 2px; }
|
||||
.scr-releases .rel-sec-title { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); letter-spacing: -.015em; display: flex; align-items: center; gap: 10px; margin: 0; }
|
||||
.scr-releases .rel-sec-sub { color: var(--muted); font-size: var(--ds-t-meta); margin: 0; }
|
||||
.scr-releases .rel-submit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(460px, 1fr)); gap: 16px; align-items: start; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.scr-releases .rel-lanes, .scr-releases .rel-submit-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root:not([data-motion="reduce"]) .scr-releases .rel-step.cur .rel-node { transition: box-shadow var(--speed, .2s) var(--ease, ease); }
|
||||
}
|
||||
81
apps/desktop/src/styles/reviews.css
Normal file
81
apps/desktop/src/styles/reviews.css
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/* Reviews tab v2: rating summary, filters on a toolbar, a store-like review feed and the reply panel. Scoped to .scr-reviews. */
|
||||
.scr-reviews { display: grid; gap: 18px; min-width: 0; }
|
||||
.scr-reviews .grow { flex: 1; }
|
||||
|
||||
/* rating summary */
|
||||
.scr-reviews .rv2-sum {
|
||||
display: grid; grid-template-columns: auto minmax(220px, 1fr) auto; gap: 28px; align-items: center;
|
||||
padding: 22px 26px; border-radius: var(--ds-r-lg); background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e2);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.scr-reviews .rv2-sum::before { content: ""; position: absolute; inset: 0 0 auto; height: 3px; background: var(--tint); opacity: .85; }
|
||||
.scr-reviews .rv2-avg { display: grid; justify-items: center; gap: 6px; padding-right: 28px; border-right: 1px solid var(--ds-stroke); }
|
||||
.scr-reviews .rv2-avg b { font-size: 56px; line-height: 1; font-weight: var(--ds-w-display); letter-spacing: -.04em; color: var(--ink); }
|
||||
.scr-reviews .rv2-avg .stars svg { width: 18px; height: 18px; }
|
||||
.scr-reviews .rv2-avg span { font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-reviews .rv2-dist { display: grid; gap: 6px; max-width: 520px; }
|
||||
.scr-reviews .rv2-dist-row {
|
||||
all: unset; box-sizing: border-box; display: grid; grid-template-columns: 34px minmax(0, 1fr) 40px; align-items: center; gap: 10px;
|
||||
padding: 3px 6px; border-radius: var(--ds-r-xs); cursor: pointer; font-size: var(--ds-t-meta); color: var(--muted);
|
||||
transition: opacity var(--speed) var(--ease), background var(--speed) var(--ease);
|
||||
}
|
||||
.scr-reviews .rv2-dist-row:hover { background: var(--ds-inset); }
|
||||
.scr-reviews .rv2-dist-row:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.scr-reviews .rv2-dist-row[aria-pressed="true"] { background: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .14); color: var(--ink); }
|
||||
.scr-reviews .rv2-dist-row[data-dim] { opacity: .45; }
|
||||
.scr-reviews .rv2-dist-row > span:first-child { display: inline-flex; align-items: center; gap: 3px; font-weight: 650; color: var(--ink); }
|
||||
.scr-reviews .rv2-dist-row > span:first-child svg { width: 11px; height: 11px; color: var(--warn-solid); }
|
||||
.scr-reviews .rv2-dist-row > span:last-child { text-align: right; }
|
||||
.scr-reviews .rv2-dist-row .bar { height: 10px; }
|
||||
.scr-reviews .rv2-side { display: grid; gap: 10px; justify-items: end; }
|
||||
.scr-reviews .rv2-unans { display: grid; justify-items: end; gap: 2px; }
|
||||
.scr-reviews .rv2-unans b { font-size: var(--ds-t-stat); font-weight: var(--ds-w-display); color: var(--ink); line-height: 1; }
|
||||
.scr-reviews .rv2-unans span { font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
@media (max-width: 1100px) {
|
||||
.scr-reviews .rv2-sum { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.scr-reviews .rv2-side { grid-column: 1 / -1; justify-items: start; grid-auto-flow: column; align-items: center; }
|
||||
}
|
||||
|
||||
.scr-reviews .ds-toolbar .search-input { min-width: 240px; }
|
||||
.scr-reviews .ds-toolbar .search-input .input { padding-left: 30px; }
|
||||
.scr-reviews .rv2-shown { font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
|
||||
/* feed + reply panel */
|
||||
.scr-reviews .rv2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); gap: 18px; align-items: start; }
|
||||
@media (max-width: 1100px) { .scr-reviews .rv2 { grid-template-columns: minmax(0, 1fr); } }
|
||||
.scr-reviews .rv2-list { display: grid; gap: 10px; max-height: 1100px; overflow: auto; padding: 2px; }
|
||||
.scr-reviews .rv2-card.inbox-item {
|
||||
display: grid; gap: 8px; padding: 16px 18px; border: 1px solid var(--ds-stroke); border-top: 1px solid var(--ds-stroke);
|
||||
border-radius: var(--ds-r); background: var(--ds-surface); box-shadow: var(--ds-e1);
|
||||
transition: box-shadow var(--speed) var(--ease), border-color var(--speed) var(--ease);
|
||||
}
|
||||
.scr-reviews .rv2-card.inbox-item:hover { background: var(--ds-surface); box-shadow: var(--ds-e2); border-color: var(--ds-stroke-strong); }
|
||||
.scr-reviews .rv2-card.inbox-item[aria-selected="true"] { background: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .08); border-color: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .55); }
|
||||
.scr-reviews .rv2-card.inbox-item[aria-selected="true"]::before { background: var(--tint); top: 14px; bottom: 14px; }
|
||||
.scr-reviews .rv2-card.inbox-item:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.scr-reviews .rv2-c-top { display: flex; align-items: center; gap: 10px; }
|
||||
.scr-reviews .rv2-c-top .stars svg { width: 14px; height: 14px; }
|
||||
.scr-reviews .rv2-c-date { font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-reviews .rv2-c-title { font-size: var(--ds-t-h3); font-weight: var(--ds-w-strong); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scr-reviews .rv2-c-body { color: var(--ink-2); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.scr-reviews .rv2-c-foot, .scr-reviews .rv2-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: var(--ds-t-micro); color: var(--muted); }
|
||||
.scr-reviews .rv2-c-foot svg { color: var(--muted); }
|
||||
.scr-reviews .rv2-lang { display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px; border-radius: var(--ds-r-pill); background: var(--ds-inset); font-weight: 600; }
|
||||
.scr-reviews .rv2-lang svg { width: 11px; height: 11px; }
|
||||
|
||||
.scr-reviews .rv2-detail {
|
||||
display: grid; gap: 18px; padding: 22px 24px; border-radius: var(--ds-r-lg); background: var(--ds-surface);
|
||||
border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e2); position: sticky; top: 72px; min-width: 0;
|
||||
}
|
||||
.scr-reviews .rv2-d-head { display: grid; gap: 8px; }
|
||||
.scr-reviews .rv2-d-head .stars svg { width: 18px; height: 18px; }
|
||||
.scr-reviews .rv2-d-head h2 { margin: 0; font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); color: var(--ink); }
|
||||
.scr-reviews .rv2-body { margin: 0; font-size: var(--ds-t-body); line-height: 1.6; border-left-color: var(--tint); }
|
||||
.scr-reviews .rv2-dev { display: grid; gap: 8px; }
|
||||
.scr-reviews .rv2-dev-l, .scr-reviews .rv2-draft-l { display: inline-flex; align-items: center; gap: 6px; font-size: var(--ds-t-meta); font-weight: 650; color: var(--ink); }
|
||||
.scr-reviews .rv2-dev-l svg, .scr-reviews .rv2-draft-l svg { width: 14px; height: 14px; color: var(--tint); }
|
||||
.scr-reviews .rv2-dev .reply-box { border-radius: var(--ds-r); background: var(--ds-inset); }
|
||||
.scr-reviews .rv2-draft { display: grid; gap: 10px; padding-top: 16px; border-top: 1px solid var(--ds-stroke); }
|
||||
.scr-reviews .rv2-draft-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.scr-reviews .rv2-draft .reply-edit { border-radius: var(--ds-r); min-height: 120px; }
|
||||
.scr-reviews .rv2-count { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: var(--ds-t-micro); color: var(--muted); }
|
||||
37
apps/desktop/src/styles/settings.css
Normal file
37
apps/desktop/src/styles/settings.css
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/* Settings v2: grouped sections with a sticky jump list, roomy toggle rows and calm descriptions. */
|
||||
.scr-settings { gap: 22px; }
|
||||
.scr-settings .set-layout { grid-template-columns: 230px minmax(0, 1fr); gap: 28px; }
|
||||
.scr-settings .set-nav { top: 12px; padding: 8px; gap: 2px; border: 1px solid var(--ds-stroke); border-radius: var(--ds-r-lg); background: var(--ds-surface); box-shadow: var(--ds-e1); }
|
||||
.scr-settings .set-nav .nav { height: 34px; border-radius: var(--ds-r-sm); font-size: var(--ds-t-body); }
|
||||
.scr-settings .set-nav .nav[aria-current="true"] { background: var(--ds-raised); color: var(--ink); box-shadow: var(--ds-e1), inset 0 0 0 1px var(--ds-stroke); }
|
||||
.scr-settings .set-nav .nav[aria-current="true"] svg { color: var(--accent-solid); }
|
||||
.scr-settings .set-body { gap: 20px !important; max-width: 920px; }
|
||||
.scr-settings .set-card { scroll-margin-top: 16px; }
|
||||
.scr-settings .ds-sec-b > .form { gap: 18px; }
|
||||
.scr-settings .toggle-row { padding: 14px 16px; margin: 0 -16px; border-top: 1px solid var(--ds-stroke); border-radius: var(--ds-r-sm); gap: 20px; transition: background var(--speed) var(--ease); }
|
||||
.scr-settings .toggle-row:first-child { border-top: 0; }
|
||||
.scr-settings .toggle-row:hover { background: var(--ds-inset); }
|
||||
.scr-settings .toggle-row .strong { font-size: var(--ds-t-h3); font-weight: var(--ds-w-strong); }
|
||||
.scr-settings .toggle-row .small { font-size: var(--ds-t-meta); line-height: 1.45; max-width: 64ch; }
|
||||
.scr-settings .switch { width: 42px; height: 24px; }
|
||||
.scr-settings .switch::after { width: 19px; height: 19px; }
|
||||
.scr-settings .switch:checked { background: var(--accent-solid); box-shadow: 0 4px 12px -6px var(--accent-solid); }
|
||||
.scr-settings .switch:checked::after { transform: translateX(18px); }
|
||||
.scr-settings .field .legend, .scr-settings .field > label { font-weight: var(--ds-w-strong); font-size: var(--ds-t-body); }
|
||||
.scr-settings .field .hint { font-size: var(--ds-t-meta); }
|
||||
.scr-settings .kv { padding: 14px 16px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); row-gap: 10px; }
|
||||
.scr-settings .studio-sum { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
|
||||
.scr-settings .studio-sum-item { padding: 14px 16px; border-radius: var(--ds-r); border: 1px solid var(--ds-stroke); background: var(--ds-raised); box-shadow: var(--ds-e1); transition: box-shadow var(--speed) var(--ease), border-color var(--speed) var(--ease); }
|
||||
.scr-settings .studio-sum-item:hover { box-shadow: var(--ds-e3); border-color: var(--ds-stroke-strong); }
|
||||
.scr-settings .studio-sum-item b { font-size: var(--ds-t-h3); }
|
||||
.scr-settings #set-pairing .table { margin-top: 14px; }
|
||||
.scr-settings #set-pairing .card-foot { display: flex; align-items: center; gap: 8px; padding: 14px 22px; border-top: 1px solid var(--ds-stroke); color: var(--muted); font-size: var(--ds-t-meta); }
|
||||
.scr-settings .dep-sum { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.scr-settings .dep-sum > span { padding: 6px 12px; border-radius: var(--ds-r-pill); background: var(--ds-inset); border: 1px solid var(--ds-stroke); }
|
||||
.scr-settings .plain-list { display: grid; gap: 10px; }
|
||||
.scr-settings .plain-list li { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.scr-settings .plain-list li svg { color: var(--ok-fg, var(--accent-solid)); flex: none; margin-top: 2px; }
|
||||
.scr-settings .shortcuts { row-gap: 12px; }
|
||||
.scr-settings .danger-card { border-color: color-mix(in srgb, var(--bad-fg, var(--accent-solid)) 35%, var(--ds-stroke)); }
|
||||
.scr-settings .danger-card .ds-sec-ic { background: color-mix(in srgb, var(--bad-fg, var(--accent-solid)) 16%, transparent); color: var(--bad-fg, var(--accent-solid)); }
|
||||
@media (max-width: 1100px) { .scr-settings .set-layout { grid-template-columns: 1fr; } }
|
||||
60
apps/desktop/src/styles/submit.css
Normal file
60
apps/desktop/src/styles/submit.css
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/* Send for review (v2): one card per store with a numbered checklist that ends in the one primary button. */
|
||||
.sub-card.card { background: var(--ds-surface); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r-lg); box-shadow: var(--ds-e2); padding: 0; overflow: hidden; min-width: 0; }
|
||||
|
||||
.sub-card .sub-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 18px 22px 16px; border-bottom: 1px solid var(--ds-stroke);
|
||||
background: linear-gradient(180deg, hsl(var(--tint-h, 250) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a2)), transparent); }
|
||||
.sub-card .sub-mark { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; background: var(--ds-raised); border: 1px solid var(--ds-stroke); flex: none; }
|
||||
.sub-card .sub-mark svg { width: 20px; height: 20px; }
|
||||
.sub-card .sub-head-t { flex: 1; min-width: 180px; }
|
||||
.sub-card .sub-head-t h3 { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); letter-spacing: -.015em; margin: 0; }
|
||||
.sub-card .sub-head-s { color: var(--muted); font-size: var(--ds-t-meta); margin-top: 2px; }
|
||||
.sub-card .sub-count { display: inline-flex; align-items: baseline; padding: 6px 12px; border-radius: var(--ds-r-pill); background: var(--ds-inset); font-weight: var(--ds-w-display); font-size: 18px; color: var(--ink); }
|
||||
.sub-card .sub-count .of { font-size: var(--ds-t-meta); color: var(--muted); font-weight: var(--ds-w-strong); }
|
||||
.sub-card .sub-count.all { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
.sub-card .sub-count.all .of { color: var(--ok-ink); }
|
||||
.sub-card .sub-head-a { flex-basis: 100%; justify-content: flex-start; }
|
||||
|
||||
.sub-card .sub-body { display: flex; flex-direction: column; gap: 16px; padding: 18px 22px 22px; }
|
||||
|
||||
/* checklist */
|
||||
.sub-card .sub-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||
.sub-card .sub-step { position: relative; display: grid; grid-template-columns: 34px 1fr; gap: 14px; padding-bottom: 18px; }
|
||||
.sub-card .sub-step:last-child { padding-bottom: 0; }
|
||||
.sub-card .sub-step::before { content: ""; position: absolute; left: 16px; top: 36px; bottom: 2px; width: 2px; border-radius: 2px; background: var(--ds-stroke); }
|
||||
.sub-card .sub-step:last-child::before { display: none; }
|
||||
.sub-card .sub-step.ready::before { background: color-mix(in srgb, var(--ok-solid) 55%, var(--ds-stroke)); }
|
||||
.sub-card .sub-node { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: var(--ds-inset); border: 2px solid var(--ds-stroke-strong); color: var(--muted); font-weight: var(--ds-w-title); font-size: var(--ds-t-meta); position: relative; z-index: 1; }
|
||||
.sub-card .sub-node svg { width: 16px; height: 16px; }
|
||||
.sub-card .sub-step.ready .sub-node { background: var(--ok-solid); border-color: var(--ok-solid); color: #fff; }
|
||||
.sub-card .sub-step.blocked .sub-node { background: var(--bad-bg); border-color: var(--bad-solid); color: var(--bad-solid); }
|
||||
.sub-card .sub-step.warn .sub-node { background: var(--warn-bg); border-color: var(--warn-solid); color: var(--warn-ink); }
|
||||
.sub-card .sub-step-b { display: flex; flex-direction: column; gap: 10px; min-width: 0; padding-top: 5px; }
|
||||
.sub-card .sub-step-h { display: flex; align-items: center; gap: 10px; justify-content: space-between; }
|
||||
.sub-card .sub-step-t { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); }
|
||||
.sub-card .sub-step.todo .sub-step-t { color: var(--muted); }
|
||||
.sub-card .sub-hint { font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
|
||||
.sub-card .sub-field select.input { width: 100%; max-width: 520px; height: 40px; border-radius: var(--ds-r-sm); font-size: var(--ds-t-body); }
|
||||
.sub-card .sub-version { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.sub-card .sub-version-v { font-size: 24px; font-weight: var(--ds-w-display); letter-spacing: -.02em; }
|
||||
.sub-card .sub-check { gap: 8px; }
|
||||
.sub-card .sub-go .btn-primary { height: 42px; padding: 0 20px; font-size: var(--ds-t-body); border-radius: var(--ds-r-sm); }
|
||||
|
||||
/* submissions */
|
||||
.sub-card .sub-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sub-card .sub-item { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; align-items: start; padding: 12px 14px; border-radius: var(--ds-r-sm); background: var(--ds-inset); border: 1px solid var(--ds-stroke); }
|
||||
.sub-card .sub-item-ic { width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; background: var(--neutral-bg); color: var(--neutral-ink); }
|
||||
.sub-card .sub-item-ic svg { width: 16px; height: 16px; }
|
||||
.sub-card .sub-item.tone-info .sub-item-ic { background: var(--info-bg); color: var(--info-ink); }
|
||||
.sub-card .sub-item.tone-bad { border-color: color-mix(in srgb, var(--bad-solid) 40%, var(--ds-stroke)); }
|
||||
.sub-card .sub-item.tone-bad .sub-item-ic { background: var(--bad-bg); color: var(--bad-ink); }
|
||||
.sub-card .sub-item.tone-ok .sub-item-ic { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
.sub-card .sub-item-m { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.sub-card .sub-item-t { font-weight: var(--ds-w-title); }
|
||||
.sub-card .sub-item-s { font-size: var(--ds-t-meta); color: var(--muted); overflow-wrap: anywhere; }
|
||||
.sub-card .sub-item .note-list { margin: 6px 0 0; padding-left: 18px; font-size: var(--ds-t-meta); }
|
||||
|
||||
/* extras: in-app purchases, console steps */
|
||||
.sub-card .sub-extra { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px dashed var(--ds-stroke-strong); }
|
||||
.sub-card .sub-extra-h { display: flex; align-items: center; gap: 8px; }
|
||||
.sub-card .sub-extra-h svg { width: 16px; height: 16px; color: var(--tint, var(--accent-solid)); }
|
||||
64
apps/desktop/src/styles/testflight.css
Normal file
64
apps/desktop/src/styles/testflight.css
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/* TestFlight tab (v2): stat tiles, group cards with the tester count, build cards with three state pills. */
|
||||
.scr-testflight { display: flex; flex-direction: column; gap: 24px; min-width: 0; }
|
||||
|
||||
.scr-testflight .tf-top { display: flex; align-items: flex-start; gap: 16px; }
|
||||
.scr-testflight .tf-kpis { flex: 1; --ds-min: 200px; }
|
||||
.scr-testflight .tf-read { flex: none; padding-top: 4px; }
|
||||
|
||||
/* groups */
|
||||
.scr-testflight .tf-groups { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
|
||||
.scr-testflight .tf-group { position: relative; display: flex; flex-direction: column; gap: 14px; padding: 18px 20px; background: var(--ds-surface); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r-lg); box-shadow: var(--ds-e1); overflow: hidden; }
|
||||
.scr-testflight .tf-group::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--info-solid); }
|
||||
.scr-testflight .tf-group.external::before { background: var(--tint, var(--accent-solid)); }
|
||||
.scr-testflight .tf-group-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.scr-testflight .tf-group-n { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scr-testflight .tf-group-big { display: flex; align-items: baseline; gap: 8px; }
|
||||
.scr-testflight .tf-group-big .num { font-size: var(--ds-t-stat); font-weight: var(--ds-w-display); letter-spacing: -.03em; line-height: 1; }
|
||||
.scr-testflight .tf-group-l { color: var(--muted); font-size: var(--ds-t-meta); }
|
||||
.scr-testflight .tf-group-f { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.scr-testflight .tf-tag { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: var(--ds-r-pill); background: var(--ds-inset); color: var(--muted); font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); }
|
||||
.scr-testflight .tf-tag b { color: var(--ink); }
|
||||
.scr-testflight .tf-tag svg { width: 13px; height: 13px; }
|
||||
.scr-testflight .tf-tag.on { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 16%, transparent); color: var(--tint, var(--accent-solid)); }
|
||||
|
||||
/* builds */
|
||||
.scr-testflight .tf-builds { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.scr-testflight .tf-build { border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); background: var(--ds-raised); transition: border-color var(--speed, .15s), box-shadow var(--speed, .15s); }
|
||||
.scr-testflight .tf-build:hover, .scr-testflight .tf-build.open { border-color: hsl(var(--tint-h, 250) var(--ds-tint-s) var(--ds-tint-l) / .55); box-shadow: var(--ds-e2); }
|
||||
.scr-testflight .tf-build-row { display: grid; grid-template-columns: minmax(150px, 200px) 1fr auto; gap: 18px; align-items: center; padding: 14px 18px; }
|
||||
.scr-testflight .tf-build-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.scr-testflight .tf-build-v { font-size: 22px; font-weight: var(--ds-w-display); letter-spacing: -.02em; line-height: 1.1; }
|
||||
.scr-testflight .tf-build-n { font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-testflight .tf-build-n b { color: var(--ink); }
|
||||
.scr-testflight .tf-build-d { display: inline-flex; align-items: center; gap: 5px; font-size: var(--ds-t-micro); color: var(--muted); }
|
||||
.scr-testflight .tf-build-d svg { width: 12px; height: 12px; }
|
||||
.scr-testflight .tf-build-states { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.scr-testflight .tf-state { display: flex; flex-direction: column; align-items: flex-start; gap: 5px; min-width: 0; }
|
||||
.scr-testflight .tf-state-l { font-size: var(--ds-t-micro); color: var(--muted); font-weight: var(--ds-w-strong); }
|
||||
.scr-testflight .tf-state .ds-pill { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||
.scr-testflight .tf-dash { color: var(--faint, var(--muted)); }
|
||||
.scr-testflight .tf-build-a .actions { justify-content: flex-end; flex-wrap: nowrap; }
|
||||
.scr-testflight .tf-edit { display: flex; flex-direction: column; gap: 10px; margin: 0 18px 16px; padding: 14px 16px; border-radius: var(--ds-r-sm); background: var(--ds-inset); }
|
||||
.scr-testflight .tf-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.scr-testflight .tf-chip { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 12px; border-radius: var(--ds-r-pill); border: 1px solid var(--ds-stroke-strong); background: var(--ds-raised); cursor: pointer; font-size: var(--ds-t-meta); font-weight: var(--ds-w-strong); }
|
||||
.scr-testflight .tf-chip input { margin: 0; }
|
||||
.scr-testflight .tf-chip svg { width: 13px; height: 13px; color: var(--muted); }
|
||||
.scr-testflight .tf-chip.on { border-color: var(--tint, var(--accent-solid)); background: color-mix(in srgb, var(--tint, var(--accent-solid)) 14%, var(--ds-raised)); }
|
||||
.scr-testflight .tf-chip:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.scr-testflight .tf-chip-n { color: var(--muted); font-size: var(--ds-t-micro); }
|
||||
|
||||
.scr-testflight .tf-note { display: flex; align-items: center; gap: 8px; margin: 12px 0 0; color: var(--muted); font-size: var(--ds-t-meta); }
|
||||
.scr-testflight .tf-note svg { width: 14px; height: 14px; flex: none; }
|
||||
.scr-testflight .tf-err { display: flex; align-items: center; gap: 8px; margin: 0; padding: 0 22px 18px; color: var(--bad-ink); font-size: var(--ds-t-meta); }
|
||||
.scr-testflight .tf-err svg { width: 14px; height: 14px; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.scr-testflight .tf-build-row { grid-template-columns: 1fr; }
|
||||
.scr-testflight .tf-build-a .actions { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
/* TestFlight editor */
|
||||
.scr-testflight-ed .ed-langs { gap: 16px; }
|
||||
.scr-testflight-ed .ed-lang { background: var(--ds-inset); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); padding: 14px 16px; }
|
||||
.scr-testflight-ed .ed-lang .mono-id { display: inline-block; padding: 2px 8px; border-radius: var(--ds-r-pill); background: color-mix(in srgb, var(--tint, var(--accent-solid)) 16%, transparent); color: var(--tint, var(--accent-solid)); margin-bottom: 8px; }
|
||||
.scr-testflight-ed .tf-apply { gap: 16px; }
|
||||
48
apps/desktop/src/styles/tools.css
Normal file
48
apps/desktop/src/styles/tools.css
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/* More tools tab v2: a tidy command grid. Group tiles, the open group's commands as cards, owner steps. Scoped to .scr-tools. */
|
||||
.scr-tools { display: grid; gap: 20px; min-width: 0; }
|
||||
.scr-tools .tl-lead { display: flex; gap: 10px; align-items: flex-start; margin: 0; max-width: 980px; font-size: var(--ds-t-body); color: var(--muted); line-height: 1.55; }
|
||||
.scr-tools .tl-lead svg { width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--tint); }
|
||||
|
||||
/* group tiles */
|
||||
.scr-tools .tl-groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
|
||||
.scr-tools .tl-group {
|
||||
all: unset; box-sizing: border-box; display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; gap: 10px 12px; align-items: center;
|
||||
padding: 16px 18px; border-radius: var(--ds-r-lg); background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e1); cursor: pointer;
|
||||
transition: box-shadow var(--speed) var(--ease), border-color var(--speed) var(--ease), background var(--speed) var(--ease);
|
||||
}
|
||||
.scr-tools .tl-group:hover { box-shadow: var(--ds-e2); border-color: var(--ds-stroke-strong); }
|
||||
.scr-tools .tl-group:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.scr-tools .tl-group[aria-expanded="true"] { background: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .10); border-color: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .55); }
|
||||
.scr-tools .tl-group-ic { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; background: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .16); color: var(--tint); }
|
||||
.scr-tools .tl-group-ic svg { width: 19px; height: 19px; }
|
||||
.scr-tools .tl-group-t { display: grid; gap: 2px; min-width: 0; font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-tools .tl-group-t b { font-size: var(--ds-t-h3); font-weight: var(--ds-w-strong); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scr-tools .tl-group-x { grid-column: 1 / -1; display: inline-flex; align-items: center; justify-content: space-between; gap: 6px; font-size: var(--ds-t-micro); font-weight: 650; color: var(--muted); padding-top: 8px; border-top: 1px solid var(--ds-stroke); }
|
||||
.scr-tools .tl-group-x svg { width: 14px; height: 14px; }
|
||||
.scr-tools .tl-group[aria-expanded="true"] .tl-group-x { color: var(--tint); }
|
||||
|
||||
/* commands */
|
||||
.scr-tools .tl-cmds { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px; align-items: start; }
|
||||
.scr-tools .tl-cmd.tool { display: grid; gap: 12px; padding: 16px 18px; border: 1px solid var(--ds-stroke); border-radius: var(--ds-r); background: var(--ds-inset); min-width: 0; }
|
||||
.scr-tools .tl-cmd.tool:first-child { border-top: 1px solid var(--ds-stroke); padding-top: 16px; }
|
||||
.scr-tools .tl-cmd.has-out { grid-column: 1 / -1; }
|
||||
.scr-tools .tl-cmd-h { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: start; }
|
||||
.scr-tools .tl-cmd-ic { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: var(--info-bg); color: var(--info-ink); }
|
||||
.scr-tools .tl-cmd-ic.write { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.scr-tools .tl-cmd-ic svg { width: 16px; height: 16px; }
|
||||
.scr-tools .tl-cmd-t { display: grid; gap: 3px; min-width: 0; }
|
||||
.scr-tools .tl-cmd-title { font-weight: var(--ds-w-strong); color: var(--ink); }
|
||||
.scr-tools .tl-cmd-desc { font-size: var(--ds-t-meta); color: var(--muted); line-height: 1.45; }
|
||||
.scr-tools .tl-cmd-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.scr-tools .tl-cmd-m { margin-left: auto; font-size: var(--ds-t-micro); color: var(--muted); }
|
||||
.scr-tools .tl-form { padding: 12px; border-radius: var(--ds-r-sm); background: var(--ds-surface); border: 1px solid var(--ds-stroke); }
|
||||
.scr-tools .tl-cmd-run { display: flex; gap: 8px; }
|
||||
.scr-tools .tl-out { display: grid; gap: 6px; }
|
||||
.scr-tools .tl-out-h { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: var(--ds-t-meta); color: var(--muted); }
|
||||
.scr-tools .tl-out pre { max-height: 300px; overflow: auto; margin: 0; border-radius: var(--ds-r-sm); }
|
||||
|
||||
/* owner steps */
|
||||
.scr-tools .tl-owner { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 10px; }
|
||||
.scr-tools .tl-step { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); min-width: 0; }
|
||||
.scr-tools .tl-step-t { display: grid; gap: 4px; flex: 1; min-width: 0; color: var(--ink); font-weight: 600; }
|
||||
.scr-tools .tl-step-t code { font-size: var(--ds-t-micro); color: var(--muted); font-weight: 500; overflow-wrap: anywhere; }
|
||||
Loading…
Add table
Add a link
Reference in a new issue