From f131de9df2d3c698068a32a26f40b5afa4c9db8b Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 23:31:22 +0800 Subject: [PATCH] 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 --- apps/desktop/src/i18n/en.ts | 61 ++++ apps/desktop/src/i18n/tr.ts | 61 ++++ apps/desktop/src/screens/Accounts.tsx | 41 ++- apps/desktop/src/screens/AppFolders.tsx | 51 +-- apps/desktop/src/screens/Dependencies.tsx | 43 ++- apps/desktop/src/screens/Ignored.tsx | 21 +- apps/desktop/src/screens/Onboarding.tsx | 9 +- apps/desktop/src/screens/Settings.tsx | 58 +-- .../src/screens/editors/AppInfoEditor.tsx | 33 +- .../desktop/src/screens/editors/ArtEditor.tsx | 20 +- .../src/screens/editors/CatalogEditor.tsx | 26 +- .../src/screens/editors/ListingEditor.tsx | 47 ++- .../screens/editors/StoreListingEditor.tsx | 20 +- .../desktop/src/screens/editors/TabEditor.tsx | 5 +- .../src/screens/editors/TestFlightEditor.tsx | 15 +- apps/desktop/src/screens/tabs/AppInfo.tsx | 80 +++-- apps/desktop/src/screens/tabs/Art.tsx | 32 +- apps/desktop/src/screens/tabs/Catalog.tsx | 83 +++-- apps/desktop/src/screens/tabs/Checks.tsx | 214 +++++++---- apps/desktop/src/screens/tabs/Growth.tsx | 121 ++++--- apps/desktop/src/screens/tabs/Health.tsx | 268 +++++++------- apps/desktop/src/screens/tabs/Listings.tsx | 77 ++-- apps/desktop/src/screens/tabs/LiveArt.tsx | 18 +- apps/desktop/src/screens/tabs/Prices.tsx | 71 ++-- apps/desktop/src/screens/tabs/Releases.tsx | 135 ++++--- apps/desktop/src/screens/tabs/Reviews.tsx | 201 ++++++----- apps/desktop/src/screens/tabs/Submit.tsx | 334 +++++++++++------- apps/desktop/src/screens/tabs/TestFlight.tsx | 165 +++++---- apps/desktop/src/screens/tabs/Tools.tsx | 84 +++-- apps/desktop/src/styles/accounts.css | 26 ++ apps/desktop/src/styles/appinfo.css | 45 +++ apps/desktop/src/styles/art.css | 38 ++ apps/desktop/src/styles/catalog.css | 29 ++ apps/desktop/src/styles/checks.css | 76 ++++ apps/desktop/src/styles/deps.css | 20 ++ apps/desktop/src/styles/folders.css | 22 ++ apps/desktop/src/styles/growth.css | 37 ++ apps/desktop/src/styles/health.css | 79 +++++ apps/desktop/src/styles/listings.css | 36 ++ apps/desktop/src/styles/onboarding.css | 33 ++ apps/desktop/src/styles/prices.css | 17 + apps/desktop/src/styles/releases.css | 94 +++++ apps/desktop/src/styles/reviews.css | 81 +++++ apps/desktop/src/styles/settings.css | 37 ++ apps/desktop/src/styles/submit.css | 60 ++++ apps/desktop/src/styles/testflight.css | 64 ++++ apps/desktop/src/styles/tools.css | 48 +++ 47 files changed, 2309 insertions(+), 927 deletions(-) create mode 100644 apps/desktop/src/styles/accounts.css create mode 100644 apps/desktop/src/styles/appinfo.css create mode 100644 apps/desktop/src/styles/art.css create mode 100644 apps/desktop/src/styles/catalog.css create mode 100644 apps/desktop/src/styles/checks.css create mode 100644 apps/desktop/src/styles/deps.css create mode 100644 apps/desktop/src/styles/folders.css create mode 100644 apps/desktop/src/styles/growth.css create mode 100644 apps/desktop/src/styles/health.css create mode 100644 apps/desktop/src/styles/listings.css create mode 100644 apps/desktop/src/styles/onboarding.css create mode 100644 apps/desktop/src/styles/prices.css create mode 100644 apps/desktop/src/styles/releases.css create mode 100644 apps/desktop/src/styles/reviews.css create mode 100644 apps/desktop/src/styles/settings.css create mode 100644 apps/desktop/src/styles/submit.css create mode 100644 apps/desktop/src/styles/testflight.css create mode 100644 apps/desktop/src/styles/tools.css diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f989257..29d0984 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 0fc5ff5..4219477 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -819,6 +819,19 @@ export const tr: Record = { '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 = { '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 = { '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 = { '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", }; diff --git a/apps/desktop/src/screens/Accounts.tsx b/apps/desktop/src/screens/Accounts.tsx index f9412b7..bd79022 100644 --- a/apps/desktop/src/screens/Accounts.tsx +++ b/apps/desktop/src/screens/Accounts.tsx @@ -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 = { @@ -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 ( -
+
@@ -178,28 +180,31 @@ export function Accounts({ focus }: { focus?: string }) { ); return ( -
-
-

{t('nav.accounts')}

{t('accounts.leadStudios')}

-
- - -
-
+
+ + + + } /> + {d.accounts.length > 0 &&
+ + a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length })} /> + d.accountHealth[a.id]?.check?.status === 'ok').length} /> + { 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} /> +
} {error && {error}} - {!d.accounts.length && !d.studios.length && {t('accounts.emptyTextStudios')}} + {!d.accounts.length && !d.studios.length &&
{t('accounts.emptyTitle')} {t('accounts.emptyTextStudios')}
} {d.studios.map(s => a.studio === s.id)} focus={focus === s.id} />)} {(unassigned.length > 0 || !d.studios.length) && d.accounts.length > 0 && } -
- +
+
{ e.preventDefault(); addStudio(); }}>
setStudioName(e.target.value)} maxLength={48} placeholder={t('studio.namePlaceholder')} autoComplete="off" />
{t('studio.color')}
- - +
+
{ e.preventDefault(); addPlay(); }}>
setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} maxLength={60} autoComplete="off" />
@@ -207,8 +212,8 @@ export function Accounts({ focus }: { focus?: string }) {

{t('accounts.playHow')}

- - +
+
{ e.preventDefault(); addApple(); }}>
setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} maxLength={60} autoComplete="off" />
@@ -221,7 +226,7 @@ export function Accounts({ focus }: { focus?: string }) {
-
+
{t('accounts.safetyStudios')} diff --git a/apps/desktop/src/screens/AppFolders.tsx b/apps/desktop/src/screens/AppFolders.tsx index 1162a6f..b5df3b4 100644 --- a/apps/desktop/src/screens/AppFolders.tsx +++ b/apps/desktop/src/screens/AppFolders.tsx @@ -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
{children}
; +} + function LocateButton({ project }: { project: Project }) { const { busy, locate } = useFolderActions(project); return ; @@ -73,41 +81,36 @@ export function AppFolders() { : run.result.ok ? t('run.done') : failureText(run.result); return ( -
-
-

{t('nav.apps')}

{t('folders.lead')}

-
- +
+ - -
-
+ } /> {error && {error}} {!d.projects.length ? ( - +
- }>{t('folders.emptyText')} + }>{t('folders.emptyText')}
) : ( - +
{d.projects.map(p => { const e = d.statusOf(p); const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e); return ( -
- + +
- {e?.app?.name ?? p.name}{st.text} + {e?.app?.name ?? p.name}{st.text} {shortPath(p.path, d.info?.home)}
-
+
-
+
{p.missing ? : <> @@ -128,14 +131,14 @@ export function AppFolders() { }
-
+ ); })}
- +
)} - +
@@ -147,16 +150,16 @@ export function AppFolders() {

{t('folders.newNote')}

- +
{run && ( - {status}{run.result && <> · {run.result.command}}} +
{status}{run.result && <> · {run.result.command}}} actions={}>
{run.error && {run.error}}
{tildeText(run.result?.output ?? run.lines.join('\n'), d.info?.home) || ' '}
- +
)}
); diff --git a/apps/desktop/src/screens/Dependencies.tsx b/apps/desktop/src/screens/Dependencies.tsx index 9c388e2..e7a04c8 100644 --- a/apps/desktop/src/screens/Dependencies.tsx +++ b/apps/desktop/src/screens/Dependencies.tsx @@ -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 = { 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 ( -
-
-

{t('nav.dependencies')}

{t('deps.lead')}

-
-
+
+ } /> {error && {error}} - + {tools &&
+ + !x.found || x.status === 'outdated') ? 'warn' : undefined} label={t('v2cfg.depsNeeds')} value={tools.filter(x => !x.found || x.status === 'outdated').length} /> + i.installed).length} sub={t('v2cfg.depsInAppSub')} /> +
} +
{!tools ?
: ( -
+
{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 ( -
+
- {x.name}{st.text}{x.version && {x.version}} + {x.name} + {st.text}{x.version && {x.version}}{x.required && {t('v2cfg.needs', { version: x.required })}} {x.usedFor} {x.path && {shortPath(x.path, d.info?.home)}}
-
+
{running ? (
@@ -114,9 +119,9 @@ export function Dependencies() { })}
)} - +
{sdk && ( - {t('deps.installed')} : <>{t('deps.missing')}}> {sdk.path ? (
@@ -129,27 +134,27 @@ export function Dependencies() {
    {sdk.checked.map((c, i) =>
  • {d.info?.home ? c.split(d.info.home).join("~") : c}
  • )}
)} - +
)} -
+
{info.map(m => ( - +
{t('deps.source')}
{m.source}
{t('deps.license')}
{m.license}
{t('deps.verify')}
{m.tool === 'chrome' ? t('deps.verifyChrome') : t('deps.verifyFfmpeg')}
- +
))}
- +
{['storebound doctor', 'bun add -d playwright', 'brew install ffmpeg'].map(c => (
{c}
))}
- +
); } diff --git a/apps/desktop/src/screens/Ignored.tsx b/apps/desktop/src/screens/Ignored.tsx index 6095165..57c9a49 100644 --- a/apps/desktop/src/screens/Ignored.tsx +++ b/apps/desktop/src/screens/Ignored.tsx @@ -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 ( -
-
-

{t('nav.ignored')}

{t('ignored.lead')}

- {!empty &&
} -
- {empty ? {t('ignored.emptyText')} : ( +
+ : undefined} /> + {empty ?
{t('ignored.emptyTitle')} {t('ignored.emptyText')}
: ( <> - +
{apps.length ? (
{apps.map(a => { @@ -44,8 +43,8 @@ export function Ignored() { })}
) :

{t('ignored.noApps')}

} - - +
+
{accounts.length ? (
{accounts.map(a => ( @@ -57,7 +56,7 @@ export function Ignored() { ))}
) :

{t('ignored.noAccounts')}

} - +
)}

{t('ignored.howTo')}

diff --git a/apps/desktop/src/screens/Onboarding.tsx b/apps/desktop/src/screens/Onboarding.tsx index ca535fb..9fa6026 100644 --- a/apps/desktop/src/screens/Onboarding.tsx +++ b/apps/desktop/src/screens/Onboarding.tsx @@ -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 ( -
+
+
+ {t('v2cfg.stepOf', { n: step + 1, total: STEPS.length })} · {t(STEPS[step]![0])} +
+ {STEPS.map(([k], i) => )} +
+
{step === 0 && ( <> diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index 3342203..59a67f8 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -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 ( -
-

{t('nav.settings')}

{t('set.lead')}

+
+ {error && {error}}
- nav.go({ screen: 'accounts' })}>{t('set.manage')}}> +
nav.go({ screen: 'accounts' })}>{t('set.manage')}}> {d.studios.length ? (
{d.studios.map(st => { @@ -107,20 +109,20 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; })}
) :

{t('set.noStudios')}

} - +
- +
{storeOnly.length ? ( - +
{[...paired, ...singles].map(a => )}
{t('col.app')}{t('set.pairedHow')}{t('col.actions')}
) :

{t('set.noStoreOnly')}

}
{t('set.ignoredLine', { count: d.apps.filter(a => a.ignored).length, accounts: d.overrides.ignored.filter(i => i.startsWith('account:')).length })}
- +
- +
set(x => ({ ...x, refresh: { ...x.refresh, autoRead: v } }))} />
{t('set.everyMinutes')} @@ -138,9 +140,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; {d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}
- +
- +
{t('set.cacheSize')}
{cache ? `${bytes(cache.file_bytes)} · ${t('set.cacheRows', { count: cache.rows })}` : '–'}
@@ -152,9 +154,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;

{t('settings.dataText')}

- +
- +
{t('settings.language')} update({ ...prefs, lang })} label={t('settings.language')} options={[['system', t('settings.system')], ['en', 'English'], ['tr', 'Türkçe']]} />
@@ -163,9 +165,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
{t('settings.motion')} update({ ...prefs, motion })} label={t('settings.motion')} options={[['system', t('settings.followSystem')], ['reduce', t('settings.reduce')]]} />
- +
- +
set(x => ({ ...x, notify: { ...x.notify, rejected: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, approved: v } }))} /> @@ -183,18 +185,18 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
- +
- nav.go({ screen: 'dependencies' })}>{t('set.manage')}}> +
nav.go({ screen: 'dependencies' })}>{t('set.manage')}}> {tools ? (
{tools.tools.map(x => {x.name}{x.version ? {x.version} : null}{x.source === 'app' && {t('set.appCopy')}})}
) :

–

}

