desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case

This commit is contained in:
CRTkafa 2026-10-14 00:35:29 +08:00
parent 714352ec18
commit 3cea2af4e1
37 changed files with 3020 additions and 54 deletions

View file

@ -10,6 +10,7 @@ import type { Overrides } from './lib/identity';
export type {
AccountCheck, AppIdentity, AppsListResult, AppStatus, CatalogParity, DraftsResult, StoreApp, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult,
ProgressParams, ProjectSummary, ReleasePhase, ReleaseTrain, ReplyDraft, StoreRelease, ToolResult,
EditableListing, EditableResult, EditResult,
AnalyticsCompare, AsoAuditView, AsoTrendRow, ConsoleAnswer, ConsoleFillResult, ConsoleForm, DeclAsk, DeclarationsResult, ListingText, PpoStatus, PpoTest, PriceDiffRow, PriceRowData, PricesCurrent, PricesDiff, RenderImage, RenderTile, RendersResult,
} from '../../../cli/lib/desktop-protocol';

View file

@ -1545,4 +1545,182 @@ export const en = {
'deps.sdkFrom': 'Found through',
'deps.sdkNone': 'Not found. play build needs it (install Android Studio, or set ANDROID_HOME). Checked:',
'ai.dsFrom': 'from {source}',
'ed.countAria': '{n} of {limit} characters',
'ed.unsavedField': 'Unsaved change',
'ed.was': 'Saved:',
'ed.empty': '(empty)',
'ed.removed': '(removed)',
'ed.reset': 'Reset',
'ed.notSet': 'Not set',
'ed.none': 'none',
'ed.notNumber': 'Not a number',
'ed.notJson': 'Not valid JSON: {error}',
'ed.noGit': 'Not a git folder',
'ed.gitClean': 'No uncommitted changes in these files',
'ed.gitDirty': 'Uncommitted in git: {files}. storebound never commits.',
'ed.also': 'the same section also serves {codes}',
'ed.newFile': 'new file',
'ed.masked': 'Personal data: not shown.',
'ed.unsaved': '{count} unsaved changes',
'ed.unsaved_one': '1 unsaved change',
'ed.noChanges': 'No unsaved changes',
'ed.undo': 'Undo',
'ed.redo': 'Redo',
'ed.discard': 'Discard',
'ed.review': 'Review and save',
'ed.close': 'Close',
'ed.fixFirst': 'Fix the errors first',
'ed.save': 'Save to the app folder',
'ed.saved': 'Saved to the app folder',
'ed.nothingToApply': 'Nothing to change on the store.',
'ed.applyNow': 'Apply to the store',
'ed.saveFirst': 'Save your edits first: the store gets what the files hold.',
'ed.statusDraft': 'Machine draft, not reviewed',
'ed.statusTranslate': 'Not translated yet',
'ed.alsoServes': 'also {codes}',
'ed.newSection': '{lang} has no section in the listing file yet. Fill in the required fields; saving adds the section.',
'ed.keywordsHint': 'Comma separated, no spaces after commas; do not repeat words from the name or subtitle.',
'ed.whatsNewApple': 'Not allowed on the first version.',
'ed.nameHint': 'Empty: the app name from the config.',
'ed.supportUrl': 'Support URL',
'ed.marketingUrl': 'Marketing URL',
'ed.privacyUrl': 'Privacy policy URL',
'ed.termsUrl': 'Terms of Use URL',
'ed.termsHint': 'Needed with subscriptions (Apple 3.1.2).',
'ed.website': 'Website',
'ed.websiteHint': 'Google Play: the domain that serves app-ads.txt.',
'ed.contactEmail': 'Contact email',
'ed.copyright': 'Copyright',
'ed.video': 'Promo video (YouTube URL)',
'ed.videoHint': 'Public or unlisted, ads off, embeddable. Play takes no video upload.',
'ed.listingTitle': 'Edit the listing',
'ed.listingSub': 'Per language, both stores side by side. Counters use the store limits; the checks of check and scan run as you type.',
'ed.pullPlay': 'Pull from Google Play',
'ed.pullApple': 'Pull from the App Store',
'ed.pulled': 'The listing file now matches the store',
'ed.pullHint': 'Reads the live texts and shows the diff before anything in the files changes.',
'ed.pullDirty': 'You have unsaved edits: they stay pending on top of the pulled text.',
'ed.pullNote': 'Pulling rewrites the listing files after a diff; your unsaved edits stay pending on top.',
'ed.playCode': 'Play language',
'ed.rtl': 'Right-to-left language.',
'ed.noStoreForLang': '{lang} is on neither store in the config.',
'ed.detailsTitle': 'Store details',
'ed.detailsSub': 'URLs, contact and copyright, shared by both stores (storebound.config.ts).',
'ed.applyTitle': 'Apply to the stores',
'ed.applySub': 'Each store: the dry run (old to new per language) in the window, then the confirm dialog, then the real run.',
'ed.applyPlay': 'Google Play listing: preview',
'ed.applyApple': 'App Store {version}: preview',
'ed.applyNote': 'Nothing goes to review: the texts wait in the store until you send the version or changes for review.',
'ed.appleInfoTitle': 'App Store app information',
'ed.appleInfoSub': 'Categories, content rights, price, export compliance and the App Store name.',
'ed.catPrimary': 'Primary category',
'ed.catSub1': 'Game subcategory 1',
'ed.catSub2': 'Game subcategory 2',
'ed.catSecondary': 'Secondary category',
'ed.free': 'Price',
'ed.freeHint': 'Free: available in every territory and new ones.',
'ed.encHint': 'Only you can answer this: export compliance skill.',
'ed.appleName': 'App Store name',
'ed.appleNameHint': 'When the app name is taken on the App Store.',
'ed.ageTitle': 'Age rating',
'ed.ageSub': 'Every question of the App Store declaration. Not set: the "nothing" answer.',
'ed.ageDefault': 'Default: {value}',
'ed.reviewSub': 'Sign-in, notes and contact for App Review. The demo account password is never shown.',
'ed.notesHint': 'App Store Connect takes at most {max} bytes.',
'ed.signInSteps': 'Sign-in steps for reviewers',
'ed.contact_firstName': 'Contact first name',
'ed.contact_lastName': 'Contact last name',
'ed.contact_phone': 'Contact phone',
'ed.contact_email': 'Contact email',
'ed.contactHint': 'Personal data: kept in the private .storebound/ folder (gitignored), shown masked. Type to replace.',
'ed.demoHint': 'The demo account lives only in the private .storebound/ folder; the window never reads the password.',
'ed.game': 'Game',
'ed.app': 'App',
'ed.playSettingsTitle': 'Google Play store settings',
'ed.playSettingsSub': 'Category, countries, managed publishing, target audience, delete-account page.',
'ed.playCategory': 'App category',
'ed.noApiHint': 'No API: the console plan step uses this value.',
'ed.managed': 'Managed publishing',
'ed.countries': 'Production countries',
'ed.countriesHint': 'Two-letter codes (US, GB, TR). Empty: all countries.',
'ed.targetAges': 'Target age groups',
'ed.targetAgesHint': 'Of {groups}.',
'ed.deletionUrl': 'Delete-account page',
'ed.declTitle': 'Data safety and App Privacy answers',
'ed.declSub': 'The answers both forms are filled from. Only you know them: change what the app and its SDKs really do.',
'ed.dsTypes': 'Data types (Play)',
'ed.dsTypesHint': 'Play data type id to usage: { "PSL_EMAIL": { "shared": false, "collect": ["account"] } }. Not listed: not collected.',
'ed.apAnswers': 'App Privacy (App Store)',
'ed.apHint': 'Checked with the whole config when you save; App Privacy has no API (console fill).',
'ed.promoTitle': 'App Store promotion',
'ed.promoSub': 'Promoted purchases, custom product pages and in-app events.',
'ed.promoted': 'Promoted in-app purchases',
'ed.promotedHint': 'Turning them on stays a terminal step (apple promoted --enable).',
'ed.cpp': 'Custom product pages',
'ed.cppHint': 'Submitting a page and making it visible stay terminal steps.',
'ed.events': 'In-app events',
'ed.eventsHint': 'Name 30, short 50, long 120 characters per locale. Sending to review stays a terminal step.',
'ed.applyCpp': 'Custom product pages: preview',
'ed.applyEvents': 'In-app events: preview',
'ed.applyInfoSub': 'App information, App Review, export compliance, Data safety and the Play app details.',
'ed.applyPlayDetails': 'Play contact email and website: preview',
'ed.playConsoleOnly': 'Category, countries and managed publishing have no API: App info > Console steps.',
'ed.itemName': 'Name',
'ed.itemDescription': 'Description',
'ed.benefits': 'Benefits (Play)',
'ed.benefitsHint': 'Comma separated, up to 4, 40 characters each.',
'ed.usd': 'Base price (USD)',
'ed.usdHint': 'A live product\'s new price waits for your decision (Apply the price changes).',
'ed.refName': 'Reference name',
'ed.reviewNote': 'Review note',
'ed.reviewShot': 'Review screenshot',
'ed.family': 'Family Sharing',
'ed.offerCodes': 'Offer codes',
'ed.offerCodesHint': 'Creating codes stays a terminal step (they work like vouchers).',
'ed.level': 'Level in the group',
'ed.levelHint': '1 = highest level of service.',
'ed.period': 'Period',
'ed.periodHint': 'Cannot change after the subscription is approved.',
'ed.intro': 'Introductory offer',
'ed.introHint': 'For example { "type": "free", "duration": "P1W" }.',
'ed.offers': 'Promotional offers',
'ed.winBack': 'Win-back offers',
'ed.winBackHint': 'Creating one stays a terminal step: it goes live by itself on its start date.',
'ed.catalogTitle': 'Edit the catalog',
'ed.catalogEmpty': 'No products or subscriptions in the config',
'ed.catalogEmptyText': 'Add them with the store-iap-setup or store-subscriptions skill.',
'ed.productsTitle': 'In-app products',
'ed.productsSub': 'Names, descriptions per language, price and review details.',
'ed.groupTitle': 'Subscription group {name}',
'ed.groupSub': 'Group display name per language and each subscription.',
'ed.groupName': 'Group display name',
'ed.applyCatalogSub': 'Each store: the dry run per product in the window, then the confirm dialog.',
'ed.applyPlayProducts': 'Play products: preview',
'ed.applyPlaySubs': 'Play subscriptions: preview',
'ed.applyAppleIap': 'App Store in-app purchases: preview',
'ed.applyAppleSubs': 'App Store subscriptions: preview',
'ed.applyIapReview': 'Review screenshots: preview',
'ed.priceGate': 'Price changes of live products are never applied here: they wait for your decision in the owner action.',
'ed.tfTitle': 'Edit TestFlight information',
'ed.tfSub': 'What to Test per locale, the beta description, notes for Beta App Review and the groups.',
'ed.whatToTest': 'What to Test',
'ed.whatToTestHint': 'Empty: the What\'s new text is used.',
'ed.betaDescription': 'Beta App Description',
'ed.betaDescriptionAll': 'Beta App Description (every locale)',
'ed.betaNotes': 'Notes for Beta App Review',
'ed.tfGroups': 'Beta groups',
'ed.tfGroupsHint': '[{ "name": "Studio team", "internal": true }]. Existing groups are never changed or deleted.',
'ed.applyTfSub': 'Test information and the missing groups. External testing and Beta App Review stay terminal steps.',
'ed.applyTfInfo': 'Test information: preview',
'ed.artTitle': 'Screenshot order and frames',
'ed.artSub': 'The stores show the screenshots in this order. Point a scene at another frame to replace its screenshot.',
'ed.artEmpty': 'No screenshot scenes in the config',
'ed.artEmptyText': 'Set up shots with the store-screenshots skill.',
'ed.frame': 'Frame',
'ed.up': 'Up',
'ed.down': 'Down',
'ed.moveUp': 'Move {id} up',
'ed.moveDown': 'Move {id} down',
'ed.applyArtSub': 'Render, then upload: an upload replaces the store\'s screenshot set (dry run first).',
'ed.loadFailed': 'Could not read the app folder\'s files: {error}',
} as const;

View file

