diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 31a5b1f..b58bf3f 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -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'; diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3cad6a2..7e0da5c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 1efb833..40b3d3d 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -1547,4 +1547,182 @@ export const tr: Record = { '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}', }; diff --git a/apps/desktop/src/lib/edits.ts b/apps/desktop/src/lib/edits.ts new file mode 100644 index 0000000..72ddb1d --- /dev/null +++ b/apps/desktop/src/lib/edits.ts @@ -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; undo: Map[]; redo: Map[]; 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(); + 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(); diff --git a/apps/desktop/src/lib/freshness.ts b/apps/desktop/src/lib/freshness.ts index 1866caa..7f68546 100644 --- a/apps/desktop/src/lib/freshness.ts +++ b/apps/desktop/src/lib/freshness.ts @@ -15,6 +15,7 @@ export const TTL: Record = { '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'); diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 818ed46..6c528c0 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -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) => ( ))} @@ -154,7 +159,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'releases' && } {tab === 'catalog' && app.project && } {tab === 'prices' && app.project && } - {tab === 'listings' && app.project && } + {tab === 'listings' && app.project && <>} {tab === 'art' && app.project && } {tab === 'health' && } {tab === 'reviews' && } @@ -162,6 +167,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'growth' && } {tab === 'appinfo' && app.project && } {tab === 'checks' && app.project && } + {tab !== 'listings' && app.project && !app.project.missing && EDIT_TABS[tab] && } } diff --git a/apps/desktop/src/screens/editors/AppInfoEditor.tsx b/apps/desktop/src/screens/editors/AppInfoEditor.tsx new file mode 100644 index 0000000..cc2b320 --- /dev/null +++ b/apps/desktop/src/screens/editors/AppInfoEditor.tsx @@ -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; +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 ( + <> + {t('ed.appleInfoSub')}}> +
+ + {games && } + {games && } + + + + + +
+
+ +
+ {Object.entries(AGE_RATING_NONE).map(([q, none]) => ( + [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} /> + ))} +
+
+ +
+ + + + {(['firstName', 'lastName', 'phone', 'email'] as const).map(f => ( + + ))} +
{data.demoAccount ? `${data.demoAccount} · ••••••••` : t('ed.notSet')}
{t('ed.demoHint')}
+
+
+ + ); +} + +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 ( + {t('ed.playSettingsSub')}}> +
+ + + + + +
+
+ ); +} + +function Declarations({ project, data }: { project: Project; data: EditableResult }) { + const ds = obj(data.config.dataSafety); + return ( + +
+ {data.play && } + {data.play && } + {data.play && } +
+ {data.play && } + {data.apple && } +
+ ); +} + +function Promotion({ project, data }: { project: Project; data: EditableResult }) { + const apple = obj(data.config.apple); + return ( + {t('ed.promoSub')}}> + + + +
+ + +
+
+ ); +} + +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 ( +
+ {data.apple && } + {data.play && } + + {data.apple && } + + +
+ {data.apple && ( +
+
App Store
+ + + +
+ )} + {data.play && ( +
+
Google Play
+ + + {t('ed.playConsoleOnly')} +
+ )} +
+
+
+ ); +} diff --git a/apps/desktop/src/screens/editors/ArtEditor.tsx b/apps/desktop/src/screens/editors/ArtEditor.tsx new file mode 100644 index 0000000..4829e24 --- /dev/null +++ b/apps/desktop/src/screens/editors/ArtEditor.tsx @@ -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; + +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 {t('ed.artEmptyText')}; + 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 ( +
+ +
    + {scenes.map((s, i) => ( +
  1. + {i + 1} + {s.id} +
    + x.id === s.id)?.frames?.[0]?.src ?? ''} label={t('ed.frame')} mono /> +
    +
    + + +
    +
  2. + ))} +