{t('set.depsText')}

- +
- +
{t('settings.app')}
{d.info ? `storebound desktop ${d.info.version}${d.info.debug ? ` (${t('settings.debug')})` : ''} · ${d.info.platform}` : '–'}
CLI
{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? t('foot.cliStarting')}
@@ -202,9 +204,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
{t('set.state')}
{d.sidecar?.running ? t('set.running') : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}
{t('set.writes')}
{t('set.writesText')}
- +
- +
  • {t('set.p1')}
  • {t('set.p2')}
  • @@ -215,9 +217,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
- + - +
{t('settings.scPalette')}
{t('nav.portfolio')}
@@ -227,18 +229,18 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
{t('settings.scClose')}
{t('set.scQuit')}
- +
- +
{t('set.channel')} set(x => ({ ...x, channel }))} options={[['stable', t('set.stable')], ['beta', t('set.beta')]]} />

{t('set.updatesText')}

- +
- +

{t('set.resetText', { accounts: d.accounts.length, studios: d.studios.length, folders: d.projects.length })}

@@ -246,7 +248,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
- +

{t('set.footer', { n: num(d.apps.length) })}

diff --git a/apps/desktop/src/screens/editors/AppInfoEditor.tsx b/apps/desktop/src/screens/editors/AppInfoEditor.tsx index 053f4ea..c0685af 100644 --- a/apps/desktop/src/screens/editors/AppInfoEditor.tsx +++ b/apps/desktop/src/screens/editors/AppInfoEditor.tsx @@ -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; 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 ( <> - {t('ed.appleInfoSub')}}> +
{t('ed.appleInfoSub')}}>
{games && } @@ -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')} />
- - +
+
{Object.entries(AGE_RATING_NONE).map(([q, none]) => ( ))}
- - +
+
{data.demoAccount ? `${data.demoAccount} · ••••••••` : t('ed.notSet')}
{t('ed.demoHint')}
- + ); } @@ -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 ( - {t('ed.playSettingsSub')}}> +
{t('ed.playSettingsSub')}}>
@@ -83,14 +84,14 @@ function PlaySettings({ project, data }: { project: Project; data: EditableResul
- +
); } function Declarations({ project, data }: { project: Project; data: EditableResult }) { const ds = obj(data.config.dataSafety); return ( - +
{data.play && } {data.play && } @@ -101,14 +102,14 @@ function Declarations({ project, data }: { project: Project; data: EditableResul {data.play && } {data.play && } {data.play && } - +
); } function Promotion({ project, data }: { project: Project; data: EditableResult }) { const apple = obj(data.config.apple); return ( - {t('ed.promoSub')}}> +
{t('ed.promoSub')}}> @@ -116,7 +117,7 @@ function Promotion({ project, data }: { project: Project; data: EditableResult }
- + ); } @@ -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 ( -
+
{data.apple && } {data.play && } {data.apple && } - +
{data.apple && (
@@ -149,7 +150,7 @@ export function AppInfoEditor({ project, data, onSaved }: { project: Project; da
)}
- +
); } diff --git a/apps/desktop/src/screens/editors/ArtEditor.tsx b/apps/desktop/src/screens/editors/ArtEditor.tsx index 4b40d05..77f3e25 100644 --- a/apps/desktop/src/screens/editors/ArtEditor.tsx +++ b/apps/desktop/src/screens/editors/ArtEditor.tsx @@ -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; @@ -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 {t('ed.artEmptyText')}; + if (!saved.length) return
{t('ed.artEmptyText')}
; 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 ( -
- +
1 ? : undefined}>
    {scenes.map((s, i) => ( @@ -61,16 +63,16 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: ))}
- +
{data.shotsCopy && s.id)} />} - +
{t('act.render')}
{data.play &&
Google Play
} {data.apple &&
App Store
}
- +
); } @@ -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 ( - +
{langs.map(x => )}
@@ -103,6 +105,6 @@ function Captions({ project, data, scenes }: { project: Project; data: EditableR ); })}
- + ); } diff --git a/apps/desktop/src/screens/editors/CatalogEditor.tsx b/apps/desktop/src/screens/editors/CatalogEditor.tsx index 27380c4..2a3be47 100644 --- a/apps/desktop/src/screens/editors/CatalogEditor.tsx +++ b/apps/desktop/src/screens/editors/CatalogEditor.tsx @@ -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; 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 (
{open && (
@@ -71,7 +73,7 @@ function SubCard({ project, data, group, s }: { project: Project; data: Editable return (
{open && (
@@ -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 {t('ed.catalogEmptyText')}; + return
{t('ed.catalogEmptyText')}
; } return ( -
+
{products.length > 0 && ( - +
{products.map(p => )}
- +
)} {groups.map(g => ( - +
{data.languages.map(l => ( @@ -120,10 +122,10 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da
{(g.subscriptions as O[]).map(s => )}
- +
))} - +
{data.play && (
@@ -142,7 +144,7 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da )}

{t('ed.priceGate')}

- +
); } diff --git a/apps/desktop/src/screens/editors/ListingEditor.tsx b/apps/desktop/src/screens/editors/ListingEditor.tsx index a09d1f5..69270fa 100644 --- a/apps/desktop/src/screens/editors/ListingEditor.tsx +++ b/apps/desktop/src/screens/editors/ListingEditor.tsx @@ -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, 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; 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 ( +
+ {t('v2lst.preview')} +
+ +
+
{title || '–'}
+ {sub ?
{sub}
:
{t('v2lst.noSub')}
} +
+
+ {promo &&

{promo}

} + {desc ?

{desc}

:

{t('v2lst.noDesc')}

} + {desc && desc.length > 180 && } +
+ ); +} + 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 && {l.status === 'draft' ? t('ed.statusDraft') : t('ed.statusTranslate')}} {(l.codes?.length ?? 0) > 1 && {t('ed.alsoServes', { codes: l.codes!.filter(c => c !== l.lang).join(', ') })}}
+ {fresh && {t('ed.newSection', { lang: l.lang })}} {defs.map(d => ( - +
{data.play && {t('ed.pullPlay')}} {data.apple && {t('ed.pullApple')}} }>
{unsaved > 0 && {t('ed.pullNote')}} -
+
{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 && {t('ed.noStoreForLang', { lang: L?.id ?? '' })}}
- - +
+
- +
- +
{data.play && (
@@ -157,7 +184,7 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da )}

{t('ed.applyNote')}

- - +
+
); } diff --git a/apps/desktop/src/screens/editors/StoreListingEditor.tsx b/apps/desktop/src/screens/editors/StoreListingEditor.tsx index a1640cc..0d2374b 100644 --- a/apps/desktop/src/screens/editors/StoreListingEditor.tsx +++ b/apps/desktop/src/screens/editors/StoreListingEditor.tsx @@ -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 (
{store === 'play' ? 'Google Play' : 'App Store'}{lang}
+ {store === 'apple' && !editable && {t('ed.noEditableVersion')}} {defs.map(d => ( ; - return {t('tf.read')}}>{r.error ?? t('ed.storeOnlyNote')}; + if (r.loading && !r.error) return
; + return
{t('tf.read')}}>{r.error ?? t('ed.storeOnlyNote')}
; } 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 ( - <> +
{Object.entries(L.errors).map(([s, e]) => )} - } id="store-listing-editor"> +
} id="store-listing-editor">
{t('ed.storeOnlyNote')} -
+
{langs.map(x => )}
@@ -83,7 +87,7 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) { {!pl && !al && {t('ed.noStoreForLang', { lang: cur })}}
- - +
+
); } diff --git a/apps/desktop/src/screens/editors/TabEditor.tsx b/apps/desktop/src/screens/editors/TabEditor.tsx index 462fae0..109eff5 100644 --- a/apps/desktop/src/screens/editors/TabEditor.tsx +++ b/apps/desktop/src/screens/editors/TabEditor.tsx @@ -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 : ; + if (!r.data) return r.error ? :
; const p = { project, data: r.data, onSaved: () => { r.refresh(); } }; switch (tab) { case 'listings': return ; diff --git a/apps/desktop/src/screens/editors/TestFlightEditor.tsx b/apps/desktop/src/screens/editors/TestFlightEditor.tsx index 9507aab..07a3dd0 100644 --- a/apps/desktop/src/screens/editors/TestFlightEditor.tsx +++ b/apps/desktop/src/screens/editors/TestFlightEditor.tsx @@ -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; 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 ( -
- +
+
{data.apple.map(l => (
@@ -35,14 +36,14 @@ export function TestFlightEditor({ project, data, onSaved }: { project: Project; {typeof desc === 'string' && } - +
- -
+
+
- +
); } diff --git a/apps/desktop/src/screens/tabs/AppInfo.tsx b/apps/desktop/src/screens/tabs/AppInfo.tsx index 7b7e5bf..be4ce7c 100644 --- a/apps/desktop/src/screens/tabs/AppInfo.tsx +++ b/apps/desktop/src/screens/tabs/AppInfo.tsx @@ -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 {t('ai.allAnswered')}; return ( - +
{ask.map(a => (
@@ -45,7 +47,7 @@ function AskCard({ ask }: { ask: DeclAsk[] }) {
))}
- +
); } @@ -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 ( - {t('ai.dsSub')}} pad={false} +
{t('ai.dsSub')}} pad={false} actions={ds ? <> {t('ai.dsUpload')} : undefined}> - {!ds ? {t('ai.dsNoneText')} : ( + {!ds ? {t('ai.dsNoneTitle')}
{t('ai.dsNoneText')}
: ( <>
@@ -99,7 +101,7 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR {asked > 0 && {t('ai.askedHere', { count: asked })}}
{ds.types.length > 0 && ( - +
{ds.types.map(x => ( @@ -110,12 +112,12 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR ))} -
{t('ai.colType')}{t('ai.colCollected')}{t('ai.colShared')}
+
)}
{t('ai.dsFoot', { rows: ds.rows })}
)} -
+ ); } @@ -123,14 +125,14 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe const ap = d.appPrivacy; if (!ap) return null; return ( - {t('ai.apSub')}} pad={false} +
{t('ai.apSub')}} pad={false} actions={<> }>
{t('ai.apNote')}
- {!ap.items.length ? {ap.fromDataSafety ? t('ai.apNoneText') : t('ai.apUnknownText')} : ( - + {!ap.items.length ? {ap.fromDataSafety ? t('ai.apNoneTitle') : t('ai.apUnknownTitle')}
{ap.fromDataSafety ? t('ai.apNoneText') : t('ai.apUnknownText')}
: ( +
{ap.items.map(x => ( @@ -142,9 +144,9 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe ))} -
{t('ai.colAppleType')}{t('ai.colPurposes')}{t('ai.colLinked')}{t('ai.colTracking')}
+
)} - + ); } @@ -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 ( - +
{forms.map(f => (
@@ -167,7 +169,7 @@ function ConsoleSteps({ project, stores }: { project: Project; stores: Declarati
))}
- +
); } @@ -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 ? : ; + if (!r.data) return r.error ? :
; 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 ( - <> +
+
+
{t('v2ins.aiSheet')}

{name}

+ {d.stores.apple && App Store}{d.stores.play && Google Play} +
+
+ {facts.map(f =>
{f.label}
{f.value}
)} +
+
{d.stores.apple && e?.app?.platforms.ios && !e.app.platforms.ios.ascAppId && ( }>
{t('cf.noRecordTitle')}{t('cf.noRecordText')}
)} -
- {d.stores.play ? : {t('ai.noPlayText')}} - {d.stores.apple ? : {t('ai.noAppleText')}} +
+ {d.stores.play ? :
{t('ai.noPlay')}
{t('ai.noPlayText')}
} + {d.stores.apple ? :
{t('ai.noApple')}
{t('ai.noAppleText')}
}
{d.stores.apple && ( -
- +
{t('ai.push')} @@ -212,15 +232,15 @@ export function AppInfoTab({ project }: { project: Project }) {
{t('ai.rights')}
{info!.contentRights ? {info!.contentRights === 'USES_THIRD_PARTY_CONTENT' ? t('ai.rightsThird') : t('ai.rightsOwn')} : {t('ai.notSet')}}
{t('ai.price')}
{info!.free ? t('ai.free') : t('ai.paid')}
- - +
{t('ai.encAnswer')}}>
{enc === null ? {t('ai.notAnswered')} : {enc ? t('ai.encYes') : t('ai.encNo')}} {enc === null ? t('ai.encUnset') : enc ? t('ai.encYesText') : t('ai.encNoText')}
- - +
{t('ai.push')}}>
@@ -235,13 +255,13 @@ export function AppInfoTab({ project }: { project: Project }) {
{t('ai.reviewSafe')}
- +
)} -

{t('ai.foot', { when: ago(r.at) })}

- +

{t('ai.foot', { when: ago(r.at) })}

+
); } diff --git a/apps/desktop/src/screens/tabs/Art.tsx b/apps/desktop/src/screens/tabs/Art.tsx index 66ff5b9..43517fc 100644 --- a/apps/desktop/src/screens/tabs/Art.tsx +++ b/apps/desktop/src/screens/tabs/Art.tsx @@ -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 ( -
+
{tile.name}{tile.w}×{tile.h}{tile.bytes ? ` · ${bytes(tile.bytes)}` : ''}
{tile.issues.length > 0 &&
    {tile.issues.slice(0, 2).map((i, k) =>
  • {i.msg}
  • )}
} @@ -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 <>{r.error ? : }; + if (!data) return
{r.error ? :
}
; const total = data.totals.images + data.totals.videos; const toolbar = ( <> @@ -136,15 +140,15 @@ export function ArtTab({ project }: { project: Project }) { ); return ( - <> +
-
- - - - +
+ + + +
- {dir}} actions={toolbar} pad={false}> +
{dir}} actions={toolbar} pad={false}>
{data.variants.length > 1 && [x.id, x.id ? t('gr.variant', { id: x.id }) : t('art.base')] as [string, string])} />}
@@ -163,9 +167,9 @@ export function ArtTab({ project }: { project: Project }) {
{rows.map(row => (
i.level === 'error') ? ' bad' : ''}`} key={row.size}> -

{row.label}{lg}/{row.size}{row.store === 'apple' ? 'App Store' : 'Google Play'}{t('art.count', { count: row.tiles.length })}

+

{row.label}{lg}/{row.size}{row.store === 'apple' ? 'App Store' : 'Google Play'}{row.issues.length > 0 && i.level === 'error') ? 'bad' : 'warn'}>{t('v2lst.issues', { count: row.issues.length })}}{t('art.count', { count: row.tiles.length })}

{row.issues.length > 0 &&
    {row.issues.map((i, k) =>
  • {i.msg}
  • )}
} -
row.tiles[0].h ? '260px' : '150px' }}> +
row.tiles[0].h ? '260px' : '170px' }}> {row.tiles.map((tile, i) => setOpen({ tiles: row.tiles, i })} />)}
@@ -173,8 +177,8 @@ export function ArtTab({ project }: { project: Project }) {
)}
{t('art.foot', { when: ago(r.at) })}
- +
{open && setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} onReplace={tile => { setOpen(null); replace(tile); }} />} - +
); } diff --git a/apps/desktop/src/screens/tabs/Catalog.tsx b/apps/desktop/src/screens/tabs/Catalog.tsx index 7dedd9d..290d34a 100644 --- a/apps/desktop/src/screens/tabs/Catalog.tsx +++ b/apps/desktop/src/screens/tabs/Catalog.tsx @@ -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 = { 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 ( -
+
+ {store && {store === 'ios' ? 'App Store' : 'Google Play'}} {t(key)} {c.notes.length > 0 &&
    {c.notes.slice(0, 3).map((n, i) =>
  • {n}
  • )}
}
@@ -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 ( - +
{android && (
@@ -60,7 +63,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project

{t('act.catalogNote')}

- +
); } @@ -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 ; + if (!e) return
; 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 {t('cat.emptyText')}; + return
{t('cat.emptyTitle')} {t('cat.emptyText')}
; } return ( - <> -
- - - 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} /> - +
+
+ + + 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} /> +
{diff.error && } { diff.refresh(); }} /> - }> -
- - - - {rows.map(r => ( - - - - - - - - - ))} - -
{t('col.item')}{t('col.type')}{t('col.config')}App StoreGoogle Play{t('col.priceGap')}
{r.id}{r.kind === 'extra' ? {t('cat.extra')} : {r.kind === 'subscription' ? t('cat.sub') : t('cat.product')}}{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')}}{r.maxDiff !== null ? 25 ? 'bad' : 'warn'}>{`${num(r.maxDiff, 1)} %`} : {read ? t('cat.noGap') : '–'}}
-
-
+
    + {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 ( +
  • +
    + +
    + {r.id} + {r.kind === 'extra' ? {t('cat.extra')} : {r.kind === 'subscription' ? t('cat.sub') : t('cat.product')}} + {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')} +
    + + {r.maxDiff !== null ? 25 ? 'bad' : 'warn'} title={t('col.priceGap')}>{`Δ ${num(r.maxDiff, 1)} %`} : {read ? t('cat.noGap') : '–'}} +
    +
    + + +
    +
  • + ); + })} +
+ {gaps.length > 0 && ( - - +
+
{gaps.slice(0, 12).map((g, i) => ( @@ -125,9 +134,9 @@ export function CatalogTab({ project }: { project: Project }) { ))} -
{t('col.item')}{t('col.region')}App StoreGoogle Play{t('col.diff')}
-
+
+ )} - +
); } diff --git a/apps/desktop/src/screens/tabs/Checks.tsx b/apps/desktop/src/screens/tabs/Checks.tsx index 1752997..6dc0ad1 100644 --- a/apps/desktop/src/screens/tabs/Checks.tsx +++ b/apps/desktop/src/screens/tabs/Checks.tsx @@ -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 = { 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 ( +
+ +
+
{title}
+ {children} +
+
+ {tags &&
{tags}
} + {actions &&
{actions}
} +
+
+ ); +} + +/** 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(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 ( + {f.guideline}{f.title}} + tags={<>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters[0])}{f.store === 'apple' ? 'App Store' : 'Google Play'}} + actions={<>{!f.accepted && }{!f.accepted && fixable(f.action) && }}> + {ev.slice(0, 2).map((e, j) => {e.file}:{e.line}{e.text ? ` ${e.text}` : ''})} + {f.accepted ? {t('checks.accepted', { reason: f.accepted })} + : {f.fix} {f.skill}} + {!f.accepted && f.aiFix && !fixable(f.action) &&
{ s.refresh(); }} + stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />
} +
+ ); + }; return ( - x.name).join(', ') }) : t('checks.scanSub')} pad={false} +
x.name).join(', ') }) : t('checks.scanSub')} actions={}> - {s.error &&
} - {!s.data ? (s.loading && !s.error ?
: null) - : !rows.length ? {t('checks.scanCleanText')} : ( -
- {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 ( -
- -
- {f.guideline} · {f.title} - {ev.slice(0, 2).map((e, j) => {e.file}:{e.line}{e.text ? ` ${e.text}` : ''})} - {f.accepted ? {t('checks.accepted', { reason: f.accepted })} - : {f.fix} {f.skill}} - {!f.accepted && } - {!f.accepted && f.aiFix && !fixable(f.action) && { s.refresh(); }} - stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />} -
- {!f.accepted && fixable(f.action) && } - {t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters[0])} - {f.store === 'apple' ? 'App Store' : 'Google Play'} -
- ); - })} +
+ {s.error && } + {!s.data ? (s.loading && !s.error ? : null) + : !rows.length ? {t('checks.scanClean')}
{t('checks.scanCleanText')}
: ( + <> + {open.map(card)} + {accepted.length > 0 && ( +
+ {t('v2ins.cAccepted', { count: accepted.length })} +
{accepted.map(card)}
+
+ )} + + )} + {s.data && (asks > 0 || s.data.declarations?.changed) && ( +
+ {asks > 0 &&
{t('checks.scanAsk', { count: asks })}
} + {s.data.declarations?.changed &&
{t('checks.scanWrite')}
}
)} - {s.data && (asks > 0 || s.data.declarations?.changed) && ( -
- {asks > 0 &&
{t('checks.scanAsk', { count: asks })}
} - {s.data.declarations?.changed &&
{t('checks.scanWrite')}
} -
- )} - +
+
); } @@ -114,10 +141,10 @@ function ReportBar({ project }: { project: Project }) { finally { setBusy(false); } }; return ( - {t('checks.reportOpen')}}> - {null} - +
+ {t('checks.report')}{last ? reportSummary(last) : t('checks.reportSub')} + +
); } @@ -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 ( + {f.store === 'apple' ? 'App Store' : 'Google Play'}} + actions={<>{f.level !== 'ok' && fixable(f.action) && }}> + {f.fix && {f.fix}} + + ); + }; return ( -
-
-
- d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}>{t('checks.again')}}> - {!e ?
: !problems.length && !warnings.length ? ( - {t('checks.cleanText')} - ) : ( -
- {problems.map((p, i) =>
{p}{t('checks.problem')}
)} - {warnings.map((w, i) =>
{w}{t('checks.warning')}
)} +
+
+
+ +
+ {t('v2ins.cScore')} +

{headline}

+

{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) })}` : ''}

+ {total > 0 && }
- )} - - }> - {v.error &&
} - {!v.data ? ( - v.loading && !v.error ?
: {t('checks.validateEmptyText')} - ) : !findings.length ? {t('checks.readyText')} : ( -
- {findings.map((f, i) => { - const [tone, icon] = LEVEL[f.level]; - return ( -
- -
- {f.msg} - {f.fix && {f.fix}} -
- - {f.level !== 'ok' && fixable(f.action) && } - {f.store === 'apple' ? 'App Store' : 'Google Play'} -
- ); - })} +
+
+ {blockers}{t('v2ins.cBlockers')} + {warns}{t('v2ins.cWarnings')} + {passed}{t('v2ins.cPassedN')} +
+
+ + +
+
+ {v.error && } + +
+
+ {!e ? : null} + {problems.map((p, i) => {t('checks.problem')})} + {vBlock.map(finding)} + {!v.data && !v.error && (v.loading ? : {t('checks.validateEmpty')}
{t('checks.validateEmptyText')}
)} + {e && v.data && !blockers && {t('checks.ready')}
{t('checks.readyText')}
} +
+
+ + + + {(warnings.length > 0 || vWarn.length > 0) && ( +
+
+ {warnings.map((x, i) => {t('checks.warning')})} + {vWarn.map(finding)}
- )} - +
+ )} + + {vOk.length > 0 && ( +
+ {t('v2ins.cPassed', { count: vOk.length })} +
{vOk.map(finding)}
+
+ )} + +
); } diff --git a/apps/desktop/src/screens/tabs/Growth.tsx b/apps/desktop/src/screens/tabs/Growth.tsx index 7849f96..e4bbcc9 100644 --- a/apps/desktop/src/screens/tabs/Growth.tsx +++ b/apps/desktop/src/screens/tabs/Growth.tsx @@ -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 ( - {stores.length > 1 && }{actions}}> {none ? ( - {t('gr.noneText')}{hasPlay && !playReports ? ` ${t('gr.noBucket')}` : ''} - ) : r.error ?
: !a || !s ?
: !s.dates.length ? ( - {a.errors?.[st] ?? t('gr.noDataText')}{st === 'play' && !playReports ? ` ${t('gr.noBucket')}` : ''} + {t('gr.noneTitle')}
{t('gr.noneText')}{hasPlay && !playReports ? ` ${t('gr.noBucket')}` : ''}
+ ) : r.error ? : !a || !s ? : !s.dates.length ? ( + {t('gr.noDataTitle', { store: st === 'apple' ? 'App Store' : 'Google Play' })}
{a.errors?.[st] ?? t('gr.noDataText')}{st === 'play' && !playReports ? ` ${t('gr.noBucket')}` : ''}
) : ( -
-
- - - +
+
+ + + + +
+

{t('gr.days', { count: s.dates.length })} · {t('gr.conversionSub')}

{s.dates.length > 1 ? ( ) : {t('gr.oneDay')}} {effects.length > 0 && ( - +
{effects.map((e, i) => { @@ -79,17 +99,17 @@ function Analytics({ projectId, hasApple, hasPlay, name, playReports }: { projec - + ); })} -
{t('gr.change')}{t('col.date')}{t('gr.before')}{t('gr.after')}{t('col.diff')}
{date(e.event.date)} {pct(e.before.conversion ?? null, 1)}
{t('gr.daysN', { count: e.daysBefore })}
{pct(e.after.conversion ?? null, 1)}
{t('gr.daysN', { count: e.daysAfter })}
{dlt === null ? '–' : = 0 ? 'ok' : 'warn'} dot={false}>{`${dlt >= 0 ? '+' : ''}${num(dlt, 1)} %`}}{dlt === null ? '–' : = 0 ? 'ok' : 'warn'}>{`${dlt >= 0 ? '+' : ''}${num(dlt, 1)} %`}}
+
)} -

{t('gr.reading')}

+

{t('gr.reading')}

)} - + ); } @@ -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 ( - }> - {r.error ?
: !a ?
: ( -
-
- : !a ? : ( +
+
+ - - +
{a.ranks.dates.length > 1 && lines.length > 0 && } {a.ranks.rows.length > 0 && ( - +
{a.ranks.rows.slice(0, 8).map(x => ( @@ -127,25 +147,25 @@ function Aso({ projectId }: { projectId: string }) { - + ))} -
{t('gr.asoKeyword')}{t('col.store')}{t('gr.asoRank')}{t('col.diff')}{t('gr.asoBest')}
{x.keyword} {x.storefront} {x.rank === null ? '–' : `#${x.rank}`}{x.change === null ? x.arrow : `${x.arrow} ${x.change > 0 ? '+' : ''}${x.change}`}{x.change === null ? x.arrow : `${x.arrow} ${x.change > 0 ? '+' : ''}${x.change}`} {x.best === null ? '–' : `#${x.best}`}
+
)} {a.next.length > 0 && ( -
-
{t('gr.asoNext')}
-
    - {a.next.map((x, i) =>
  1. {x.what}
    {x.why}{x.command ? <> · {x.command} : null}
  2. )} +
    +
    {t('gr.asoNext')}
    +
      + {a.next.map((x, i) =>
    1. {i + 1}{x.what}{x.why}{x.command ? <> · {x.command} : null}
    2. )}
    )} -

    {t('gr.asoReading')}

    +

    {t('gr.asoReading')}

)} - + ); } @@ -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 ( - {r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}} pad={false} +
{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}} actions={<> }> - {r.error &&
} - {!r.data ? (r.loading && !r.error ?
: {t('gr.ppoNotReadText')}) - : !tests.length ? {variants.length ? t('gr.ppoNoneText') : t('gr.ppoNoVariants')} : ( - +
+ {r.error && } + {!r.data ? (r.loading && !r.error ? : {t('gr.ppoNotRead')}
{t('gr.ppoNotReadText')}
) + : !tests.length ? {t('gr.ppoNone')}
{variants.length ? t('gr.ppoNoneText') : t('gr.ppoNoVariants')}
: ( +
{tests.map(x => ( - + @@ -190,10 +211,10 @@ function Experiments({ app }: { app: UnifiedApp }) { ))} -
{t('gr.test')}{t('gr.state')}{t('gr.traffic')}{t('gr.dates')}{t('gr.treatments')}{t('gr.owner')}
{x.name}{x.state.replace(/_/g, ' ').toLowerCase()}{x.state.replace(/_/g, ' ').toLowerCase()} {x.trafficProportion !== undefined ? `${x.trafficProportion} %` : '–'} {x.startDate ? `${date(x.startDate)} → ${x.endDate ? date(x.endDate) : t('gr.running')}` : t('gr.notStarted')} {x.treatments.length ? x.treatments.map(tr => t('gr.treatment', { name: tr.name, count: tr.locales })).join(' · ') : '–'}
+
)} {app.project ? ( -
+
{!variants.length ? {t('gr.ppoNoVariants')} : (
@@ -215,37 +236,39 @@ function Experiments({ app }: { app: UnifiedApp }) { )}

{t('gr.ppoNote')}

- ) :
{t('gr.ppoStoreOnly')}
} - + ) :
{t('gr.ppoStoreOnly')}
} +
+ ); } function PlayExperiments({ app }: { app: UnifiedApp }) { const nav = useNav(); return ( - {t('gr.playExpSub')}} +
{t('gr.playExpSub')}} actions={}> -
-