@ -1547,4 +1547,182 @@ export const tr: Record<keyof typeof en, string> = {
'deps.sdkFrom': 'Bulunduğu yer',
'deps.sdkNone': 'Bulunamadı. play build buna ihtiyaç duyar (Android Studio\'yu kurun veya ANDROID_HOME ayarlayın). Bakılan yerler:',
'ai.dsFrom': 'kaynak: {source}',
'ed.countAria': '{limit} karakterin {n} tanesi',
'ed.unsavedField': 'Kaydedilmemiş değişiklik',
'ed.was': 'Kayıtlı:',
'ed.empty': '(boş)',
'ed.removed': '(kaldırıldı)',
'ed.reset': 'Geri al',
'ed.notSet': 'Ayarlanmadı',
'ed.none': 'yok',
'ed.notNumber': 'Sayı değil',
'ed.notJson': 'Geçerli JSON değil: {error}',
'ed.noGit': 'Git klasörü değil',
'ed.gitClean': 'Bu dosyalarda commit edilmemiş değişiklik yok',
'ed.gitDirty': 'Git\'te commit edilmemiş: {files}. storebound asla commit etmez.',
'ed.also': 'aynı bölüm {codes} için de geçerli',
'ed.newFile': 'yeni dosya',
'ed.masked': 'Kişisel veri: gösterilmiyor.',
'ed.unsaved': '{count} kaydedilmemiş değişiklik',
'ed.unsaved_one': '1 kaydedilmemiş değişiklik',
'ed.noChanges': 'Kaydedilmemiş değişiklik yok',
'ed.undo': 'Geri al',
'ed.redo': 'Yinele',
'ed.discard': 'Vazgeç',
'ed.review': 'Gözden geçir ve kaydet',
'ed.close': 'Kapat',
'ed.fixFirst': 'Önce hataları düzeltin',
'ed.save': 'Uygulama klasörüne kaydet',
'ed.saved': 'Uygulama klasörüne kaydedildi',
'ed.nothingToApply': 'Mağazada değişecek bir şey yok.',
'ed.applyNow': 'Mağazaya uygula',
'ed.saveFirst': 'Önce düzenlemeleri kaydedin: mağazaya dosyalardaki gönderilir.',
'ed.statusDraft': 'Makine taslağı, gözden geçirilmedi',
'ed.statusTranslate': 'Henüz çevrilmedi',
'ed.alsoServes': 'ayrıca {codes}',
'ed.newSection': '{lang} için listeleme dosyasında henüz bölüm yok. Zorunlu alanları doldurun; kaydetmek bölümü ekler.',
'ed.keywordsHint': 'Virgülle ayrılmış, virgülden sonra boşluk yok; ad veya alt başlıktaki kelimeleri tekrarlamayın.',
'ed.whatsNewApple': 'İlk sürümde kullanılamaz.',
'ed.nameHint': 'Boş: yapılandırmadaki uygulama adı.',
'ed.supportUrl': 'Destek URL\'si',
'ed.marketingUrl': 'Pazarlama URL\'si',
'ed.privacyUrl': 'Gizlilik politikası URL\'si',
'ed.termsUrl': 'Kullanım koşulları URL\'si',
'ed.termsHint': 'Aboneliklerde gerekli (Apple 3.1.2).',
'ed.website': 'Web sitesi',
'ed.websiteHint': 'Google Play: app-ads.txt dosyasını sunan alan adı.',
'ed.contactEmail': 'İletişim e-postası',
'ed.copyright': 'Telif hakkı',
'ed.video': 'Tanıtım videosu (YouTube URL\'si)',
'ed.videoHint': 'Herkese açık veya liste dışı, reklamsız, yerleştirilebilir. Play video yüklemesi almaz.',
'ed.listingTitle': 'Listelemeyi düzenle',
'ed.listingSub': 'Dil başına, iki mağaza yan yana. Sayaçlar mağaza sınırlarını kullanır; check ve scan denetimleri yazarken çalışır.',
'ed.pullPlay': 'Google Play\'den çek',
'ed.pullApple': 'App Store\'dan çek',
'ed.pulled': 'Listeleme dosyası artık mağazayla aynı',
'ed.pullHint': 'Canlı metinleri okur ve dosyalarda bir şey değişmeden önce farkı gösterir.',
'ed.pullDirty': 'Kaydedilmemiş düzenlemeleriniz var: çekilen metnin üzerinde bekler.',
'ed.pullNote': 'Çekme, farkı gösterdikten sonra listeleme dosyalarını yeniden yazar; kaydedilmemiş düzenlemeler üstte bekler.',
'ed.playCode': 'Play dili',
'ed.rtl': 'Sağdan sola yazılan dil.',
'ed.noStoreForLang': '{lang} yapılandırmada iki mağazada da yok.',
'ed.detailsTitle': 'Mağaza ayrıntıları',
'ed.detailsSub': 'URL\'ler, iletişim ve telif hakkı, iki mağazada ortak (storebound.config.ts).',
'ed.applyTitle': 'Mağazalara uygula',
'ed.applySub': 'Her mağaza: pencerede deneme çalıştırması (dil başına eskiden yeniye), sonra onay penceresi, sonra gerçek çalıştırma.',
'ed.applyPlay': 'Google Play listelemesi: önizle',
'ed.applyApple': 'App Store {version}: önizle',
'ed.applyNote': 'Hiçbir şey incelemeye gitmez: metinler sürümü veya değişiklikleri incelemeye gönderene kadar mağazada bekler.',
'ed.appleInfoTitle': 'App Store uygulama bilgileri',
'ed.appleInfoSub': 'Kategoriler, içerik hakları, fiyat, ihracat uyumluluğu ve App Store adı.',
'ed.catPrimary': 'Birincil kategori',
'ed.catSub1': 'Oyun alt kategorisi 1',
'ed.catSub2': 'Oyun alt kategorisi 2',
'ed.catSecondary': 'İkincil kategori',
'ed.free': 'Fiyat',
'ed.freeHint': 'Ücretsiz: her bölgede ve yenilerinde sunulur.',
'ed.encHint': 'Bunu yalnızca siz yanıtlayabilirsiniz: export-compliance becerisi.',
'ed.appleName': 'App Store adı',
'ed.appleNameHint': 'Uygulama adı App Store\'da alınmışsa.',
'ed.ageTitle': 'Yaş derecelendirmesi',
'ed.ageSub': 'App Store beyanının her sorusu. Ayarlanmadı: "hiç" yanıtı.',
'ed.ageDefault': 'Varsayılan: {value}',
'ed.reviewSub': 'App Review için oturum açma, notlar ve iletişim. Demo hesap parolası asla gösterilmez.',
'ed.notesHint': 'App Store Connect en fazla {max} bayt alır.',
'ed.signInSteps': 'İnceleyiciler için oturum açma adımları',
'ed.contact_firstName': 'İletişim adı',
'ed.contact_lastName': 'İletişim soyadı',
'ed.contact_phone': 'İletişim telefonu',
'ed.contact_email': 'İletişim e-postası',
'ed.contactHint': 'Kişisel veri: gizli .storebound/ klasöründe (gitignore) tutulur, maskeli gösterilir. Değiştirmek için yazın.',
'ed.demoHint': 'Demo hesap yalnızca gizli .storebound/ klasöründe; pencere parolayı asla okumaz.',
'ed.game': 'Oyun',
'ed.app': 'Uygulama',
'ed.playSettingsTitle': 'Google Play mağaza ayarları',
'ed.playSettingsSub': 'Kategori, ülkeler, yönetilen yayınlama, hedef kitle, hesap silme sayfası.',
'ed.playCategory': 'Uygulama kategorisi',
'ed.noApiHint': 'API yok: konsol planı adımı bu değeri kullanır.',
'ed.managed': 'Yönetilen yayınlama',
'ed.countries': 'Üretim ülkeleri',
'ed.countriesHint': 'İki harfli kodlar (US, GB, TR). Boş: tüm ülkeler.',
'ed.targetAges': 'Hedef yaş grupları',
'ed.targetAgesHint': 'Şunlardan: {groups}.',
'ed.deletionUrl': 'Hesap silme sayfası',
'ed.declTitle': 'Veri güvenliği ve App Privacy yanıtları',
'ed.declSub': 'İki formun doldurulduğu yanıtlar. Bunları yalnızca siz bilirsiniz: uygulamanın ve SDK\'larının gerçekte yaptığını yazın.',
'ed.dsTypes': 'Veri türleri (Play)',
'ed.dsTypesHint': 'Play veri türü kimliğinden kullanıma: { "PSL_EMAIL": { "shared": false, "collect": ["account"] } }. Listede olmayan: toplanmıyor.',
'ed.apAnswers': 'App Privacy (App Store)',
'ed.apHint': 'Kaydederken tüm yapılandırmayla denetlenir; App Privacy\'nin API\'si yok (konsolda doldurma).',
'ed.promoTitle': 'App Store tanıtımı',
'ed.promoSub': 'Öne çıkan satın almalar, özel ürün sayfaları ve uygulama içi etkinlikler.',
'ed.promoted': 'Öne çıkan uygulama içi satın almalar',
'ed.promotedHint': 'Etkinleştirme terminal adımı olarak kalır (apple promoted --enable).',
'ed.cpp': 'Özel ürün sayfaları',
'ed.cppHint': 'Sayfayı gönderme ve görünür yapma terminal adımları olarak kalır.',
'ed.events': 'Uygulama içi etkinlikler',
'ed.eventsHint': 'Yerel ayar başına ad 30, kısa 50, uzun 120 karakter. İncelemeye gönderme terminal adımı olarak kalır.',
'ed.applyCpp': 'Özel ürün sayfaları: önizle',
'ed.applyEvents': 'Uygulama içi etkinlikler: önizle',
'ed.applyInfoSub': 'Uygulama bilgileri, App Review, ihracat uyumluluğu, Veri güvenliği ve Play uygulama ayrıntıları.',
'ed.applyPlayDetails': 'Play iletişim e-postası ve web sitesi: önizle',
'ed.playConsoleOnly': 'Kategori, ülkeler ve yönetilen yayınlamanın API\'si yok: Uygulama bilgileri > Konsol adımları.',
'ed.itemName': 'Ad',
'ed.itemDescription': 'Açıklama',
'ed.benefits': 'Avantajlar (Play)',
'ed.benefitsHint': 'Virgülle ayrılmış, en fazla 4, her biri 40 karakter.',
'ed.usd': 'Taban fiyat (USD)',
'ed.usdHint': 'Canlı bir ürünün yeni fiyatı kararınızı bekler (Fiyat değişikliklerini uygula).',
'ed.refName': 'Referans adı',
'ed.reviewNote': 'İnceleme notu',
'ed.reviewShot': 'İnceleme ekran görüntüsü',
'ed.family': 'Aile Paylaşımı',
'ed.offerCodes': 'Teklif kodları',
'ed.offerCodesHint': 'Kod oluşturma terminal adımı olarak kalır (kupon gibi çalışırlar).',
'ed.level': 'Gruptaki düzey',
'ed.levelHint': '1 = en yüksek hizmet düzeyi.',
'ed.period': 'Dönem',
'ed.periodHint': 'Abonelik onaylandıktan sonra değiştirilemez.',
'ed.intro': 'Tanıtım teklifi',
'ed.introHint': 'Örneğin { "type": "free", "duration": "P1W" }.',
'ed.offers': 'Promosyon teklifleri',
'ed.winBack': 'Geri kazanma teklifleri',
'ed.winBackHint': 'Oluşturma terminal adımı olarak kalır: başlangıç tarihinde kendiliğinden yayına girer.',
'ed.catalogTitle': 'Kataloğu düzenle',
'ed.catalogEmpty': 'Yapılandırmada ürün veya abonelik yok',
'ed.catalogEmptyText': 'store-iap-setup veya store-subscriptions becerisiyle ekleyin.',
'ed.productsTitle': 'Uygulama içi ürünler',
'ed.productsSub': 'Dil başına adlar, açıklamalar, fiyat ve inceleme ayrıntıları.',
'ed.groupTitle': 'Abonelik grubu {name}',
'ed.groupSub': 'Dil başına grup görünen adı ve her abonelik.',
'ed.groupName': 'Grup görünen adı',
'ed.applyCatalogSub': 'Her mağaza: pencerede ürün başına deneme çalıştırması, sonra onay penceresi.',
'ed.applyPlayProducts': 'Play ürünleri: önizle',
'ed.applyPlaySubs': 'Play abonelikleri: önizle',
'ed.applyAppleIap': 'App Store uygulama içi satın almalar: önizle',
'ed.applyAppleSubs': 'App Store abonelikleri: önizle',
'ed.applyIapReview': 'İnceleme ekran görüntüleri: önizle',
'ed.priceGate': 'Canlı ürünlerin fiyat değişiklikleri burada asla uygulanmaz: sahip eyleminde kararınızı bekler.',
'ed.tfTitle': 'TestFlight bilgilerini düzenle',
'ed.tfSub': 'Yerel ayar başına Neyi Test Etmeli, beta açıklaması, Beta App Review notları ve gruplar.',
'ed.whatToTest': 'Neyi Test Etmeli',
'ed.whatToTestHint': 'Boş: Yenilikler metni kullanılır.',
'ed.betaDescription': 'Beta Uygulama Açıklaması',
'ed.betaDescriptionAll': 'Beta Uygulama Açıklaması (tüm yerel ayarlar)',
'ed.betaNotes': 'Beta App Review notları',
'ed.tfGroups': 'Beta grupları',
'ed.tfGroupsHint': '[{ "name": "Stüdyo ekibi", "internal": true }]. Mevcut gruplar asla değiştirilmez veya silinmez.',
'ed.applyTfSub': 'Test bilgileri ve eksik gruplar. Harici test ve Beta App Review terminal adımları olarak kalır.',
'ed.applyTfInfo': 'Test bilgileri: önizle',
'ed.artTitle': 'Ekran görüntüsü sırası ve kareler',
'ed.artSub': 'Mağazalar ekran görüntülerini bu sırayla gösterir. Görüntüsünü değiştirmek için bir sahneyi başka bir kareye yönlendirin.',
'ed.artEmpty': 'Yapılandırmada ekran görüntüsü sahnesi yok',
'ed.artEmptyText': 'shots ayarını store-screenshots becerisiyle yapın.',
'ed.frame': 'Kare',
'ed.up': 'Yukarı',
'ed.down': 'Aşağı',
'ed.moveUp': '{id} sahnesini yukarı taşı',
'ed.moveDown': '{id} sahnesini aşağı taşı',
'ed.applyArtSub': 'Oluştur, sonra yükle: yükleme mağazadaki ekran görüntüsü setinin yerini alır (önce deneme çalıştırması).',
'ed.loadFailed': 'Uygulama klasörünün dosyaları okunamadı: {error}',
};