+
+ + +
+
{t('act.render')}
+ {data.play &&
Google Play
} + {data.apple &&
App Store
} +
+
+
+ ); +} diff --git a/apps/desktop/src/screens/editors/CatalogEditor.tsx b/apps/desktop/src/screens/editors/CatalogEditor.tsx new file mode 100644 index 0000000..27380c4 --- /dev/null +++ b/apps/desktop/src/screens/editors/CatalogEditor.tsx @@ -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; +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 ( +
+ {data.languages.map(l => { + const x = obj(listings[l.id]); + return ( +
+
{l.id}
+
+ + + {benefits && } +
+
+ ); + })} +
+ ); +} + +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; + const e = useEdits(); + const n = e.list(project.id, 'catalog').filter(x => x.key.startsWith(`config:${base}`)).length; + return ( +
+ + {open && ( +
+
+ + + + + +
+ + +
+ )} +
+ ); +} + +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; + return ( +
+ + {open && ( +
+
+ + + + + +
+ + + + + +
+ )} +
+ ); +} + +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 {t('ed.catalogEmptyText')}; + } + return ( +
+ {products.length > 0 && ( + +
{products.map(p => )}
+
+ )} + {groups.map(g => ( + +
+
+ {data.languages.map(l => ( +
{l.id}
+ +
+ ))} +
+
+
{(g.subscriptions as O[]).map(s => )}
+
+ ))} + + +
+ {data.play && ( +
+
Google Play
+ {products.length > 0 && } + {groups.length > 0 && } +
+ )} + {data.apple && ( +
+
App Store
+ {products.length > 0 && } + {groups.length > 0 && } + {products.length > 0 && } +
+ )} +
+

{t('ed.priceGate')}

+
+
+ ); +} diff --git a/apps/desktop/src/screens/editors/ListingEditor.tsx b/apps/desktop/src/screens/editors/ListingEditor.tsx new file mode 100644 index 0000000..ae322a6 --- /dev/null +++ b/apps/desktop/src/screens/editors/ListingEditor.tsx @@ -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, project: Project, store: 'play' | 'apple', l: EditableListing, fields: string[]): Record { + const out: Record = {}; + 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; + 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 ( +
+
{store === 'play' ? 'Google Play' : 'App Store'}{l.lang} + {l.status && {l.status === 'draft' ? t('ed.statusDraft') : t('ed.statusTranslate')}} + {(l.codes?.length ?? 0) > 1 && {t('ed.alsoServes', { codes: l.codes!.filter(c => c !== l.lang).join(', ') })}} +
+ {fresh && {t('ed.newSection', { lang: l.lang })}} + {defs.map(d => ( + + ))} +
+ ); +} + +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 | null>)[a]; + return o && typeof o[b] === 'string' ? (o[b] as string) : ''; + }; + return ( +
+ {DETAILS.filter(d => !d.store || (d.store === 'play' ? !!data.play : !!data.apple)).map(d => { + const v = get(d.path); + return ; + })} +
+ ); +} + +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(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 ( + <> + + {data.play && {t('ed.pullPlay')}} + {data.apple && {t('ed.pullApple')}} + }> +
+ {unsaved > 0 && {t('ed.pullNote')}} +
+ {data.languages.map(x => { + const n = e.list(project.id, 'listings').filter(p => p.op.kind === 'listing' && (p.op.lang === x.apple || x.play.includes(p.op.lang))).length; + return ( + + ); + })} +
+ {playL.length > 1 && ( +
+ {t('ed.playCode')} + {playL.map(x => )} +
+ )} + {L?.rtl && {t('ed.rtl')}} +
+ {pl && } + {al && } + {!pl && !al && {t('ed.noStoreForLang', { lang: L?.id ?? '' })}} +
+
+
+ + + + + +
+ {data.play && ( +
+
Google Play
+ +
+ )} + {data.apple && ( +
+
App Store
+
+ + setVersion(ev.target.value.trim())} placeholder="2.4.0" /> +
+ +
+ )} +
+

{t('ed.applyNote')}

+
+ + ); +} diff --git a/apps/desktop/src/screens/editors/TabEditor.tsx b/apps/desktop/src/screens/editors/TabEditor.tsx new file mode 100644 index 0000000..462fae0 --- /dev/null +++ b/apps/desktop/src/screens/editors/TabEditor.tsx @@ -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> = { + 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 ? : ; + const p = { project, data: r.data, onSaved: () => { r.refresh(); } }; + switch (tab) { + case 'listings': return ; + case 'appinfo': return ; + case 'catalog': return ; + case 'testflight': return ; + case 'art': return ; + 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); + }, []); +} diff --git a/apps/desktop/src/screens/editors/TestFlightEditor.tsx b/apps/desktop/src/screens/editors/TestFlightEditor.tsx new file mode 100644 index 0000000..9507aab --- /dev/null +++ b/apps/desktop/src/screens/editors/TestFlightEditor.tsx @@ -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; +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 ( +
+ +
+ {data.apple.map(l => ( +
+
{l.lang}
+ + {typeof desc !== 'string' && ( + + )} +
+ ))} +
+ {typeof desc === 'string' && } + + +
+ + +
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/screens/tabs/Listings.tsx b/apps/desktop/src/screens/tabs/Listings.tsx index a171b1a..44753b1 100644 --- a/apps/desktop/src/screens/tabs/Listings.tsx +++ b/apps/desktop/src/screens/tabs/Listings.tsx @@ -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 ( @@ -79,18 +76,12 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] }) {android && (
Google Play
- {t('act.playListing')} {t('act.playImages')}
)} {ios && (
App Store
-
- - setVersion(ev.target.value.trim())} placeholder="2.4.0" /> -
- {t('act.appleListing', { version: version || '…' })} {t('act.appleShots')} {t('act.applePreviews')}
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 8685ddd..07aeeda 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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); } diff --git a/apps/desktop/src/ui/Editor.tsx b/apps/desktop/src/ui/Editor.tsx new file mode 100644 index 0000000..7479ebe --- /dev/null +++ b/apps/desktop/src/ui/Editor.tsx @@ -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(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(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 {n} / {limit}; +} + +function Issues({ issues }: { issues: FieldIssueView[] }) { + if (!issues.length) return null; + return ( +
    + {issues.map((i, k) =>
  • {i.msg}
  • )} +
+ ); +} + +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, 'value'> | { kind: 'config'; path: string } | { kind: 'contact'; field: Extract['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(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 ( +
+
+ + {limit !== undefined && } +
+ {multiline ?