{t('gr.playExpText')}

+
+

{t('gr.playExpText')}

{app.project &&
}
- +
); } 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 ; + if (app.project && !s) return
; return ( - <> +
{app.project && } {app.project && (hasApple || hasPlay) && } -
+
{hasApple && } {hasPlay && }
- +
); } diff --git a/apps/desktop/src/screens/tabs/Health.tsx b/apps/desktop/src/screens/tabs/Health.tsx index 9986790..846e64e 100644 --- a/apps/desktop/src/screens/tabs/Health.tsx +++ b/apps/desktop/src/screens/tabs/Health.tsx @@ -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 ( - {s.tone === 'bad' ? t('health.over') : s.tone === 'warn' ? t('health.near') : s.tone === 'ok' ? t('health.under') : t('common.unknown')}}> -
-
-
-
{pct(s.current)}
-
{t('health.current28')}
-
-
- {t('health.daily')} - {t('health.threshold', { value: pct(s.threshold) })} -
-
- -
{date(s.dates[0])}{date(s.dates[Math.floor(s.dates.length / 2)])}{date(s.dates.at(-1))}
+
+
+ {title} + {toneWord(s.tone)}
- +
+ {pct(s.current)} + {t('health.current28')} +
+ +
{date(s.dates[0])}{date(s.dates[Math.floor(s.dates.length / 2)])}{date(s.dates.at(-1))}
+
+ {t('health.daily')} + {t('health.threshold', { value: pct(s.threshold) })} + {t('health.window', { from: date(s.dates[0]), to: date(s.dates.at(-1)) })} +
+
+ ); +} + +/** 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 ( +
+
+ {t('v2ins.hStartup')} + {m.goal ? {m.goal} : null} +
+
+ {num(m.value, m.value < 10 ? 2 : 0)}{m.unit} + {m.metric} +
+
{t('v2ins.hStartupSub', { version: m.version })}
+
); } @@ -76,66 +101,58 @@ function IssueDetail({ app, v }: { app: UnifiedApp; v: IssueView }) { ); } -function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) { - const [open, setOpen] = useState(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 ( - <> -
- - -
-
- - {devices.length ? ( -
- {devices.map(x => { - const v = Math.max(x.crash ?? 0, x.anr ?? 0); - return ( -
- {x.model} - = PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} /> - {pct(v)} -
- ); - })} -
- ) :

{t('health.noDevices')}

} -
- - {h.issues.length ? ( - - - - {h.issues.slice(0, 6).map((i, n) => ( - setOpen(open === n ? null : n)} cols={3} - head={<>
{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}{i.cause || '?'}
-
{i.location}
} - cells={<>
} - detail={} /> - ))} - -
{t('col.issue')}{t('col.reports')}{t('col.users')}
{num(i.reports)}{num(i.users)}
- ) : } -
-
- +
+ + {open &&
{detail}
} +
); } -/** 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(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 ( - <> - { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }} tabIndex={0} aria-expanded={open} role="button"> -
{head}
- {cells} - - {open && {detail}} - +
+
+ {h.issues.length ? ( +
+ {h.issues.slice(0, 6).map((i, n) => ( + 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={<>{num(i.reports)}{t('col.reports')}{num(i.users)}{t('col.users')}} + detail={} /> + ))} +
+ ) : {t('health.noIssues')}} +
+
+ {devices.length ? ( +
+ {devices.map(x => { + const v = Math.max(x.crash ?? 0, x.anr ?? 0); + return ( +
+ {x.model} + = PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} /> + {pct(v)} +
+ ); + })} +
+ ) : {t('health.noDevices')}} +
+
); } @@ -143,70 +160,83 @@ function Apple({ h, app }: { h: AppleHealth; app: UnifiedApp }) { const [open, setOpen] = useState(null); const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count)); return ( -
- - {h.metrics.length ? ( - - - +
+
+ {h.signatures.length ? ( +
+ {h.signatures.slice(0, 5).map((s, i) => ( + setOpen(open === i ? null : i)} tone="bad" kind={s.type} + title={s.signature || s.type} where={t('health.build', { build: s.build })} + stats={{num(s.weight * 100, 1)}%} + detail={} /> + ))} +
+ ) : {t('health.noSignatures')}} +
+
+
+ {h.metrics.length ? ( +
{h.metrics.slice(0, 8).map((m, i) => ( -
- +
+ {m.metric}{m.category} · {m.version} + {num(m.value, m.value < 10 ? 2 : 0)} {m.unit} + {m.goal ? {m.goal} : –} +
))} - -
{t('col.metric')}{t('col.value')}{t('col.version')}{t('col.goal')}
{m.metric}
{m.category}
{num(m.value, m.value < 10 ? 2 : 0)} {m.unit}{m.version}{m.goal ? {m.goal} : '–'}
- ) : } -
-
- - {h.signatures.length ? ( - - - {h.signatures.slice(0, 5).map((s, i) => ( - setOpen(open === i ? null : i)} cols={2} - head={<>
{s.signature || s.type}
{s.type} · {t('health.build', { build: s.build })}
} - cells={
} - detail={} /> - ))} - -
{num(s.weight * 100, 1)}%
- ) : } -
- +
+ ) : {t('health.noMetrics')}} + +
{h.betaCrashes.length ? ( -
{h.betaCrashes.slice(0, 5).map(b => ( -
{t('health.build', { build: b.build })}{num(b.count)}
+
{h.betaCrashes.slice(0, 5).map(b => ( +
{t('health.build', { build: b.build })}{num(b.count)}
))}
- ) :

{t('health.noBetaCrashes')}

} - + ) : {t('health.noBetaCrashes')}} +
); } export function HealthTab({ app }: { app: UnifiedApp }) { + const d = useData(); + const hue = useTint(d.iconFor(app), app.name); const r = useRead(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 ; + if (r.loading && !r.error) return
; return ( - - {t('health.read')}}>{t('health.emptyText')} - {r.error && } - +
+
+ {t('health.read')}}> + {t('health.emptyTitle')}
{t('health.emptyText')} +
+ {r.error && } +
+
); } 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 ( - <> -
- - -
-

Google Play

+
+
+ {tiles && ( +
+ {crash && } + {anr && } + {apple && } +
+ )} +

Google Play

{h.play === undefined ? {t('health.noPlay')} : isError(h.play) ? : <>{h.play.issuesError && }} -

App Store

+

App Store

{h.apple === undefined ? {t('health.noApple')} : isError(h.apple) ? : } - +
); } diff --git a/apps/desktop/src/screens/tabs/Listings.tsx b/apps/desktop/src/screens/tabs/Listings.tsx index 44753b1..d4aa4bf 100644 --- a/apps/desktop/src/screens/tabs/Listings.tsx +++ b/apps/desktop/src/screens/tabs/Listings.tsx @@ -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 ( - -
-
{t('col.language')}{fields.map(f => {t(FIELD_KEY[f] ?? 'lst.title')})}{t('lst.complete')}
- {locales.map(l => ( -
-
{l.lang}{l[store]?.code ?? ''}{l.rtl && RTL}
- {fields.map(f => x.field === f)} />)} -
+
= 1 ? ' done' : ''}`} data-lang={l.lang} aria-label={`${store === 'play' ? 'Google Play' : 'App Store'} ${l.lang}`}> +
+ {l.lang} + {l[store]?.code ?? ''} + {l.rtl && RTL} + + {bad > 0 ? {t('v2lst.toFix', { count: bad })} : v >= 1 ? {t('v2lst.ready')} : null} + +
+
+ {fs.map(f => ( +
+ {t(FIELD_KEY[f.field] ?? 'lst.title')} +
))}
- +
+ ); +} + +function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: ListingLocale[] }) { + return ( +
{store === 'play' ? 'Google Play' : 'App Store'}} icon="text" sub={store === 'play' ? t('lst.playSub') : t('lst.appleSub')}> +
+ {locales.filter(l => l[store]).map(l => )} +
+
); } @@ -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 ( - +
{android && (
@@ -99,7 +117,7 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] })

{t('act.note')}

- +
); } @@ -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 ; - return ; + return
; } 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 ( - <> -
- l.lang).join(' · ')} /> - - - l.screenshots !== null) ? shots : '–'} sub={L.locales.some(l => l.screenshots !== null) ? t('lst.screenshotsSub') : t('lst.noRenders')} /> +
+
+ l.lang).join(' · ')} /> + + + l.screenshots !== null) ? shots : '–'} sub={L.locales.some(l => l.screenshots !== null) ? t('lst.screenshotsSub') : t('lst.noRenders')} />
{L.problems.map((p, i) => {p})} {!noFiles && l.lang)} />} {noFiles ? ( - - {t('lst.noFilesText')} -
storebound init{t('lst.noFilesHint')}
-
+
+ storebound init{t('lst.noFilesHint')}
}>{t('lst.noFilesTitle')} {t('lst.noFilesText')} + ) : ( <> {L.files.play && } @@ -139,6 +156,6 @@ export function ListingsTab({ project }: { project: Project }) { )}

{t('lst.foot', { when: ago(r.at) })}

- +
); } diff --git a/apps/desktop/src/screens/tabs/LiveArt.tsx b/apps/desktop/src/screens/tabs/LiveArt.tsx index 92866fd..f86dbea 100644 --- a/apps/desktop/src/screens/tabs/LiveArt.tsx +++ b/apps/desktop/src/screens/tabs/LiveArt.tsx @@ -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(); @@ -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 ( -
+
w ? 'portrait' : 'landscape'}`} style={{ ['--ar' as string]: `${w} / ${h}` }} data-live-tile={tile.state}>
{src ? : {src === null && tile.thumb ? <>{t('art.liveNoThumb')} : }}
{name}{tile.w && tile.h ? `${tile.w}×${tile.h}` : ''}
- {state && {t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}} + {state && {t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}}
); } @@ -84,10 +86,10 @@ export function LiveArt({ target, project }: { target: string; project?: Project const actions = <>{pull}; if (!data) { return ( - +
{r.loading && !r.error ?
: { r.refresh(); }}>{t('art.liveRead')}}>{r.error ?? t('art.liveEmptyText')}} - +
); } 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 ( - +
{data.stores.filter(s => !s.ok).map(s =>
)}
{!project && {t('art.liveReadOnly')}} @@ -120,7 +122,7 @@ export function LiveArt({ target, project }: { target: string; project?: Project {sets.map(s => (

{s.label}{s.lang && {s.lang}/{s.size}}{s.store === 'apple' ? 'App Store' : 'Google Play'}{t('art.count', { count: s.tiles.length })}

-
(s.tiles[0].h ?? 1) ? '260px' : '150px' }}> +
(s.tiles[0].h ?? 1) ? '260px' : '170px' }}> {s.tiles.map(tile => )}
@@ -128,6 +130,6 @@ export function LiveArt({ target, project }: { target: string; project?: Project
)}
{project ? t('art.liveFoot') : t('art.liveFootStoreOnly')}
- +
); } diff --git a/apps/desktop/src/screens/tabs/Prices.tsx b/apps/desktop/src/screens/tabs/Prices.tsx index 4f51cb1..7dc37f3 100644 --- a/apps/desktop/src/screens/tabs/Prices.tsx +++ b/apps/desktop/src/screens/tabs/Prices.tsx @@ -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 }) => {code}; 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 –; return ( -
+
= GAP ? (r.gapPct > 25 ? ' gap-bad' : ' gap-warn') : ''}`}> {apple && {r.apple ? money(r.apple.price, r.apple.currency) : '–'}} {play && {r.play ? money(r.play.price, r.play.currency) : '–'}} {r.gapPct !== null && r.gapPct >= GAP && 25 ? 'bad' : 'warn'} dot={false}>{`Δ ${num(r.gapPct, 0)} %`}} @@ -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 ( - {sum && sum.pairs > 0 && {t('prices.apply', { count: sum.pairs })}} @@ -51,8 +56,8 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff {sum!.down > 0 && {t('prices.dDown', { count: sum!.down })}} {t('prices.dProducts', { list: sum!.products.join(', ') })}
-
- +
+
{rows.slice(0, 200).map((r, i) => { @@ -61,7 +66,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff - + @@ -76,7 +81,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff
{t('prices.applyNote')}
)} - + ); } @@ -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 ; + if (!e) return
; if (!s?.products) { return ( - +
{t('prices.emptyText')}
products: [{'{'} id, type, usd {'}'}]{t('prices.emptyHint')}
- +
); } return ( - <> - +
runExport(exportItem)} disabled={!p}>{t('prices.exportCsv')}}>
@@ -136,33 +141,33 @@ export function PricesTab({ project }: { project: Project }) {

{t('prices.sheetNote')}

- +
{sheet && { cur.refresh(); diff.refresh(); }} />} {cur.error && } {!p ? ( - cur.loading && !cur.error ? : ( - + cur.loading && !cur.error ?
: ( +
{t('prices.readNow')}}>{t('prices.notReadText')} - +
) ) : ( <> -
- - - - `${m.product} (${m.store === 'apple' ? 'App Store' : 'Play'})`).join(', ') : t('prices.kMissingNone')} tone={stats!.missing ? 'bad' : undefined} /> +
+ + + + `${m.product} (${m.store === 'apple' ? 'App Store' : 'Play'})`).join(', ') : t('prices.kMissingNone')} tone={stats!.missing ? 'bad' : undefined} />
- }> -
-
{t('col.item')}{t('col.store')}{t('col.region')}{t('prices.colStarts')}{t('prices.colNow')}{t('prices.colNew')}{t('col.diff')}
{r.product} {r.store === 'apple' ? 'App Store' : 'Google Play'}{r.region} {r.startDate ?? t('prices.now')} {r.from === null ? '–' : money(r.from, r.currency)} {money(r.to, r.currency)}
- {matrix!.regions.map(r => )} +
+
{t('col.item')}{r}
+ {matrix!.regions.map(r => )} {matrix!.rows.map(row => ( setProduct(row.product)}> @@ -174,19 +179,19 @@ export function PricesTab({ project }: { project: Project }) {
{t('col.item')}
App StoreGoogle Play{t('prices.autoLegend')}{t('prices.gapLegend', { pct: GAP })}
- - +
setQ(ev.target.value)} placeholder={t('prices.searchRegion')} aria-label={t('prices.searchRegion')} />
}> -
- +
+
{apple && }{apple && }{play && } {regions.map(r => ( - - + = GAP ? (r.gapPct > 25 ? 'gap-bad' : 'gap-warn') : undefined}> + {apple && } {apple && } {play && } @@ -196,10 +201,10 @@ export function PricesTab({ project }: { project: Project }) {
{t('col.region')}App Store{t('prices.colKind')}Google Play{t('col.priceGap')}
{r.region}
{r.apple ? money(r.apple.price, r.apple.currency) : –}{r.scheduled &&
{t('prices.scheduled', { price: money(r.scheduled.price, r.scheduled.currency), date: r.scheduled.startDate ?? '' })}
}
{r.apple ? {r.apple.kind === 'auto' ? t('prices.auto') : t('prices.manual')} : null}{r.play ? money(r.play.price, r.play.currency) : –}
- +
{p.missing.length > 0 && {t('prices.missingNote', { list: p.missing.map(m => `${m.product} (${m.store === 'apple' ? 'App Store' : 'Google Play'})`).join(', ') })}} )} - +
); } diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index 29a4c53..498306e 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -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 ( -
-
- -
-
{storeName(lane.store)}
-
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}
+
+
+ +
+
{storeName(lane.store)}
+
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}
-
- {gap ?
{gap.line}
: STAGES.map((s, i) => { - const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : ''; - const last = s === 'rollout'; - return ( -
- {i < lane.at && } - {last && i === lane.at && lane.share !== null ? ( -
= 1 ? 'ok' : 'accent'} label={t('tl.rolloutAria')} /> - {lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}
- ) : {stageCaption(lane, i)}} -
- ); - })} -
+ {ph && {ph.text}} + + {gap ?
{gap.line}
: ( +
+
    + {STAGES.map((s, i) => { + const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : 'todo'; + const cap = stageCaption(lane, i); + return ( +
  1. + {i < lane.at ? : {i + 1}} + {t(STAGE_KEY[s])} + {cap && {cap}} +
  2. + ); + })} +
+ {rolling && lane.share !== null && ( +
+ +
+ {lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`} + {t('v2rel.ofUsers')} + +
+
+ )} +
+ )} + {r && app.project && } + ); } @@ -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 ( - {ver}{label}} - sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}> -
-
{t('tl.store')}{STAGES.map(s => {t(STAGE_KEY[s])})}
- {lanes.map(l => )} -
- {lanes.some(l => l.release) && app.project && ( -
- {lanes.map(l => l.release && )} +
+
+
+ {t('v2rel.version')} + {ver}
- )} - + {label} + + {created && {created}} +
+
+ {lanes.map(l => )} +
+
); } @@ -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(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) ? ( -
-

{t('sub.section')}

-
+
+
+

{t('sub.section')}

+

{t('releases.ownerNote')}

+
+
{sum?.apple && } {sum?.play && }
-

{t('releases.ownerNote')}

) : null; + const wrap = (children: ReactNode) =>
{children}
; - if (!l && d.portfolioBusy === 'live') return <>{submit}; + if (!l && d.portfolioBusy === 'live') return wrap(<>{submit}); if (!l) { - return ( + return wrap( <> @@ -166,12 +198,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { }>{t('releases.notReadText')} {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 && } {l.release.current && } - {!l.release.next && !l.release.current && {t('releases.noVersionsText')}} + {!l.release.next && !l.release.current &&
{t('releases.noVersions')} {t('releases.noVersionsText')}
} - +
{t('v2rel.versions', { count: history.length })} : undefined}> {history.length ? ( -
- +
+
{history.map(h => ( - + @@ -200,10 +233,10 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
{t('col.version')}App Store{t('col.build')}Google Play{t('col.versionCode')}{t('col.track')}
{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')} {h.ios ? : –} {h.ios?.build ?? '–'} {h.android ? : –}
- ) : } - + ) :
{t('releases.noVersions')}
} +
{submit} - + , ); } diff --git a/apps/desktop/src/screens/tabs/Reviews.tsx b/apps/desktop/src/screens/tabs/Reviews.tsx index 95b5348..d5a8152 100644 --- a/apps/desktop/src/screens/tabs/Reviews.tsx +++ b/apps/desktop/src/screens/tabs/Reviews.tsx @@ -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 ( - {r.title || t('reviews.noTitle')}} - sub={{r.store === 'apple' ? 'App Store' : 'Google Play'}{r.where ? ` · ${r.where}` : ''} · {date(r.date)}{r.appVersion ? ` · ${t('reviews.version', { v: r.appVersion })}` : ''}}> -
-

{r.body || t('reviews.noText')}

- {r.reply && ( -
- {t('reviews.currentReply')}{r.reply.date ? ` · ${date(r.reply.date)}` : ''} -
{r.reply.text}
-
- )} -
-
-
- {t('reviews.draft')} - {draft && (draft.sent ? {t('reviews.sentOn', { date: date(draft.sent) })} : draft.approved ? {t('reviews.approved')} : {t('reviews.notApproved')})} -
- {!draft ?

{t('reviews.noDraft')}

: draft.sent ? ( -
{draft.reply}
- ) : ( - <> -