View file

@ -0,0 +1,107 @@
// Unsaved edits of the window's editors, per app: one pending value per field (a config path, a listing field of one
// store and language, an App Review contact field), with undo / redo. Kept for the whole session, outside the tabs,
// so switching tabs or apps never loses them; the tab bar marks apps and tabs with unsaved edits, and closing the
// window asks first (beforeunload). Saving turns them into the ops of `storebound edit` (project.edit). Pure apart
// from the subscriber list (tests/edits.test.ts).
export type EditOp =
| { kind: 'config'; path: string; value?: unknown; remove?: boolean }
| { kind: 'listing'; store: 'play' | 'apple'; lang: string; field: string; value: string }
| { kind: 'contact'; field: 'firstName' | 'lastName' | 'phone' | 'email'; value: string };
/** Which editor (tab) a pending edit belongs to, for the unsaved marks. */
export type EditArea = 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art';
export interface Pending { key: string; op: EditOp; area: EditArea; /** The saved value, to show "was" and to drop an edit typed back to it. */ saved: unknown }
export const keyOf = (op: EditOp): string =>
op.kind === 'config' ? `config:${op.path}` : op.kind === 'listing' ? `listing:${op.store}:${op.lang}:${op.field}` : `contact:${op.field}`;
const valueOf = (op: EditOp): unknown => (op.kind === 'config' ? (op.remove ? undefined : op.value) : op.value);
const same = (a: unknown, b: unknown): boolean => {
const n = (v: unknown) => (v === undefined || v === null || v === '' ? null : v);
return JSON.stringify(n(a)) === JSON.stringify(n(b));
};
interface ProjectEdits { pending: Map<string, Pending>; undo: Map<string, Pending>[]; redo: Map<string, Pending>[]; lastKey: string | null; lastAt: number }
/** Typing into one field within this time is one undo step. */
export const COALESCE_MS = 1200;
export class EditStore {
private byProject = new Map<string, ProjectEdits>();
private subs = new Set<() => void>();
private version = 0;
constructor(private now: () => number = () => Date.now()) {}
private of(project: string): ProjectEdits {
let p = this.byProject.get(project);
if (!p) { p = { pending: new Map(), undo: [], redo: [], lastKey: null, lastAt: 0 }; this.byProject.set(project, p); }
return p;
}
private emit(): void { this.version++; for (const s of this.subs) s(); }
subscribe = (fn: () => void): (() => void) => { this.subs.add(fn); return () => { this.subs.delete(fn); }; };
snapshot = (): number => this.version;
/** Sets a field; a value equal to the saved one drops the pending edit. */
set(project: string, op: EditOp, area: EditArea, saved: unknown): void {
const p = this.of(project);
const key = keyOf(op);
const t = this.now();
if (!(p.lastKey === key && t - p.lastAt < COALESCE_MS)) { p.undo.push(new Map(p.pending)); if (p.undo.length > 200) p.undo.shift(); }
p.redo = [];
p.lastKey = key; p.lastAt = t;
if (same(valueOf(op), saved)) p.pending.delete(key);
else p.pending.set(key, { key, op, area, saved });
this.emit();
}
/** The pending value of a field, or `undefined` when it has none (use the saved value). */
get(project: string, key: string): Pending | undefined { return this.byProject.get(project)?.pending.get(key); }
list(project: string, area?: EditArea): Pending[] {
const all = [...(this.byProject.get(project)?.pending.values() ?? [])];
return area ? all.filter(p => p.area === area) : all;
}
count(project: string, area?: EditArea): number { return this.list(project, area).length; }
/** Every app with unsaved edits. */
dirtyProjects(): string[] { return [...this.byProject].filter(([, p]) => p.pending.size > 0).map(([k]) => k); }
canUndo(project: string): boolean { return (this.byProject.get(project)?.undo.length ?? 0) > 0; }
canRedo(project: string): boolean { return (this.byProject.get(project)?.redo.length ?? 0) > 0; }
undo(project: string): void {
const p = this.of(project);
const prev = p.undo.pop();
if (!prev) return;
p.redo.push(new Map(p.pending));
p.pending = prev; p.lastKey = null;
this.emit();
}
redo(project: string): void {
const p = this.of(project);
const next = p.redo.pop();
if (!next) return;
p.undo.push(new Map(p.pending));
p.pending = next; p.lastKey = null;
this.emit();
}
/** Drops the pending edits of one area (or all): after a save, or "Discard". Discard can be undone. */
clear(project: string, area?: EditArea, o: { undoable?: boolean } = {}): void {
const p = this.of(project);
if (o.undoable) p.undo.push(new Map(p.pending));
for (const [k, v] of p.pending) if (!area || v.area === area) p.pending.delete(k);
p.lastKey = null;
if (!o.undoable) { p.undo = []; p.redo = []; }
this.emit();
}
/** The ops of `storebound edit` for one area (or all), in a stable order. */
ops(project: string, area?: EditArea): EditOp[] { return this.list(project, area).sort((a, b) => a.key.localeCompare(b.key)).map(p => p.op); }
}
/** The window's one store of unsaved edits. */
export const edits = new EditStore();

View file

@ -15,6 +15,7 @@ export const TTL: Record<string, number> = {
'catalog.diff': 30 * MIN,
'prices.current': 30 * MIN,
'project.listings': 30 * MIN,
'project.editable': 30 * MIN,
'apple.ppo_status': 30 * MIN,
validate: 30 * MIN,
scan: 30 * MIN,
@ -38,7 +39,7 @@ export function isStale(method: string, at: string | null | undefined, now = Dat
export function lanesOf(method: string, o: { ios: boolean; android: boolean }): Lane[] {
if (method.startsWith('apple.')) return ['apple'];
if (method.startsWith('play.')) return ['play'];
if (['project.listings', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
if (['project.listings', 'project.editable', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
const l: Lane[] = [];
if (o.ios) l.push('apple');
if (o.android) l.push('play');

View file

@ -23,6 +23,8 @@ import { PricesTab } from './tabs/Prices';
import { ReleasesTab } from './tabs/Releases';
import { ReviewsTab } from './tabs/Reviews';
import { TestFlightTab } from './tabs/TestFlight';
import { EDIT_TABS, TabEditor, useUnsavedGuard } from './editors/TabEditor';
import { useEdits } from '../ui/Editor';
export const ascUrl = (appId: string | null | undefined): string => (appId ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(appId)}/distribution` : 'https://appstoreconnect.apple.com/apps');
export const PLAY_CONSOLE = 'https://play.google.com/console/';
@ -99,6 +101,8 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
const android = e?.app?.platforms.android ?? (app.android ? { packageName: app.android.app.id } : null);
const name = (app.project && e?.app?.name) || app.name;
const tabs = tabsFor(!!app.project);
const ed = useEdits();
useUnsavedGuard();
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const studio = d.studios.find(s => s.id === app.studio) ?? null;
const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x });
@ -146,6 +150,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
{tabs.map((x, i) => (
<button key={x} ref={el => { tabRefs.current[i] = el; }} role="tab" id={`tab-${x}`} aria-selected={tab === x} aria-controls="tabpanel" tabIndex={tab === x ? 0 : -1} className="tab" onClick={() => setTab(x)}>
<Icon name={TAB_ICON[x]} />{t(TAB_KEY[x])}
{app.project && EDIT_TABS[x] && ed.count(app.project.id, EDIT_TABS[x]) > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: ed.count(app.project.id, EDIT_TABS[x]) })} />}
</button>
))}
</div>
@ -154,7 +159,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
{tab === 'releases' && <ReleasesTab app={app} />}
{tab === 'catalog' && app.project && <CatalogTab project={app.project} />}
{tab === 'prices' && app.project && <PricesTab project={app.project} />}
{tab === 'listings' && app.project && <ListingsTab project={app.project} />}
{tab === 'listings' && app.project && <><TabEditor tab={tab} project={app.project} /><ListingsTab project={app.project} /></>}
{tab === 'art' && app.project && <ArtTab project={app.project} />}
{tab === 'health' && <HealthTab app={app} />}
{tab === 'reviews' && <ReviewsTab app={app} />}
@ -162,6 +167,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
{tab === 'growth' && <GrowthTab app={app} />}
{tab === 'appinfo' && app.project && <AppInfoTab project={app.project} />}
{tab === 'checks' && app.project && <ChecksTab project={app.project} />}
{tab !== 'listings' && app.project && !app.project.missing && EDIT_TABS[tab] && <TabEditor tab={tab} project={app.project} />}
</ErrorBoundary>
</div>
</>}

View file

@ -0,0 +1,152 @@
// App info editors (App info tab): App Store categories, age rating answers, content rights, price, export
// compliance, App Review (sign-in, notes, contact; the demo account stays masked and is never read out); Google Play
// store settings (category, countries, managed publishing, target ages, delete-account page); Data safety and App
// Privacy answers; the App Store promotion blocks (promoted purchases, custom product pages, in-app events). Saved
// into the config (and the review contact into the private file in .storebound/), then applied per store with the dry run first.
import type { EditableResult, Project } from '../../api';
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';
type O = Record<string, unknown>;
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
const label = (id: string): string => id.replace(/^GAMES_/, '').toLowerCase().replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase());
/** "alcoholTobaccoOrDrugUseOrReferences" -> "Alcohol tobacco or drug use or references". */
const question = (q: string): string => q.replace(/([A-Z])/g, ' $1').toLowerCase().replace(/^\w/, c => c.toUpperCase());
const yesNo = (): [string, string][] => [['true', t('common.yes')], ['false', t('common.no')]];
function AppleInfo({ project, data }: { project: Project; data: EditableResult }) {
const apple = obj(data.config.apple);
const info = obj(apple.info);
const cats = obj(info.categories);
const age = obj(info.ageRating);
const ra = obj(data.config.reviewAccess);
const games = cats.primary === 'GAMES';
const catOpts = APPLE_CATEGORIES.map(c => [c, label(c)] as [string, string]);
const subOpts = APPLE_GAME_SUBCATEGORIES.map(c => [c, label(c)] as [string, string]);
const contact = data.contact;
return (
<>
<Card title={t('ed.appleInfoTitle')} icon="clipboard" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.appleInfoSub')}</span>}>
<div className="ed-grid">
<ChoiceField project={project} area="appinfo" path="apple.info.categories.primary" saved={str(cats.primary)} label={t('ed.catPrimary')} options={catOpts} />
{games && <ChoiceField project={project} area="appinfo" path="apple.info.categories.primarySubOne" saved={str(cats.primarySubOne)} label={t('ed.catSub1')} options={subOpts} />}
{games && <ChoiceField project={project} area="appinfo" path="apple.info.categories.primarySubTwo" saved={str(cats.primarySubTwo)} label={t('ed.catSub2')} options={subOpts} />}
<ChoiceField project={project} area="appinfo" path="apple.info.categories.secondary" saved={str(cats.secondary)} label={t('ed.catSecondary')} options={catOpts} />
<ChoiceField project={project} area="appinfo" path="apple.info.contentRights" saved={str(info.contentRights)} label={t('ai.rights')}
options={[['DOES_NOT_USE_THIRD_PARTY_CONTENT', t('ai.rightsOwn')], ['USES_THIRD_PARTY_CONTENT', t('ai.rightsThird')]]} />
<ChoiceField project={project} area="appinfo" path="apple.info.free" saved={info.free === undefined ? null : (info.free as boolean)} label={t('ed.free')} options={[['true', t('ai.free')], ['false', t('ai.paid')]]} hint={t('ed.freeHint')} />
<ChoiceField project={project} area="appinfo" path="apple.usesNonExemptEncryption" saved={apple.usesNonExemptEncryption as boolean | undefined} label={t('ai.encTitle')}
options={[['false', t('ai.encNo')], ['true', t('ai.encYes')]]} hint={t('ed.encHint')} />
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'apple.name' }} saved={str(apple.name)} label={t('ed.appleName')} limit={30} placeholder={data.appName} hint={t('ed.appleNameHint')} />
</div>
</Card>
<Card title={t('ed.ageTitle')} icon="shield" sub={t('ed.ageSub')}>
<div className="ed-grid three">
{Object.entries(AGE_RATING_NONE).map(([q, none]) => (
<ChoiceField key={q} project={project} area="appinfo" path={`apple.info.ageRating.${q}`} saved={age[q] === undefined ? null : (age[q] as string | boolean | null)} label={question(q)}
options={q === 'kidsAgeBand' ? KIDS_AGE_BANDS.map(b => [b, label(b)] as [string, string]) : typeof none === 'boolean' ? yesNo() : AGE_FREQUENCY.map(f => [f, label(f)] as [string, string])}
hint={age[q] === undefined ? t('ed.ageDefault', { value: none === null ? t('ed.none') : typeof none === 'boolean' ? t('common.no') : label(String(none)) }) : undefined} />
))}
</div>
</Card>
<Card title={t('ai.reviewTitle')} icon="users" sub={t('ed.reviewSub')}>
<div className="ed-grid">
<ChoiceField project={project} area="appinfo" path="reviewAccess.signInRequired" saved={ra.signInRequired === undefined ? null : (ra.signInRequired as boolean)} label={t('ai.signIn')} options={yesNo()} />
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'apple.info.reviewNotes' }} saved={str(info.reviewNotes)} label={t('ai.notes')} multiline rows={5}
hint={t('ed.notesHint', { max: Number(data.limits.reviewNotesBytes) })} />
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'reviewAccess.instructions' }} saved={str(ra.instructions)} label={t('ed.signInSteps')} multiline rows={3} />
{(['firstName', 'lastName', 'phone', 'email'] as const).map(f => (
<TextField key={f} project={project} area="appinfo" op={{ kind: 'contact', field: f }} saved="" label={t(`ed.contact_${f}` as Key)} kind={f === 'email' ? 'email' : undefined}
placeholder={contact?.[f] || t('ed.notSet')} hint={f === 'email' ? t('ed.contactHint') : undefined} />
))}
<div className="ed-field"><div className="ed-head"><label>{t('ai.demo')}</label></div><span className="mono-id">{data.demoAccount ? `${data.demoAccount} · ••••••••` : t('ed.notSet')}</span><div className="ed-hint small muted">{t('ed.demoHint')}</div></div>
</div>
</Card>
</>
);
}
function PlaySettings({ project, data }: { project: Project; data: EditableResult }) {
const play = obj(data.config.play);
const acc = obj(data.config.accounts);
const cats: [string, string][] = [...PLAY_GAME_CATEGORIES.map(c => [c, `${t('ed.game')}: ${c}`] as [string, string]), ...PLAY_APP_CATEGORIES.map(c => [c, `${t('ed.app')}: ${c}`] as [string, string])];
return (
<Card title={t('ed.playSettingsTitle')} icon="sliders" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('ed.playSettingsSub')}</span>}>
<div className="ed-grid">
<ChoiceField project={project} area="appinfo" path="play.category" saved={str(play.category)} label={t('ed.playCategory')} options={cats} hint={t('ed.noApiHint')} />
<ChoiceField project={project} area="appinfo" path="play.managedPublishing" saved={play.managedPublishing === undefined ? null : (play.managedPublishing as boolean)} label={t('ed.managed')} options={yesNo()} hint={t('ed.noApiHint')} />
<ListField project={project} area="appinfo" path="play.countries" saved={(play.countries as string[] | undefined) ?? null} label={t('ed.countries')} upper hint={t('ed.countriesHint')} />
<ListField project={project} area="appinfo" path="play.targetAges" saved={(play.targetAges as string[] | undefined) ?? null} label={t('ed.targetAges')} hint={t('ed.targetAgesHint', { groups: PLAY_AGE_GROUPS.join(', ') })} />
<TextField project={project} area="appinfo" op={{ kind: 'config', path: 'accounts.deletionUrl' }} saved={str(acc.deletionUrl)} label={t('ed.deletionUrl')} kind="url" mono />
</div>
</Card>
);
}
function Declarations({ project, data }: { project: Project; data: EditableResult }) {
const ds = obj(data.config.dataSafety);
return (
<Card title={t('ed.declTitle')} icon="lock" sub={t('ed.declSub')}>
<div className="ed-grid">
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.collectsData" saved={ds.collectsData === undefined ? null : (ds.collectsData as boolean)} label={t('ai.dsCollects')} options={yesNo()} />}
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.encryptedInTransit" saved={ds.encryptedInTransit === undefined ? null : (ds.encryptedInTransit as boolean)} label={t('ai.dsEncrypted')} options={yesNo()} />}
{data.play && <ChoiceField project={project} area="appinfo" path="dataSafety.dataDeletion" saved={str(ds.dataDeletion)} label={t('ai.dsDeletion')} options={[['yes', t('common.yes')], ['no', t('common.no')], ['auto90', t('ai.dsAuto90')]]} />}
</div>
{data.play && <JsonField project={project} area="appinfo" path="dataSafety.data" saved={ds.data ?? null} label={t('ed.dsTypes')} hint={t('ed.dsTypesHint')} />}
{data.apple && <JsonField project={project} area="appinfo" path="appPrivacy" saved={data.config.appPrivacy} label={t('ed.apAnswers')} hint={t('ed.apHint')} />}
</Card>
);
}
function Promotion({ project, data }: { project: Project; data: EditableResult }) {
const apple = obj(data.config.apple);
return (
<Card title={t('ed.promoTitle')} icon="sparkle" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{t('ed.promoSub')}</span>}>
<JsonField project={project} area="appinfo" path="apple.promoted" saved={apple.promoted ?? null} label={t('ed.promoted')} rows={6} hint={t('ed.promotedHint')} />
<JsonField project={project} area="appinfo" path="apple.customProductPages" saved={apple.customProductPages ?? null} label={t('ed.cpp')} rows={8} hint={t('ed.cppHint')} />
<JsonField project={project} area="appinfo" path="apple.events" saved={apple.events ?? null} label={t('ed.events')} rows={8} hint={t('ed.eventsHint')} />
<div className="act-grid">
<ApplyPanel project={project} method="apple.cpp_create" label={t('ed.applyCpp')} done={t('ai.pushed')} blocked={null} />
<ApplyPanel project={project} method="apple.events_create" label={t('ed.applyEvents')} done={t('ai.pushed')} blocked={null} />
</div>
</Card>
);
}
export function AppInfoEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const unsaved = e.count(project.id, 'appinfo') ? t('ed.saveFirst') : null;
return (
<div className="stack" id="appinfo-editor" style={{ gap: 16 }}>
{data.apple && <AppleInfo project={project} data={data} />}
{data.play && <PlaySettings project={project} data={data} />}
<Declarations project={project} data={data} />
{data.apple && <Promotion project={project} data={data} />}
<EditBar project={project} area="appinfo" data={data} files={[data.files.config, data.files.contact]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyInfoSub')}>
<div className="act-grid">
{data.apple && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
<ApplyPanel project={project} method="apple.appinfo" label={t('ai.infoPush')} done={t('ai.pushed')} blocked={unsaved} />
<ApplyPanel project={project} method="apple.review_details" label={t('ai.reviewPush')} done={t('ai.pushed')} blocked={unsaved} />
<ApplyPanel project={project} method="apple.encryption" label={t('ai.encPush')} done={t('ai.pushed')} blocked={unsaved} />
</div>
)}
{data.play && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
<ApplyPanel project={project} method="play.data_safety" label={t('ai.dsUpload')} done={t('ai.dsUploaded')} blocked={unsaved} />
<ApplyPanel project={project} method="play.listing" label={t('ed.applyPlayDetails')} done={t('act.playListingDone')} blocked={unsaved} />
<span className="small muted">{t('ed.playConsoleOnly')}</span>
</div>
)}
</div>
</Card>
</div>
);
}

View file

@ -0,0 +1,57 @@
// 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 type { EditableResult, Project } from '../../api';
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 { keyOf } from '../../lib/edits';
type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Record<string, unknown>;
export function ArtEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const saved = ((data.config as unknown as { shots?: { scenes?: Scene[] } | null }).shots?.scenes ?? []) as Scene[];
const op = { kind: 'config' as const, path: 'shots.scenes' };
const p = e.get(project.id, keyOf(op));
const scenes = (p && p.op.kind === 'config' ? p.op.value : saved) as Scene[];
const unsaved = e.count(project.id, 'art') ? t('ed.saveFirst') : null;
if (!saved.length) return <Card title={t('ed.artTitle')} icon="image"><Empty compact illo="text" title={t('ed.artEmpty')}>{t('ed.artEmptyText')}</Empty></Card>;
const move = (i: number, d: -1 | 1) => {
const next = [...scenes];
const j = i + d;
if (j < 0 || j >= next.length) return;
[next[i], next[j]] = [next[j]!, next[i]!];
e.set(project.id, { ...op, value: next }, 'art', saved);
};
return (
<div className="stack" id="art-editor" style={{ gap: 16 }}>
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}>
<ol className="ed-scenes">
{scenes.map((s, i) => (
<li key={s.id} data-scene={s.id}>
<span className="num strong">{i + 1}</span>
<span className="mono-id strong">{s.id}</span>
<div className="ed-scene-frame">
<TextField project={project} area="art" op={{ kind: 'config', path: `shots.scenes[id=${s.id}].frames[0].src` }} saved={saved.find(x => x.id === s.id)?.frames?.[0]?.src ?? ''} label={t('ed.frame')} mono />
</div>
<div className="row" style={{ gap: 4 }}>
<button className="btn btn-xs btn-ghost" disabled={i === 0} onClick={() => move(i, -1)} aria-label={t('ed.moveUp', { id: s.id })}><Icon name="arrowLeft" className="rot90" />{t('ed.up')}</button>
<button className="btn btn-xs btn-ghost" disabled={i === scenes.length - 1} onClick={() => move(i, 1)} aria-label={t('ed.moveDown', { id: s.id })}><Icon name="arrowRight" className="rot90" />{t('ed.down')}</button>
</div>
</li>
))}
</ol>
</Card>
<EditBar project={project} area="art" data={data} files={[data.files.config]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyArtSub')}>
<div className="act-grid">
<div className="act-col"><div className="act-head"><Icon name="wand" />{t('act.render')}</div><ApplyPanel project={project} method="shots.render" label={t('act.renderShots')} done={t('act.rendered')} blocked={unsaved} /></div>
{data.play && <div className="act-col"><div className="act-head"><StoreGlyph store="android" />Google Play</div><ApplyPanel project={project} method="play.images" label={t('act.playImages')} done={t('act.uploaded')} blocked={unsaved} /></div>}
{data.apple && <div className="act-col"><div className="act-head"><StoreGlyph store="ios" />App Store</div><ApplyPanel project={project} method="apple.screenshots" label={t('act.appleShots')} done={t('act.uploaded')} blocked={unsaved} /><ApplyPanel project={project} method="apple.previews" label={t('act.applePreviews')} done={t('act.uploaded')} blocked={unsaved} /></div>}
</div>
</Card>
</div>
);
}

View file

@ -0,0 +1,148 @@
// Catalog editor (Catalog tab): every in-app product and subscription of the config with its names and descriptions
// per language (live counters against the store limits), base price in USD, reference name, review note, and the
// 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 { 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';
type O = Record<string, unknown>;
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
const PERIODS: [string, string][] = [['P1W', '1 week'], ['P1M', '1 month'], ['P2M', '2 months'], ['P3M', '3 months'], ['P6M', '6 months'], ['P1Y', '1 year']];
function Listings({ project, data, base, listings, limits, benefits }: { project: Project; data: EditableResult; base: string; listings: O; limits: { title: number; description: number }; benefits?: boolean }) {
return (
<div className="ed-langs">
{data.languages.map(l => {
const x = obj(listings[l.id]);
return (
<div className="ed-lang" key={l.id}>
<div className="small strong mono-id">{l.id}</div>
<div className="ed-grid">
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.listings.${l.id}.${'name' in x && !('title' in x) ? 'name' : 'title'}` }} saved={str(x.title ?? x.name)} label={t('ed.itemName')} limit={limits.title} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.listings.${l.id}.description` }} saved={str(x.description)} label={t('ed.itemDescription')} limit={limits.description} />
{benefits && <ListField project={project} area="catalog" path={`${base}.listings.${l.id}.benefits`} saved={(x.benefits as string[] | undefined) ?? null} label={t('ed.benefits')} hint={t('ed.benefitsHint')} />}
</div>
</div>
);
})}
</div>
);
}
function ProductCard({ project, data, p }: { project: Project; data: EditableResult; p: O }) {
const [open, setOpen] = useState(false);
const base = `products[id=${String(p.id)}]`;
const lim = data.limits.iap as Record<string, number>;
const e = useEdits();
const n = e.list(project.id, 'catalog').filter(x => x.key.startsWith(`config:${base}`)).length;
return (
<div className="ed-item" data-item={String(p.id)}>
<button className="ed-item-head" onClick={() => setOpen(o => !o)} aria-expanded={open}>
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="mono-id strong">{String(p.id)}</span><Chip tone="outline">{String(p.type)}</Chip>
<span className="num small muted">${String(p.usd)}</span>{n > 0 && <Chip tone="accent" icon="edit">{t('ed.unsaved', { count: n })}</Chip>}
</button>
{open && (
<div className="ed-item-body stack">
<div className="ed-grid">
<NumberField project={project} area="catalog" path={`${base}.usd`} saved={p.usd as number} label={t('ed.usd')} min={0} hint={t('ed.usdHint')} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.referenceName` }} saved={str(p.referenceName)} label={t('ed.refName')} limit={64} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.reviewNote` }} saved={str(p.reviewNote)} label={t('ed.reviewNote')} multiline rows={2} limit={4000} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.reviewScreenshot` }} saved={str(p.reviewScreenshot)} label={t('ed.reviewShot')} mono />
<ChoiceField project={project} area="catalog" path={`${base}.familySharable`} saved={p.familySharable === undefined ? null : (p.familySharable as boolean)} label={t('ed.family')} options={[['true', t('common.yes')], ['false', t('common.no')]]} />
</div>
<Listings project={project} data={data} base={base} listings={obj(p.listings)} limits={{ title: lim.name ?? 30, description: lim.description ?? 45 }} />
<JsonField project={project} area="catalog" path={`${base}.offerCodes`} saved={p.offerCodes ?? null} label={t('ed.offerCodes')} rows={5} hint={t('ed.offerCodesHint')} />
</div>
)}
</div>
);
}
function SubCard({ project, data, group, s }: { project: Project; data: EditableResult; group: string; s: O }) {
const [open, setOpen] = useState(false);
const base = `subscriptions[name=${group}].subscriptions[id=${String(s.id)}]`;
const lim = data.limits.appleSub as Record<string, number>;
return (
<div className="ed-item" data-item={String(s.id)}>
<button className="ed-item-head" onClick={() => setOpen(o => !o)} aria-expanded={open}>
<Icon name={open ? 'chevronDown' : 'chevronRight'} /><span className="mono-id strong">{String(s.id)}</span><Chip tone="outline">{String(s.period)}</Chip><span className="num small muted">${String(s.usd)}</span>
</button>
{open && (
<div className="ed-item-body stack">
<div className="ed-grid">
<NumberField project={project} area="catalog" path={`${base}.usd`} saved={s.usd as number} label={t('ed.usd')} min={0} hint={t('ed.usdHint')} />
<NumberField project={project} area="catalog" path={`${base}.level`} saved={s.level as number} label={t('ed.level')} min={1} step="1" hint={t('ed.levelHint')} />
<ChoiceField project={project} area="catalog" path={`${base}.period`} saved={str(s.period)} label={t('ed.period')} options={PERIODS} allowEmpty={false} hint={t('ed.periodHint')} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.referenceName` }} saved={str(s.referenceName)} label={t('ed.refName')} limit={64} />
<TextField project={project} area="catalog" op={{ kind: 'config', path: `${base}.reviewNote` }} saved={str(s.reviewNote)} label={t('ed.reviewNote')} multiline rows={2} limit={4000} />
</div>
<Listings project={project} data={data} base={base} listings={obj(s.listings)} limits={{ title: lim.name ?? 30, description: lim.description ?? 45 }} benefits />
<JsonField project={project} area="catalog" path={`${base}.intro`} saved={s.intro ?? null} label={t('ed.intro')} rows={4} hint={t('ed.introHint')} />
<JsonField project={project} area="catalog" path={`${base}.offers`} saved={s.offers ?? null} label={t('ed.offers')} rows={5} />
<JsonField project={project} area="catalog" path={`${base}.offerCodes`} saved={s.offerCodes ?? null} label={t('ed.offerCodes')} rows={5} hint={t('ed.offerCodesHint')} />
<JsonField project={project} area="catalog" path={`${base}.winBack`} saved={s.winBack ?? null} label={t('ed.winBack')} rows={5} hint={t('ed.winBackHint')} />
</div>
)}
</div>
);
}
export function CatalogEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const products = data.config.products as O[];
const groups = data.config.subscriptions as O[];
const unsaved = e.count(project.id, 'catalog') ? t('ed.saveFirst') : null;
if (!products.length && !groups.length) {
return <Card title={t('ed.catalogTitle')} icon="edit"><Empty compact illo="catalog" title={t('ed.catalogEmpty')}>{t('ed.catalogEmptyText')}</Empty></Card>;
}
return (
<div className="stack" id="catalog-editor" style={{ gap: 16 }}>
{products.length > 0 && (
<Card title={t('ed.productsTitle')} icon="edit" sub={t('ed.productsSub')} pad={false}>
<div className="ed-items">{products.map(p => <ProductCard key={String(p.id)} project={project} data={data} p={p} />)}</div>
</Card>
)}
{groups.map(g => (
<Card key={String(g.name)} title={t('ed.groupTitle', { name: String(g.name) })} icon="layers" sub={t('ed.groupSub')} pad={false}>
<div className="card-body">
<div className="ed-langs">
{data.languages.map(l => (
<div className="ed-lang" key={l.id}><div className="small strong mono-id">{l.id}</div>
<TextField project={project} area="catalog" op={{ kind: 'config', path: `subscriptions[name=${String(g.name)}].listings.${l.id}.name` }} saved={str(obj(obj(g.listings)[l.id]).name)} label={t('ed.groupName')} limit={30} />
</div>
))}
</div>
</div>
<div className="ed-items">{(g.subscriptions as O[]).map(s => <SubCard key={String(s.id)} project={project} data={data} group={String(g.name)} s={s} />)}</div>
</Card>
))}
<EditBar project={project} area="catalog" data={data} files={[data.files.config]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyCatalogSub')}>
<div className="act-grid">
{data.play && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
{products.length > 0 && <ApplyPanel project={project} method="play.products" label={t('ed.applyPlayProducts')} done={t('ai.pushed')} blocked={unsaved} />}
{groups.length > 0 && <ApplyPanel project={project} method="play.subscriptions" label={t('ed.applyPlaySubs')} done={t('ai.pushed')} blocked={unsaved} />}
</div>
)}
{data.apple && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
{products.length > 0 && <ApplyPanel project={project} method="apple.iap" label={t('ed.applyAppleIap')} done={t('ai.pushed')} blocked={unsaved} />}
{groups.length > 0 && <ApplyPanel project={project} method="apple.subscriptions" label={t('ed.applyAppleSubs')} done={t('ai.pushed')} blocked={unsaved} />}
{products.length > 0 && <ApplyPanel project={project} method="apple.iap_review" label={t('ed.applyIapReview')} done={t('act.uploaded')} blocked={unsaved} />}
</div>
)}
</div>
<p className="small muted act-note"><Icon name="shield" />{t('ed.priceGate')}</p>
</Card>
</div>
);
}

View file

@ -0,0 +1,163 @@
// The listing editor (Listings tab): per language, the Google Play and App Store texts side by side with live
// 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 { useState } from 'react';
import type { EditableListing, EditableResult, Project } from '../../api';
import { type Key, t } from '../../i18n';
import { appleLocaleIssues, playLangIssues, type FieldIssue } from '../../../../../cli/lib/listing-rules';
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 { WriteButton } from '../../ui/WriteButton';
const PLAY: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [
{ f: 'title', label: 'lst.title' }, { f: 'short', label: 'lst.short' }, { f: 'full', label: 'lst.full', multiline: true, rows: 10 }, { f: 'whatsNew', label: 'lst.whatsNew', multiline: true, rows: 4 },
];
const APPLE: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [
{ f: 'name', label: 'lst.name' }, { f: 'subtitle', label: 'lst.subtitle' }, { f: 'promotionalText', label: 'lst.promo', multiline: true, rows: 3 },
{ f: 'keywords', label: 'lst.keywords', mono: true }, { f: 'description', label: 'lst.description', multiline: true, rows: 10 }, { f: 'whatsNew', label: 'lst.whatsNew', multiline: true, rows: 4 },
];
/** The values of one listing language with the unsaved edits on top. */
function current(e: ReturnType<typeof useEdits>, project: Project, store: 'play' | 'apple', l: EditableListing, fields: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const f of fields) {
const p = e.get(project.id, keyOf({ kind: 'listing', store, lang: l.lang, field: f, value: '' }));
out[f] = p && p.op.kind === 'listing' ? p.op.value : l.values[f] ?? '';
}
return out;
}
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 }) {
const e = useEdits();
const defs = store === 'play' ? PLAY : APPLE;
const lim = (data.limits[store] ?? {}) as Record<string, number>;
const vals = current(e, project, store, l, defs.map(d => d.f));
const issues = store === 'play'
? playLangIssues(l.lang, { title: vals.title || undefined, short: vals.short, full: vals.full, whatsNew: vals.whatsNew }, data.appName)
: appleLocaleIssues(l.lang, Object.fromEntries(Object.entries(vals).filter(([, v]) => v)), data.appleName);
// a new section starts empty: say what is required instead of a wall of errors
const fresh = !l.exists && Object.values(vals).every(v => !v);
return (
<div className="ed-col" data-store={store} data-lang={l.lang}>
<div className="act-head"><StoreGlyph store={store === 'play' ? 'android' : 'ios'} />{store === 'play' ? 'Google Play' : 'App Store'}<span className="mono-id small muted">{l.lang}</span>
{l.status && <Chip tone="warn">{l.status === 'draft' ? t('ed.statusDraft') : t('ed.statusTranslate')}</Chip>}
{(l.codes?.length ?? 0) > 1 && <Chip tone="outline">{t('ed.alsoServes', { codes: l.codes!.filter(c => c !== l.lang).join(', ') })}</Chip>}
</div>
{fresh && <Notice tone="info" icon="info">{t('ed.newSection', { lang: l.lang })}</Notice>}
{defs.map(d => (
<TextField key={d.f} project={project} area="listings" op={{ kind: 'listing', store, lang: l.lang, field: d.f }} saved={l.values[d.f] ?? ''}
label={t(d.label)} limit={lim[d.f]} multiline={d.multiline} rows={d.rows} mono={d.mono}
placeholder={d.f === 'title' ? data.appName : d.f === 'name' ? data.appleName : undefined}
hint={d.f === 'keywords' ? t('ed.keywordsHint') : d.f === 'whatsNew' && store === 'apple' ? t('ed.whatsNewApple') : d.f === 'title' || d.f === 'name' ? t('ed.nameHint') : undefined}
issues={fresh ? [] : byField(issues, d.f === 'title' && store === 'play' ? 'title' : d.f)} />
))}
</div>
);
}
const DETAILS: { path: string; label: Key; kind?: 'url' | 'email'; store?: 'play' | 'apple'; hint?: Key }[] = [
{ path: 'app.supportUrl', label: 'ed.supportUrl', kind: 'url' }, { path: 'app.marketingUrl', label: 'ed.marketingUrl', kind: 'url' },
{ path: 'app.privacyPolicyUrl', label: 'ed.privacyUrl', kind: 'url' }, { path: 'app.termsUrl', label: 'ed.termsUrl', kind: 'url', hint: 'ed.termsHint' },
{ path: 'app.website', label: 'ed.website', kind: 'url', hint: 'ed.websiteHint' }, { path: 'app.contactEmail', label: 'ed.contactEmail', kind: 'email' },
{ path: 'app.copyright', label: 'ed.copyright' }, { path: 'play.video', label: 'ed.video', kind: 'url', store: 'play', hint: 'ed.videoHint' },
];
export function StoreDetails({ project, data }: { project: Project; data: EditableResult }) {
const c = data.config;
const get = (p: string): string => {
const [a, b] = p.split('.') as [string, string];
const o = (c as unknown as Record<string, Record<string, unknown> | null>)[a];
return o && typeof o[b] === 'string' ? (o[b] as string) : '';
};
return (
<div className="ed-grid">
{DETAILS.filter(d => !d.store || (d.store === 'play' ? !!data.play : !!data.apple)).map(d => {
const v = get(d.path);
return <TextField key={d.path} project={project} area="listings" op={{ kind: 'config', path: d.path }} saved={v} label={t(d.label)} kind={d.kind} mono={!!d.kind}
hint={d.hint ? t(d.hint) : undefined} />;
})}
</div>
);
}
export function ListingEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const d = useData();
const e = useEdits();
const w = useWrite(project.id);
const live = d.liveOf(project);
const [lang, setLang] = useState(data.languages[0]?.id ?? '');
const [code, setCode] = useState<string | null>(null);
const [version, setVersion] = useState(live?.release.next?.ios?.version ?? live?.release.next?.version ?? '');
const L = data.languages.find(x => x.id === lang) ?? data.languages[0];
const playL = data.play?.filter(x => x.language === L?.id) ?? [];
const pl = playL.find(x => x.lang === code) ?? playL[0];
const al = data.apple?.find(x => x.language === L?.id);
const unsaved = e.count(project.id, 'listings');
const okVersion = /^\d+(\.\d+){0,2}$/.test(version);
const files = [data.files.play, data.files.apple, data.files.config];
return (
<>
<Card title={t('ed.listingTitle')} icon="edit" sub={t('ed.listingSub')} id="listing-editor"
actions={<>
{data.play && <WriteButton run={w} icon="download" w={{ method: 'play.listing_pull', args: {}, title: t('ed.pullPlay'), done: t('ed.pulled'), after: onSaved }} hint={unsaved ? t('ed.pullDirty') : t('ed.pullHint')}>{t('ed.pullPlay')}</WriteButton>}
{data.apple && <WriteButton run={w} icon="download" w={{ method: 'apple.listing_pull', args: {}, title: t('ed.pullApple'), done: t('ed.pulled'), after: onSaved }} hint={unsaved ? t('ed.pullDirty') : t('ed.pullHint')}>{t('ed.pullApple')}</WriteButton>}
</>}>
<div className="stack" style={{ gap: 12 }}>
{unsaved > 0 && <Notice tone="info" icon="info">{t('ed.pullNote')}</Notice>}
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }} role="tablist" aria-label={t('col.language')}>
{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 (
<button key={x.id} role="tab" aria-selected={x.id === L?.id} className={`btn btn-sm${x.id === L?.id ? ' btn-primary' : ''}`} onClick={() => { setLang(x.id); setCode(null); }} data-lang-tab={x.id}>
<Icon name="globe" />{x.id}{n > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: n })} />}
</button>
);
})}
</div>
{playL.length > 1 && (
<div className="row" style={{ gap: 6 }}>
<span className="small muted">{t('ed.playCode')}</span>
{playL.map(x => <button key={x.lang} className={`btn btn-xs${x.lang === pl?.lang ? ' btn-primary' : ' btn-ghost'}`} onClick={() => setCode(x.lang)}>{x.lang}</button>)}
</div>
)}
{L?.rtl && <Notice tone="info" icon="info">{t('ed.rtl')}</Notice>}
<div className="ed-cols" dir={L?.rtl ? 'rtl' : undefined}>
{pl && <StoreColumn project={project} data={data} store="play" l={pl} />}
{al && <StoreColumn project={project} data={data} store="apple" l={al} />}
{!pl && !al && <Notice tone="warn">{t('ed.noStoreForLang', { lang: L?.id ?? '' })}</Notice>}
</div>
</div>
</Card>
<Card title={t('ed.detailsTitle')} icon="link" sub={t('ed.detailsSub')}>
<StoreDetails project={project} data={data} />
</Card>
<EditBar project={project} area="listings" data={data} files={files} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applySub')}>
<div className="act-grid">
{data.play && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
<ApplyPanel project={project} method="play.listing" label={t('ed.applyPlay')} done={t('act.playListingDone')} blocked={unsaved ? t('ed.saveFirst') : null} />
</div>
)}
{data.apple && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
<div className="row" style={{ gap: 8 }}>
<label className="small muted" htmlFor="ed-version">{t('act.version')}</label>
<input id="ed-version" className="input input-sm mono" style={{ width: 90 }} value={version} onChange={ev => setVersion(ev.target.value.trim())} placeholder="2.4.0" />
</div>
<ApplyPanel project={project} method="apple.listing" args={{ version }} label={t('ed.applyApple', { version: version || '…' })} done={t('act.appleListingDone')} disabled={!okVersion} blocked={unsaved ? t('ed.saveFirst') : null} />
</div>
)}
</div>
<p className="small muted act-note"><Icon name="shield" />{t('ed.applyNote')}</p>
</Card>
</>
);
}

View file

@ -0,0 +1,47 @@
// The editor of an app tab (console parity: every value the stores' consoles let the owner edit and storebound can
// write is edited here): loads what the files hold once (project.editable) and shows the tab's editor.
import { useEffect } from 'react';
import type { Project } from '../../api';
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 { AppInfoEditor } from './AppInfoEditor';
import { ArtEditor } from './ArtEditor';
import { CatalogEditor } from './CatalogEditor';
import { ListingEditor } from './ListingEditor';
import { TestFlightEditor } from './TestFlightEditor';
/** Tabs with an editor, and the edit area each one keeps its unsaved edits in. */
export const EDIT_TABS: Partial<Record<AppTab, 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art'>> = {
listings: 'listings', appinfo: 'appinfo', catalog: 'catalog', testflight: 'testflight', art: 'art',
};
export function TabEditor({ tab, project }: { tab: AppTab; project: Project }) {
const r = useEditable(project);
if (!EDIT_TABS[tab]) return null;
if (!r.data) return r.error ? <ErrorNotice error={t('ed.loadFailed', { error: r.error })} tone="bad" /> : <Card><Skeleton lines={4} block /></Card>;
const p = { project, data: r.data, onSaved: () => { r.refresh(); } };
switch (tab) {
case 'listings': return <ListingEditor {...p} />;
case 'appinfo': return <AppInfoEditor {...p} />;
case 'catalog': return <CatalogEditor {...p} />;
case 'testflight': return <TestFlightEditor {...p} />;
case 'art': return <ArtEditor {...p} />;
default: return null;
}
}
/** Closing or reloading the window with unsaved edits asks first. */
export function useUnsavedGuard(): void {
useEffect(() => {
const on = (ev: BeforeUnloadEvent) => {
if (!edits.dirtyProjects().length) return;
ev.preventDefault();
ev.returnValue = '';
};
window.addEventListener('beforeunload', on);
return () => window.removeEventListener('beforeunload', on);
}, []);
}

View file

@ -0,0 +1,48 @@
// TestFlight editor (TestFlight tab): What to Test per locale (### whatToTest in the App Store listing file), the
// Beta App Description per language, notes for Beta App Review, and the beta groups (checked JSON). Saved into the
// files, then applied with the dry run in the window: test information, and creating the missing groups. Sending a
// build to external testers and Beta App Review stay the owner's terminal steps.
import type { EditableResult, Project } from '../../api';
import { t } from '../../i18n';
import { ApplyPanel, EditBar, JsonField, TextField, useEdits } from '../../ui/Editor';
import { Card } from '../../ui/kit';
type O = Record<string, unknown>;
const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {});
export function TestFlightEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const tf = obj(obj(data.config.apple).testflight);
const desc = tf.description;
const unsaved = e.count(project.id, 'testflight') ? t('ed.saveFirst') : null;
if (!data.apple) return null;
return (
<div className="stack" id="testflight-editor" style={{ gap: 16 }}>
<Card title={t('ed.tfTitle')} icon="edit" sub={t('ed.tfSub')}>
<div className="ed-langs">
{data.apple.map(l => (
<div className="ed-lang" key={l.lang}>
<div className="small strong mono-id">{l.lang}</div>
<TextField project={project} area="testflight" op={{ kind: 'listing', store: 'apple', lang: l.lang, field: 'whatToTest' }} saved={l.values.whatToTest ?? ''}
label={t('ed.whatToTest')} limit={4000} multiline rows={3} placeholder={l.values.whatsNew ?? ''} hint={l.values.whatToTest ? undefined : t('ed.whatToTestHint')} />
{typeof desc !== 'string' && (
<TextField project={project} area="testflight" op={{ kind: 'config', path: `apple.testflight.description.${l.language}` }} saved={typeof obj(desc)[l.language] === 'string' ? String(obj(desc)[l.language]) : ''}
label={t('ed.betaDescription')} limit={4000} multiline rows={3} />
)}
</div>
))}
</div>
{typeof desc === 'string' && <TextField project={project} area="testflight" op={{ kind: 'config', path: 'apple.testflight.description' }} saved={desc} label={t('ed.betaDescriptionAll')} limit={4000} multiline rows={3} />}
<TextField project={project} area="testflight" op={{ kind: 'config', path: 'apple.testflight.reviewNotes' }} saved={typeof tf.reviewNotes === 'string' ? tf.reviewNotes : ''} label={t('ed.betaNotes')} limit={4000} multiline rows={3} />
<JsonField project={project} area="testflight" path="apple.testflight.groups" saved={tf.groups ?? null} label={t('ed.tfGroups')} rows={6} hint={t('ed.tfGroupsHint')} />
</Card>
<EditBar project={project} area="testflight" data={data} files={[data.files.apple, data.files.config]} onSaved={onSaved} />
<Card title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyTfSub')}>
<div className="act-grid">
<ApplyPanel project={project} method="apple.testflight_info" label={t('ed.applyTfInfo')} done={t('ai.pushed')} blocked={unsaved} />
<ApplyPanel project={project} method="apple.testflight_groups" label={t('tf.groupsSync')} done={t('tf.groupsSynced')} blocked={unsaved} />
</div>
</Card>
</div>
);
}

View file

@ -61,17 +61,14 @@ function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: Listi
);
}
/** Push the listing text, upload the art, and render it (each a dry run, a native dialog, then the real run). */
/** Upload the art and render it (each a dry run, a native dialog, then the real run). The listing text is edited and
* applied in the listing editor above (screens/editors/ListingEditor.tsx). */
function StoreActions({ project, langs }: { project: Project; langs: string[] }) {
const d = useData();
const w = useWrite(project.id);
const l = d.liveOf(project);
const e = d.statusOf(project);
const suggested = l?.release.next?.ios?.version ?? l?.release.next?.version ?? '';
const [version, setVersion] = useState(suggested);
const [lang, setLang] = useState('');
const ios = !!e?.app?.platforms.ios, android = !!e?.app?.platforms.android;
const okVersion = /^\d+(\.\d+){0,2}$/.test(version);
const only = lang ? { lang } : {};
return (
<Card title={t('act.storeTitle')} icon="upload" sub={t('act.storeSub')}>
@ -79,18 +76,12 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] })
{android && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="android" />Google Play</div>
<WriteButton run={w} icon="text" w={{ method: 'play.listing', args: {}, title: t('act.playListing'), done: t('act.playListingDone') }}>{t('act.playListing')}</WriteButton>
<WriteButton run={w} icon="image" w={{ method: 'play.images', args: {}, title: t('act.playImages'), done: t('act.uploaded') }} hint={t('act.slowHint')}>{t('act.playImages')}</WriteButton>
</div>
)}
{ios && (
<div className="act-col">
<div className="act-head"><StoreGlyph store="ios" />App Store</div>
<div className="row" style={{ gap: 8 }}>
<label className="small muted" htmlFor="act-version">{t('act.version')}</label>
<input id="act-version" className="input input-sm mono" style={{ width: 90 }} value={version} onChange={ev => setVersion(ev.target.value.trim())} placeholder="2.4.0" />
</div>
<WriteButton run={w} icon="text" disabled={!okVersion} w={{ method: 'apple.listing', args: { version }, title: t('act.appleListing', { version }), done: t('act.appleListingDone') }}>{t('act.appleListing', { version: version || '…' })}</WriteButton>
<WriteButton run={w} icon="image" w={{ method: 'apple.screenshots', args: {}, title: t('act.appleShots'), done: t('act.uploaded') }} hint={t('act.slowHint')}>{t('act.appleShots')}</WriteButton>
<WriteButton run={w} icon="play" w={{ method: 'apple.previews', args: {}, title: t('act.applePreviews'), done: t('act.uploaded') }} hint={t('act.slowHint')}>{t('act.applePreviews')}</WriteButton>
</div>

View file

@ -957,3 +957,52 @@ button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-
.crash-msg, .crash-detail { margin: 0; padding: 10px 12px; border-radius: 8px; background: var(--bg-sunken); color: var(--ink); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; }
.crash-box summary { cursor: pointer; color: var(--ink-2); font-size: 13px; }
.crash-box .actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ---- editors (ui/Editor.tsx, screens/editors) ---- */
.btn-xs { height: 22px; padding: 0 7px; font-size: var(--t-micro); border-radius: 6px; gap: 4px; }
.btn-xs svg { width: 12px; height: 12px; }
.ed-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 18px; align-items: start; }
.ed-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.ed-col .act-head { flex-wrap: wrap; }
.ed-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px 16px; align-items: start; }
.ed-grid.three { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.ed-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.ed-field .input { width: 100%; max-width: none; }
.ed-field textarea.input { min-height: 0; }
.ed-field.dirty .input { border-color: var(--accent-line); background: color-mix(in srgb, var(--accent-soft) 45%, var(--surface)); }
.ed-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: var(--t-meta); color: var(--muted); }
.ed-head label { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; color: var(--ink-2, var(--ink)); }
.ed-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent-solid); margin-left: 6px; flex: none; }
.ed-count { font-size: var(--t-micro); color: var(--muted); font-variant-numeric: tabular-nums; }
.ed-count.warn { color: var(--warn-ink); font-weight: 600; }
.ed-count.bad { color: var(--bad-ink); font-weight: 700; }
.ed-was { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ed-was-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.ed-issues { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; font-size: var(--t-meta); }
.ed-issues li { display: flex; gap: 6px; align-items: flex-start; line-height: 1.4; }
.ed-issues li svg { flex: none; margin-top: 2px; }
.ed-issues li.error { color: var(--bad-ink); }
.ed-issues li.warn { color: var(--warn-ink); }
.ed-issues li.info { color: var(--muted); }
.ed-bar { position: sticky; bottom: 0; z-index: 5; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); box-shadow: 0 -4px 14px rgba(0, 0, 0, .05); }
.ed-bar.active { border-color: var(--accent-line); }
.ed-review { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; }
.ed-review pre, .ed-diff pre { margin: 0; max-height: 320px; overflow: auto; padding: 10px 12px; border-radius: var(--r-sm); background: var(--code-bg); color: var(--code-ink); font: 12px/1.45 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.ed-dry.bad { outline: 1px solid var(--bad-solid); }
.ed-changes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.ed-changes li { min-width: 0; overflow-wrap: anywhere; }
.ed-from { color: var(--muted); text-decoration: line-through; }
.ed-to { color: var(--ink); font-weight: 600; }
.ed-diff summary { cursor: pointer; }
.ed-apply { display: flex; flex-direction: column; gap: 6px; width: 100%; }
.ed-langs { display: flex; flex-direction: column; gap: 12px; }
.ed-lang { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-sm); }
.ed-items { display: flex; flex-direction: column; }
.ed-item { border-top: 1px solid var(--line); }
.ed-item-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 14px; background: none; border: 0; color: inherit; text-align: left; cursor: pointer; font: inherit; }
.ed-item-head:hover { background: var(--hover); }
.ed-item-body { padding: 4px 14px 14px; gap: 12px; }
.ed-scenes { list-style: none; margin: 0; padding: 0; }
.ed-scenes li { display: grid; grid-template-columns: 28px minmax(80px, 140px) minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 14px; border-top: 1px solid var(--line); }
.ed-scene-frame { min-width: 0; }
.rot90 { transform: rotate(90deg); }

View file

@ -0,0 +1,298 @@
// The building blocks of the window's editors (console parity, docs/console-parity.md):
// - useEditable: the values the files hold (project.editable = `storebound edit --show --json`, offline)
// - fields (text, multi-line, number, yes / no, choice, list) that keep unsaved edits in lib/edits.ts, with the
// store limit as a live counter, the checks of `check` / `scan` as the owner types, and "was: …" with a reset
// - EditBar: unsaved count, undo / redo, discard, and "Review and save": the dry run of project.edit in the window
// (old -> new, the diff per file, the checks, git), then Save (native dialog, then the files are written)
// - ApplyPanel: a store write's dry run in the window (old -> new per store and language), then Apply (native
// dialog, the real run)
// - GitNote: the edited files are uncommitted in git (storebound never commits)
import { useCallback, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
import { api, type EditableResult, type EditResult, type Project, type ToolResult } from '../api';
import { t } from '../i18n';
import { type EditArea, type EditOp, edits, keyOf } from '../lib/edits';
import { failureText } from '../lib/results';
import { useRead, useWrite } from '../store';
import { Icon, type IconName } from './icons';
import { Chip, Notice } from './kit';
import { emailIssue, urlIssue } from '../../../../cli/lib/listing-rules';
import { WriteButton } from './WriteButton';
/** Characters as the stores count them (code points). */
export const chars = (s: string): number => [...s].length;
export function useEditable(project: Project) {
return useRead<EditableResult>(project.id, 'project.editable', { network: false });
}
/** Re-renders on every change of the unsaved edits. */
export function useEdits(): typeof edits {
useSyncExternalStore(edits.subscribe, edits.snapshot);
return edits;
}
export interface FieldIssueView { level: 'error' | 'warn' | 'info'; msg: string }
/** The current value of a field: the unsaved edit, else the saved one. */
export function useValue<T>(project: Project, op: EditOp, saved: T): { value: T; dirty: boolean } {
const e = useEdits();
const p = e.get(project.id, keyOf(op));
if (!p) return { value: saved, dirty: false };
const v = p.op.kind === 'config' ? (p.op.remove ? undefined : p.op.value) : p.op.value;
return { value: (v ?? (typeof saved === 'string' ? '' : v)) as T, dirty: true };
}
function Counter({ n, limit }: { n: number; limit: number }) {
const tone = n > limit ? 'bad' : n > limit * 0.95 ? 'warn' : '';
return <span className={`ed-count num ${tone}`} aria-label={t('ed.countAria', { n, limit })}>{n} / {limit}</span>;
}
function Issues({ issues }: { issues: FieldIssueView[] }) {
if (!issues.length) return null;
return (
<ul className="ed-issues">
{issues.map((i, k) => <li key={k} className={i.level}><Icon name={i.level === 'error' ? 'alertCircle' : i.level === 'warn' ? 'alert' : 'info'} className="ic13" />{i.msg}</li>)}
</ul>
);
}
interface FieldBase { project: Project; area: EditArea; label: string; hint?: ReactNode; issues?: FieldIssueView[]; id?: string; disabled?: boolean }
/** A text field: one line or several, a live character counter against the store limit. */
export function TextField({ project, area, op, saved, label, limit, multiline, rows = 3, placeholder, hint, issues = [], mono, id, disabled, kind }: FieldBase & {
/** The edit without its value: { kind: 'listing', store, lang, field } or { kind: 'config', path } or contact. */
op: Omit<Extract<EditOp, { kind: 'listing' }>, 'value'> | { kind: 'config'; path: string } | { kind: 'contact'; field: Extract<EditOp, { kind: 'contact' }>['field'] };
saved: string; limit?: number; multiline?: boolean; rows?: number; placeholder?: string; mono?: boolean; kind?: 'url' | 'email' | 'text';
}) {
const e = useEdits();
const make = (v: string): EditOp => (op.kind === 'config' ? (v === '' ? { kind: 'config', path: op.path, remove: true } : { kind: 'config', path: op.path, value: v }) : { ...op, value: v } as EditOp);
const { value, dirty } = useValue<string>(project, make(saved), saved);
const fid = id ?? `ed-${keyOf(make('')).replace(/[^A-Za-z0-9_-]/g, '-')}`;
const n = chars(value ?? '');
const set = (v: string) => e.set(project.id, make(v), area, saved);
const common = { id: fid, className: `input${mono ? ' mono' : ''}`, value: value ?? '', placeholder, disabled, 'aria-invalid': issues.some(i => i.level === 'error') || undefined,
onChange: (ev: { target: { value: string } }) => set(ev.target.value), 'data-edit': keyOf(make('')) };
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head">
<label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label>
{limit !== undefined && <Counter n={n} limit={limit} />}
</div>
{multiline ? <textarea rows={rows} {...common} /> : <input type={kind === 'email' ? 'email' : kind === 'url' ? 'url' : 'text'} spellCheck={kind ? false : undefined} {...common} />}
{dirty && (
<div className="ed-was small muted">
<span className="ed-was-text">{t('ed.was')} {saved ? `“${saved.length > 90 ? `${saved.slice(0, 87)}…` : saved}”` : t('ed.empty')}</span>
<button className="btn btn-xs btn-ghost" onClick={() => set(saved)}><Icon name="rotate" />{t('ed.reset')}</button>
</div>
)}
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={[...(kind === 'url' && urlIssue(value ?? '') ? [{ level: 'error' as const, msg: urlIssue(value ?? '')! }] : []),
...(kind === 'email' && emailIssue(value ?? '') ? [{ level: 'error' as const, msg: emailIssue(value ?? '')! }] : []), ...issues]} />
</div>
);
}
/** A config value from a fixed list (an empty choice removes the key). */
export function ChoiceField({ project, area, path, saved, label, options, hint, issues = [], allowEmpty = true }: FieldBase & {
path: string; saved: string | boolean | null | undefined; options: [string, string][]; allowEmpty?: boolean;
}) {
const e = useEdits();
const toValue = (s: string): unknown => (s === 'true' ? true : s === 'false' ? false : s);
const savedS = saved === null || saved === undefined ? '' : String(saved);
const make = (s: string): EditOp => (s === '' ? { kind: 'config', path, remove: true } : { kind: 'config', path, value: toValue(s) });
const { value, dirty } = useValue<unknown>(project, make(savedS), saved);
const cur = value === null || value === undefined ? '' : String(value);
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label></div>
<select id={fid} className="input" value={cur} data-edit={`config:${path}`} onChange={ev => e.set(project.id, make(ev.target.value), area, saved === undefined ? null : saved)}>
{allowEmpty && <option value="">{t('ed.notSet')}</option>}
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={issues} />
</div>
);
}
/** A number in the config (prices in USD, levels). */
export function NumberField({ project, area, path, saved, label, hint, issues = [], min, step = 'any' }: FieldBase & { path: string; saved: number | null | undefined; min?: number; step?: string }) {
const e = useEdits();
const make = (v: number | null): EditOp => (v === null ? { kind: 'config', path, remove: true } : { kind: 'config', path, value: v });
const { value, dirty } = useValue<number | null | undefined>(project, make(saved ?? null), saved);
const [text, setText] = useState(value === null || value === undefined ? '' : String(value));
useEffect(() => { setText(value === null || value === undefined ? '' : String(value)); }, [value]);
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
const bad = text !== '' && !Number.isFinite(Number(text));
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label></div>
<input id={fid} className="input num" inputMode="decimal" value={text} data-edit={`config:${path}`} aria-invalid={bad || undefined}
onChange={ev => { setText(ev.target.value); const n = Number(ev.target.value); if (ev.target.value === '') e.set(project.id, make(null), area, saved ?? null); else if (Number.isFinite(n)) e.set(project.id, make(n), area, saved ?? null); }}
min={min} step={step} />
{dirty && <div className="ed-was small muted"><span className="ed-was-text">{t('ed.was')} {saved ?? t('ed.empty')}</span><button className="btn btn-xs btn-ghost" onClick={() => e.set(project.id, make(saved ?? null), area, saved ?? null)}><Icon name="rotate" />{t('ed.reset')}</button></div>}
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={[...(bad ? [{ level: 'error' as const, msg: t('ed.notNumber') }] : []), ...issues]} />
</div>
);
}
/** A list of short values (countries, age groups, keywords of a config list) as comma-separated text. */
export function ListField({ project, area, path, saved, label, hint, issues = [], upper }: FieldBase & { path: string; saved: string[] | null | undefined; upper?: boolean }) {
const e = useEdits();
const make = (v: string[] | null): EditOp => (v === null || !v.length ? { kind: 'config', path, remove: true } : { kind: 'config', path, value: v });
const { value, dirty } = useValue<string[] | null | undefined>(project, make(saved ?? null), saved);
const [text, setText] = useState((value ?? []).join(', '));
useEffect(() => { setText((value ?? []).join(', ')); }, [JSON.stringify(value)]); // eslint-disable-line react-hooks/exhaustive-deps
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label></div>
<input id={fid} className="input mono" value={text} data-edit={`config:${path}`}
onChange={ev => { setText(ev.target.value); const list = ev.target.value.split(',').map(s => (upper ? s.trim().toUpperCase() : s.trim())).filter(Boolean); e.set(project.id, make(list.length ? list : null), area, saved ?? null); }} />
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={issues} />
</div>
);
}
/** A block of the config as JSON (offers, promoted purchases, custom product pages, events, data types): edited as
* text, checked as JSON here and validated with the whole config when saving (the dry run). */
export function JsonField({ project, area, path, saved, label, hint, rows = 10 }: FieldBase & { path: string; saved: unknown; rows?: number }) {
const e = useEdits();
const savedText = saved === undefined || saved === null ? '' : JSON.stringify(saved, null, 2);
const pending = e.get(project.id, `config:${path}`);
const [text, setText] = useState(pending ? (pending.op.kind === 'config' && !pending.op.remove ? JSON.stringify(pending.op.value, null, 2) : '') : savedText);
useEffect(() => { if (!e.get(project.id, `config:${path}`)) setText(savedText); }, [savedText]); // eslint-disable-line react-hooks/exhaustive-deps
let err: string | null = null;
if (text.trim()) { try { JSON.parse(text); } catch (x) { err = (x as Error).message; } }
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
return (
<div className={`ed-field${pending ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{pending && <span className="ed-dot" title={t('ed.unsavedField')} />}</label><span className="small muted mono-id">{path}</span></div>
<textarea id={fid} className="input mono" rows={rows} value={text} spellCheck={false} data-edit={`config:${path}`} aria-invalid={!!err || undefined}
onChange={ev => {
setText(ev.target.value);
const v = ev.target.value.trim();
if (!v) { e.set(project.id, { kind: 'config', path, remove: true }, area, saved ?? null); return; }
try { e.set(project.id, { kind: 'config', path, value: JSON.parse(v) }, area, saved ?? null); } catch { /* shown below; kept as text until it parses */ }
}} />
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={err ? [{ level: 'error', msg: t('ed.notJson', { error: err }) }] : []} />
</div>
);
}
/** "Uncommitted in git" for the files an editor writes (storebound never commits). */
export function GitNote({ data, files }: { data: EditableResult; files: (string | null)[] }) {
if (!data.git.repo) return <span className="small muted row" style={{ gap: 6 }}><Icon name="folder" className="ic13" />{t('ed.noGit')}</span>;
const dirty = data.git.uncommitted.filter(f => files.includes(f));
if (!dirty.length) return <span className="small muted row" style={{ gap: 6 }}><Icon name="check" className="ic13" />{t('ed.gitClean')}</span>;
return <span className="row" style={{ gap: 6 }} data-git="uncommitted"><Chip tone="warn" icon="alert">{t('ed.gitDirty', { files: dirty.join(', ') })}</Chip></span>;
}
/** The plan of project.edit (old -> new, the checks, the diff per file) in the window. */
export function EditPlanView({ r }: { r: EditResult }) {
return (
<div className="ed-plan stack" data-edit-plan>
<ul className="ed-changes">
{r.changes.map((c, i) => (
<li key={i}><span className="strong">{c.what}</span>{c.also?.length ? <span className="small muted"> · {t('ed.also', { codes: c.also.join(', ') })}</span> : null}
<div className="small"><span className="ed-from">{c.from ?? t('ed.empty')}</span> <Icon name="arrowRight" className="ic13" /> <span className="ed-to">{c.to ?? t('ed.removed')}</span></div></li>
))}
</ul>
{r.issues.length > 0 && <Issues issues={r.issues.map(i => ({ level: i.level, msg: `${i.where}: ${i.msg}` }))} />}
{r.files.map(f => (
<details key={f.rel} className="ed-diff" open={r.files.length === 1}>
<summary className="small"><span className="mono-id">{f.rel}</span>{f.created ? ` · ${t('ed.newFile')}` : ''}</summary>
{f.masked ? <p className="small muted">{t('ed.masked')}</p> : <pre className="selectable">{f.diff.join('\n')}</pre>}
</details>
))}
</div>
);
}
/** The unsaved edits of one editor: count, undo / redo, discard, and review + save. */
export function EditBar({ project, area, data, files, onSaved }: { project: Project; area: EditArea; data: EditableResult; files: (string | null)[]; onSaved?: () => void }) {
const e = useEdits();
const w = useWrite(project.id);
const n = e.count(project.id, area);
const [review, setReview] = useState<{ loading: boolean; r: EditResult | null; error: string | null } | null>(null);
const ops = e.ops(project.id, area);
const opsJson = JSON.stringify(ops);
// a review shows the edits it was made for; typing more closes it
useEffect(() => { setReview(null); }, [opsJson]);
const check = useCallback(async () => {
setReview({ loading: true, r: null, error: null });
try {
const res = await api.call<ToolResult & { data?: EditResult }>('project.edit', project.id, { args: { ops: opsJson } });
setReview({ loading: false, r: (res.data as EditResult | undefined) ?? null, error: res.data ? null : failureText(res) });
} catch (x) { setReview({ loading: false, r: null, error: String((x as { message?: string }).message ?? x) }); }
}, [project.id, opsJson]);
return (
<div className={`ed-bar${n ? ' active' : ''}`} data-edit-bar={area}>
<div className="row between" style={{ gap: 10, flexWrap: 'wrap' }}>
<div className="row" style={{ gap: 8 }}>
{n ? <Chip tone="accent" icon="edit">{t('ed.unsaved', { count: n })}</Chip> : <span className="small muted">{t('ed.noChanges')}</span>}
<GitNote data={data} files={files} />
</div>
<div className="row" style={{ gap: 6 }}>
<button className="btn btn-sm btn-ghost" disabled={!e.canUndo(project.id)} onClick={() => e.undo(project.id)} title={t('ed.undo')}><Icon name="rotate" />{t('ed.undo')}</button>
<button className="btn btn-sm btn-ghost" disabled={!e.canRedo(project.id)} onClick={() => e.redo(project.id)} title={t('ed.redo')}><Icon name="refresh" />{t('ed.redo')}</button>
<button className="btn btn-sm btn-ghost" disabled={!n} onClick={() => e.clear(project.id, area, { undoable: true })}><Icon name="x" />{t('ed.discard')}</button>
<button className="btn btn-sm btn-primary" disabled={!n || review?.loading} onClick={check} data-action="review"><Icon name={review?.loading ? 'refresh' : 'eye'} className={review?.loading ? 'spin' : undefined} />{t('ed.review')}</button>
</div>
</div>
{review && !review.loading && (
<div className="ed-review">
{review.error && <Notice tone="bad">{review.error}</Notice>}
{review.r && <EditPlanView r={review.r} />}
{review.r && (
<div className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-sm btn-ghost" onClick={() => setReview(null)}>{t('ed.close')}</button>
<WriteButton run={w} primary icon="check" disabled={review.r.errors > 0 || !review.r.files.length}
hint={review.r.errors ? t('ed.fixFirst') : undefined}
w={{ method: 'project.edit', args: { ops: opsJson }, title: t('ed.save'), done: t('ed.saved'), after: () => { e.clear(project.id, area); setReview(null); onSaved?.(); } }}>{t('ed.save')}</WriteButton>
</div>
)}
</div>
)}
</div>
);
}
/** A store write with its dry run in the window first: the plan (old -> new per store and language), then Apply. */
export function ApplyPanel({ project, method, args = {}, label, icon = 'upload', done, disabled, hint, blocked }: {
project: Project; method: string; args?: Record<string, unknown>; label: string; icon?: IconName; done: string; disabled?: boolean; hint?: string;
/** Unsaved edits that the store write would not include. */ blocked?: string | null;
}) {
const w = useWrite(project.id);
const [st, setSt] = useState<{ loading: boolean; text: string; ok: boolean } | null>(null);
const preview = async () => {
setSt({ loading: true, text: '', ok: false });
try {
const r = await api.call<ToolResult>(method, project.id, { args });
const lines = r.output.split('\n').map(l => l.replace(/^\d\d:\d\d:\d\d \[[^\]]+\] /, '')).filter(l => l.trim());
const data = r.data as { diff?: string[] } | undefined;
setSt({ loading: false, text: [...lines, ...(data?.diff ?? [])].join('\n') || t('ed.nothingToApply'), ok: r.ok });
} catch (x) { setSt({ loading: false, text: String((x as { message?: string }).message ?? x), ok: false }); }
};
return (
<div className="ed-apply" data-apply={method}>
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-sm" disabled={disabled || st?.loading} onClick={preview} title={hint}><Icon name={st?.loading ? 'refresh' : 'eye'} className={st?.loading ? 'spin' : undefined} />{label}</button>
{blocked && <span className="small muted">{blocked}</span>}
</div>
{st && !st.loading && (
<div className="ed-review">
<pre className={`selectable ed-dry${st.ok ? '' : ' bad'}`}>{st.text}</pre>
<div className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-sm btn-ghost" onClick={() => setSt(null)}>{t('ed.close')}</button>
{st.ok && <WriteButton run={w} primary icon={icon} disabled={!!blocked} hint={blocked ?? undefined} w={{ method, args, title: label, done, after: () => setSt(null) }}>{t('ed.applyNow')}</WriteButton>}
</div>
</div>
)}
</div>
);
}