@appaflytech/zeyrek-ai-mcp 0.0.51 → 0.0.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/knowledge/componentCatalog.js +3 -1
- package/dist/knowledge/componentEvents.js +13 -1
- package/dist/knowledge/expressionPrelude.js +3 -0
- package/dist/knowledge/mobileComponentCatalog.js +4 -1
- package/dist/knowledge/mobileStore.js +258 -1
- package/dist/knowledge/normalize.js +4 -3
- package/dist/knowledge/platform/mobile.js +1 -1
- package/dist/knowledge/platform/mobileRules.js +79 -0
- package/dist/knowledge/rules.js +199 -6
- package/dist/knowledge/storeImport.js +69 -0
- package/dist/platform/argumentAliases.js +13 -4
- package/dist/platform/bodyFile.js +195 -38
- package/dist/platform/capture/actions.js +24 -5
- package/dist/platform/capture/capture.js +80 -11
- package/dist/platform/capture/persistent-browser.js +23 -1
- package/dist/platform/capture/readiness.js +32 -1
- package/dist/platform/errors.js +45 -5
- package/dist/platform/http.js +17 -9
- package/dist/platform/responses.js +7 -1
- package/dist/platform/secrets.js +8 -0
- package/dist/platform/token.js +32 -21
- package/dist/resources/examples/flow-credit-spend.json +233 -0
- package/dist/resources/examples/flow-purchase-paywall.json +662 -0
- package/dist/resources/examples/mobile-offline-list.json +1 -1
- package/dist/resources/examples/mobile-offline-queue.json +1 -1
- package/dist/resources/examples/mobile-offline-seamless.json +12 -3
- package/dist/resources/examples/store-import-mapping.json +25 -0
- package/dist/resources/guides/deployment.md +9 -0
- package/dist/resources/guides/expressions.md +1 -0
- package/dist/resources/guides/microservice.md +13 -1
- package/dist/resources/guides/mobile-publish.md +67 -2
- package/dist/resources/guides/mobile-steps.md +91 -3
- package/dist/resources/guides/mobile.md +38 -8
- package/dist/resources/guides/page.md +39 -8
- package/dist/resources/guides/translation.md +3 -1
- package/dist/resources/guides/workflow.md +30 -0
- package/dist/resources/schema-fingerprint.json +2 -2
- package/dist/resources/schemas/CreateWorkflowCommand.json +6 -1
- package/dist/resources/schemas/GlobalFunctionUpdateResultDto.json +4 -0
- package/dist/resources/schemas/PreviewWorkflowCommand.json +6 -1
- package/dist/resources/schemas/StepDto.json +6 -1
- package/dist/resources/schemas/UpdateWorkflowCommand.json +6 -1
- package/dist/server.js +4 -0
- package/dist/tools/applications.js +7 -2
- package/dist/tools/deployments.js +1 -1
- package/dist/tools/designSchemaPatch.js +7 -2
- package/dist/tools/environments.js +40 -0
- package/dist/tools/flows.js +102 -9
- package/dist/tools/mobile.js +56 -0
- package/dist/tools/mobilePurchases.js +236 -0
- package/dist/tools/mobileStore.js +2 -2
- package/dist/tools/mobileStoreImport.js +173 -0
- package/dist/tools/pages.js +45 -11
- package/dist/tools/platformInfo.js +4 -4
- package/dist/tools/screenWrite.js +107 -1
- package/dist/tools/variables.js +1 -1
- package/package.json +1 -1
- package/dist/resources/guides/mobile-flow.md +0 -64
|
@@ -502,6 +502,7 @@ export const componentKnowledge = {
|
|
|
502
502
|
summary: 'Aranabilir çoklu seçim; value bir değer dizisidir, seçilenler etiket (chip) olarak görünür, search olayı sunucu taraflı arama kurmaya yarar.',
|
|
503
503
|
behavior: [
|
|
504
504
|
"Bakma penceresi (lookupMode modal | drawer) tablo kelimelerini (Uygula, Seçimi temizle, ipucu satırı, filtre çubuğu, işleç adları) tablonun satırlarından okur: compProp.datatable.<kelime> (2026-10-15, frontend ADR 0024); bu satırlar bileşenin Translations sekmesinde tablonun anahtarlarıyla listelenir ve bileşen kullanılınca tohumlanır. Düğümün texts'i önce gelir.",
|
|
505
|
+
"Bakma penceresinde başlık satırının ilk hücresi tümünü seç kutusudur (2026-11-02): süzgece uyan BÜTÜN kayıtları seçer, sonuç sayfaya sığmıyorsa kimlikleri bir kez okur (en çok 1000; fazlasında pencere söyler). Süzgecin dışındaki önceki seçim korunur, yazım yine Uygula iledir. Datatable çoka-çok hücresi ve çoklu filtre penceresi aynı pencereyi kullanır.",
|
|
505
506
|
"value bir DİZİDİR ve üç biçimi de okunur: düz değer (['policies', 7]), şemanın biçimi ([{ value, label }]) ve eski ikizler ([{ id, text }]). Düz değer yazmak 2026-09-11'e kadar etiketi 'undefined' gösteriyordu; artık değer etiketin kendisi olur ve doğru metin options yüklenince gelir (2026-10-01'e kadar gelmiyordu: akışın yazdığı id dizisi seçenekler yüklense de çipte id olarak kalıyordu).",
|
|
506
507
|
"Etiketin metni options'tan tazelenir: aynı value'lu option listede varsa onun label'ı yazılır. Bu arama METİNLEŞTİREREK karşılaştırır (String(a) === String(b)), AMA yalnız etiket metni için: satırın işaretli görünmesi ve seçimin kaldırılması KATI karşılaştırma kullanır. Yani value'da metin '34', options'ta sayı 34 varsa çip doğru etiketle çizilir ama liste satırı işaretsiz kalır ve tıklamak hiçbir şey yapmaz — eşlemeyi akış katmanında aynı tipe getir (CQ-208).",
|
|
507
508
|
"change yükü { value: [{ value, label, id, text }] } taşır ve düğümün value prop'una yazılır: akışta self.value ile okunur. id/text, value/label'ın eski adlı ikizleridir — kayıtlı sayfalar onlarla okuduğu için bilerek korunuyor.",
|
|
@@ -578,7 +579,8 @@ export const componentKnowledge = {
|
|
|
578
579
|
maxChars: "SERT sınır (tarayıcı maxlength): fazlası yazılamaz. Akıştan gelen daha uzun değer aynen gösterilir (sayaç 45/40 okur). validations.maxLength'ten FARKLIDIR: o yalnız doğrular. 0/boş = sınırsız.",
|
|
579
580
|
showCounter: "Kutunun altında sağa dayalı sayaç: maxChars varsa '12/40', yoksa '12'.",
|
|
580
581
|
autofocus: 'Sayfa açılınca odak bu alana gelir (dondurulmuş alanda ve tasarım kanvasında çalışmaz). Sayfada tek alana ver.',
|
|
581
|
-
isLoading: "Alanı dondurur (readonly, gri değil) ve sağ üstte spinner çizer; changed'e bağlı sorgu sürerken aç, tamamlanınca kapat."
|
|
582
|
+
isLoading: "Alanı dondurur (readonly, gri değil) ve sağ üstte spinner çizer; changed'e bağlı sorgu sürerken aç, tamamlanınca kapat.",
|
|
583
|
+
selection: "selection { start, end } (f-07): kullanıcı seçince ya da imleci taşıyınca selectionChange yazar; yazarken kayan imleç yalnız prop'u günceller (akış koşmaz). Alana hiç dokunulmadıysa undefined'dır. Akış Components.<id>.selection'a yazarsa alan seçimi/imleci oraya koyar ve odaklanır (aynı akışta value da yazıldıysa yeni metne göre). Seçili parçayı biçimlemek ('Kalın' düğmesinin click akışı): const v = Components.<alan>.value ?? ''; const s = Components.<alan>.selection ?? { start: v.length, end: v.length }; const yeni = v.slice(0, s.start) + '**' + v.slice(s.start, s.end) + '**' + v.slice(s.end); const imlec = s.start === s.end ? s.start + 2 : s.end + 4; sonra setter ile value = yeni, selection = { start: imlec, end: imlec } (seçimde imleç kapanış işaretinden sonra, boş imleçte iki işaretin arasında). Web ve mobil aynı yükü verir; textinput'ta bu olay YOK."
|
|
582
584
|
}
|
|
583
585
|
},
|
|
584
586
|
texteditor: {
|
|
@@ -125,7 +125,19 @@ export const componentEventContracts = {
|
|
|
125
125
|
focus: NO_DATA,
|
|
126
126
|
blur: NO_DATA
|
|
127
127
|
},
|
|
128
|
-
textarea: {
|
|
128
|
+
textarea: {
|
|
129
|
+
mount: NO_DATA,
|
|
130
|
+
change: valueChange('string'),
|
|
131
|
+
changed: valueChange('string (blur ile kesinleşen değer)'),
|
|
132
|
+
focus: NO_DATA,
|
|
133
|
+
blur: NO_DATA,
|
|
134
|
+
// f-07 (plans/defter-saha-bulgulari): yük hem prop'a (`self.selection`) hem `params` ile `event.data`ya gider.
|
|
135
|
+
selectionChange: {
|
|
136
|
+
payload: {
|
|
137
|
+
selection: '{ start, end } — seçimin başı ve sonu, UTF-16 indeks (JS `slice` ile aynı birim); imleçte start === end. Aynı değer event.data.start / event.data.end olarak da gelir. Kullanıcı seçince ya da imleci taşıyınca yayılır (iki platformda 50 ms içindeki değişimler tek olay). Yazarken kayan imleç akış KOŞTURMAZ: yalnız self.selection güncellenir (her tuşta akış olmasın); biçim düğmesi seçimi tıklamada Components.<id>.selection\'dan okur'
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
129
141
|
password: {
|
|
130
142
|
mount: NO_DATA,
|
|
131
143
|
change: valueChange('string'),
|
|
@@ -198,7 +198,10 @@ export const mobileComponentKnowledge = {
|
|
|
198
198
|
differences: [
|
|
199
199
|
WEB_WORDS_NOTE,
|
|
200
200
|
'Yükseklik açıkça verilir (React Native `TextInput` satır sayısını kendi ölçmez): `autoGrow` (web varsayılanı AÇIK) iken içerikle `minLines`–`maxLines` arasında büyür, kapalıyken sabit `lines`. 2026-09-27\'ye dek hep sabit boydu (f-05). Satır yüksekliği `punto × 1,45` stile yazılır ve satır tabanlı boylar iç boşluk + kenarlığı içerir: `maxLines: 5` beş tam satır gösterir (2026-09-29). `autofocus` runtime\'da klavyeyi açar.'
|
|
201
|
-
]
|
|
201
|
+
],
|
|
202
|
+
propNotes: {
|
|
203
|
+
selection: "Web ile aynı sözleşme (componentCatalog textarea.propNotes.selection). Cihazda seçim `onSelectionChange`'ten gelir ve 50 ms içinde tek `selectionChange`'e katlanır; akışın yazdığı seçim `setSelection` ile konur ve alan odaklanır (klavye açılır). Studio önizlemesinde (react-native-web) aynı yol `setSelectionRange`."
|
|
204
|
+
}
|
|
202
205
|
},
|
|
203
206
|
numberinput: {
|
|
204
207
|
summary: 'Sayısal klavye açan alan; değer `Intl.NumberFormat` ile biçimlenir.',
|
|
@@ -97,7 +97,7 @@ export const STORE_FRAME_LAYOUTS = {
|
|
|
97
97
|
tablet10: { deviceWidth: 0.8, bezel: 0.03, radius: 0.05, cutout: 'none' }
|
|
98
98
|
};
|
|
99
99
|
/** Beyan anahtarları (`MobileStoreSettings.*Declaration`); iOS appPrivacy, ötekiler Android */
|
|
100
|
-
export const DECLARATION_KEYS = ['appPrivacy', 'dataSafety', 'contentRating', 'targetAudience', 'appAccess'];
|
|
100
|
+
export const DECLARATION_KEYS = ['appPrivacy', 'dataSafety', 'contentRating', 'targetAudience', 'appAccess', 'legacyScheme'];
|
|
101
101
|
/**
|
|
102
102
|
* Yuva kuralının istek gitmeden denetlenen kısmı (`MobileStoreAssetValidator`): derleme görseli dil
|
|
103
103
|
* almaz, ekran görüntüsü cihaz sınıfı ister, öteki türler cihaz sınıfı almaz. Ölçü/biçim/alfa
|
|
@@ -467,3 +467,260 @@ export const mobileStorePolicyRules = [
|
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
469
|
];
|
|
470
|
+
/**
|
|
471
|
+
* Uygulama içi satın alma ürünü (kök ADR 0027, `plans/mobil-uygulama-ici-satin-alma` b-01) — backend
|
|
472
|
+
* `MobileStoreProductConstants` / `MobileStoreProductValidator` aynası; `test/mobile-purchases.test.ts` sabitleri backend
|
|
473
|
+
* kaynağına bağlar. Katalog Core DB'dedir (`mobile_store_products`, ortam kavramı yok): adım ürünü bu anahtarla anar,
|
|
474
|
+
* cihaz mağaza kimliğini manifestten çözer.
|
|
475
|
+
*/
|
|
476
|
+
export const PRODUCT_KEY_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
|
|
477
|
+
export const PRODUCT_NAME_MAX_LENGTH = 30;
|
|
478
|
+
export const PRODUCT_DESCRIPTION_MAX_LENGTH = 45;
|
|
479
|
+
export const PRODUCT_KINDS = ['subscription', 'non_consumable', 'consumable'];
|
|
480
|
+
/** `consumable` ürünün verdiği miktarın üst sınırı (backend `MobileStoreProductConstants.GrantsAmountMax`) */
|
|
481
|
+
export const GRANTS_AMOUNT_MAX = 1_000_000;
|
|
482
|
+
export const PRODUCT_PERIODS = ['week', 'month', 'quarter', 'half_year', 'year'];
|
|
483
|
+
export const PRODUCT_TRIALS = ['none', '3d', '1w', '1m'];
|
|
484
|
+
/** Kabuğun satın almayı taşıyan ilk sürümü (backend `MobileBuildOptions` `PurchasesCapability`; P-23 eski kabukta uyarır) */
|
|
485
|
+
export const PURCHASES_SHELL_VERSION = '0.9.0';
|
|
486
|
+
/** Tür → davranış tablosu (backend `MobileStoreProductConstants.Kinds`, ADR 0027 madde 3): süreli mi, hak mı bakiye mi veriyor */
|
|
487
|
+
export const PRODUCT_KIND_BEHAVIOR = {
|
|
488
|
+
subscription: { periodic: true, grantsEntitlement: true, grantsBalance: false },
|
|
489
|
+
non_consumable: { periodic: false, grantsEntitlement: true, grantsBalance: false },
|
|
490
|
+
consumable: { periodic: false, grantsEntitlement: false, grantsBalance: true }
|
|
491
|
+
};
|
|
492
|
+
/** Ürünün yazılabilir alanları (`MobileStoreProductModel`); yanıttaki mağaza durumu (`ios`/`android`) gövdeye girmez */
|
|
493
|
+
const PRODUCT_MODEL_FIELDS = ['key', 'kind', 'entitlement', 'balance', 'grantsAmount', 'priceTry', 'period', 'trial', 'texts', 'reviewAssetId'];
|
|
494
|
+
const kindBehavior = (kind) => PRODUCT_KINDS.includes(kind) ? PRODUCT_KIND_BEHAVIOR[kind] : undefined;
|
|
495
|
+
/** Mağazaya gitmiş ürün (backend `MobileStoreProduct.IsSent`): bir mağazada `storeId`'si var. Anahtar, tür ve hak kilitlenir */
|
|
496
|
+
export const isProductSent = (product) => (isObject(product.ios) && typeof product.ios.storeId === 'string' && product.ios.storeId !== '') ||
|
|
497
|
+
(isObject(product.android) && typeof product.android.storeId === 'string' && product.android.storeId !== '');
|
|
498
|
+
/**
|
|
499
|
+
* Backend `PUT products/{id}` tam modeli yazar: gönderilmeyen alan boşalır (`texts` boşalınca 422). Yama bu yüzden okunan
|
|
500
|
+
* ürünün üstüne birleştirilir; `texts` dil dil ve alan alan birleşir, `null` dili kaldırır. Süresiz türde (`non_consumable`)
|
|
501
|
+
* yamada verilmeyen `period`/`trial` boşalır — tür değişiminde eski süre taşınırsa backend 422 verirdi. Aynı gerekçeyle hak vermeyen
|
|
502
|
+
* türde `entitlement`, bakiye vermeyen türde `balance`/`grantsAmount` boşalır.
|
|
503
|
+
*/
|
|
504
|
+
export function mergeProduct(current, patch) {
|
|
505
|
+
const base = Object.fromEntries(PRODUCT_MODEL_FIELDS.map(field => [field, current?.[field] ?? null]));
|
|
506
|
+
const merged = { ...base };
|
|
507
|
+
for (const field of PRODUCT_MODEL_FIELDS) {
|
|
508
|
+
if (field !== 'texts' && patch[field] !== undefined)
|
|
509
|
+
merged[field] = patch[field];
|
|
510
|
+
}
|
|
511
|
+
const texts = isObject(base.texts) ? { ...base.texts } : {};
|
|
512
|
+
for (const [locale, text] of Object.entries(isObject(patch.texts) ? patch.texts : {})) {
|
|
513
|
+
if (text === null)
|
|
514
|
+
delete texts[locale];
|
|
515
|
+
else
|
|
516
|
+
texts[locale] = { ...(isObject(texts[locale]) ? texts[locale] : {}), ...(isObject(text) ? text : {}) };
|
|
517
|
+
}
|
|
518
|
+
merged.texts = texts;
|
|
519
|
+
const behavior = kindBehavior(merged.kind);
|
|
520
|
+
if (behavior?.periodic === false) {
|
|
521
|
+
if (patch.period === undefined)
|
|
522
|
+
merged.period = null;
|
|
523
|
+
if (patch.trial === undefined)
|
|
524
|
+
merged.trial = null;
|
|
525
|
+
}
|
|
526
|
+
if (behavior?.grantsEntitlement === false && patch.entitlement === undefined)
|
|
527
|
+
merged.entitlement = null;
|
|
528
|
+
if (behavior?.grantsBalance === false) {
|
|
529
|
+
if (patch.balance === undefined)
|
|
530
|
+
merged.balance = null;
|
|
531
|
+
if (patch.grantsAmount === undefined)
|
|
532
|
+
merged.grantsAmount = null;
|
|
533
|
+
}
|
|
534
|
+
for (const field of ['balance', 'grantsAmount'])
|
|
535
|
+
if (merged[field] === null)
|
|
536
|
+
delete merged[field];
|
|
537
|
+
return merged;
|
|
538
|
+
}
|
|
539
|
+
/** Birleşmiş gövdenin okunan üründen farklı alanları (`texts.en.name` gibi yaprak yolla) */
|
|
540
|
+
export function changedProductFields(current, next) {
|
|
541
|
+
return PRODUCT_MODEL_FIELDS.flatMap((field) => {
|
|
542
|
+
if (field !== 'texts')
|
|
543
|
+
return JSON.stringify(next[field] ?? null) === JSON.stringify(current[field] ?? null) ? [] : [field];
|
|
544
|
+
const before = isObject(current.texts) ? current.texts : {};
|
|
545
|
+
const after = isObject(next.texts) ? next.texts : {};
|
|
546
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap(locale => ['name', 'description']
|
|
547
|
+
.filter(key => (isObject(before[locale]) ? before[locale][key] : undefined) !== (isObject(after[locale]) ? after[locale][key] : undefined))
|
|
548
|
+
.map(key => `texts.${locale}.${key}`));
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* `MobileStoreProductValidator` aynası: backend'in 422 vereceği her alan, aynı gerekçeyle. `siblings` uygulamanın öteki
|
|
553
|
+
* ürünleridir (anahtar tekilliği; bir hak tek türden ürünle verilir — abonelik grubu ile tek seferlik ürün aynı hakkı
|
|
554
|
+
* paylaşamaz). Fiyat TRY, en çok iki ondalık; mağaza fiyat basamağına eşlemeyi gönderim yapar.
|
|
555
|
+
*/
|
|
556
|
+
export function productFormatErrors(product, siblings = []) {
|
|
557
|
+
const errors = [];
|
|
558
|
+
const add = (field, message) => errors.push({ field, message });
|
|
559
|
+
const key = typeof product.key === 'string' ? product.key : '';
|
|
560
|
+
if (!PRODUCT_KEY_PATTERN.test(key))
|
|
561
|
+
add('key', `${PRODUCT_KEY_PATTERN.source} (küçük harf, rakam, alt çizgi; adım bu anahtarla anar)`);
|
|
562
|
+
else if (siblings.some(sibling => sibling.key === key))
|
|
563
|
+
add('key', 'bu uygulamada aynı anahtarlı başka ürün var');
|
|
564
|
+
const behavior = kindBehavior(product.kind);
|
|
565
|
+
if (!behavior) {
|
|
566
|
+
add('kind', PRODUCT_KINDS.join(' | '));
|
|
567
|
+
return errors;
|
|
568
|
+
}
|
|
569
|
+
const entitlement = typeof product.entitlement === 'string' ? product.entitlement : '';
|
|
570
|
+
const other = behavior.grantsEntitlement ? siblings.find(sibling => sibling.entitlement === entitlement && sibling.kind !== product.kind) : undefined;
|
|
571
|
+
const present = (value) => value !== null && value !== undefined;
|
|
572
|
+
if (behavior.grantsEntitlement && !PRODUCT_KEY_PATTERN.test(entitlement))
|
|
573
|
+
add('entitlement', `hak adı ${PRODUCT_KEY_PATTERN.source} (ekranlar Entitlements.<hak> okur)`);
|
|
574
|
+
else if (other)
|
|
575
|
+
add('entitlement', `'${entitlement}' hakkını '${String(other.key)}' (${String(other.kind)}) veriyor — bir hak tek türden ürünle verilir (Apple abonelik grubu = hak)`);
|
|
576
|
+
if (!behavior.grantsEntitlement && present(product.entitlement))
|
|
577
|
+
add('entitlement', `${String(product.kind)} ürününde hak olmaz — boş bırakın (tüketilen ürün hak değil bakiye verir)`);
|
|
578
|
+
if (behavior.grantsBalance && !PRODUCT_KEY_PATTERN.test(typeof product.balance === 'string' ? product.balance : ''))
|
|
579
|
+
add('balance', `bakiye adı ${PRODUCT_KEY_PATTERN.source} (ekranlar Balances.<ad> okur, balance-spend adımı bu adla harcar)`);
|
|
580
|
+
if (!behavior.grantsBalance && present(product.balance))
|
|
581
|
+
add('balance', `${String(product.kind)} ürününde bakiye olmaz — yalnız consumable bakiye verir`);
|
|
582
|
+
const amount = product.grantsAmount;
|
|
583
|
+
if (behavior.grantsBalance && !(typeof amount === 'number' && Number.isInteger(amount) && amount >= 1 && amount <= GRANTS_AMOUNT_MAX))
|
|
584
|
+
add('grantsAmount', `her satın almada eklenen miktar, 1–${GRANTS_AMOUNT_MAX} arası tam sayı`);
|
|
585
|
+
if (!behavior.grantsBalance && present(amount))
|
|
586
|
+
add('grantsAmount', `${String(product.kind)} ürününde miktar olmaz — yalnız consumable bakiye verir`);
|
|
587
|
+
const price = product.priceTry;
|
|
588
|
+
if (typeof price !== 'number' || !Number.isFinite(price) || price <= 0 || Math.round(price * 100) / 100 !== price)
|
|
589
|
+
add('priceTry', 'sıfırdan büyük TRY fiyatı, en çok iki ondalık (949.99)');
|
|
590
|
+
if (behavior.periodic && !PRODUCT_PERIODS.includes(product.period))
|
|
591
|
+
add('period', `abonelikte zorunlu: ${PRODUCT_PERIODS.join(' | ')}`);
|
|
592
|
+
if (behavior.periodic && product.trial !== null && product.trial !== undefined && !PRODUCT_TRIALS.includes(product.trial))
|
|
593
|
+
add('trial', `${PRODUCT_TRIALS.join(' | ')} ya da boş`);
|
|
594
|
+
if (!behavior.periodic && product.period !== null && product.period !== undefined)
|
|
595
|
+
add('period', `${String(product.kind)} ürününde süre olmaz — boş bırakın`);
|
|
596
|
+
if (!behavior.periodic && product.trial !== null && product.trial !== undefined)
|
|
597
|
+
add('trial', `${String(product.kind)} ürününde deneme olmaz — boş bırakın`);
|
|
598
|
+
const texts = isObject(product.texts) ? product.texts : {};
|
|
599
|
+
if (Object.keys(texts).length === 0)
|
|
600
|
+
add('texts', "en az bir dilde ad ve açıklama: { tr: { name, description } } (mağaza metni; ar, en, ru, tr mağazanın diline eşlenir)");
|
|
601
|
+
for (const [locale, text] of Object.entries(texts)) {
|
|
602
|
+
const name = isObject(text) && typeof text.name === 'string' ? text.name : '';
|
|
603
|
+
const description = isObject(text) && typeof text.description === 'string' ? text.description : '';
|
|
604
|
+
if (!LOCALE_PATTERN.test(locale))
|
|
605
|
+
add(`texts.${locale}`, `dil kodu ${LOCALE_PATTERN.source}`);
|
|
606
|
+
if (!name.trim() || name.length > PRODUCT_NAME_MAX_LENGTH)
|
|
607
|
+
add(`texts.${locale}.name`, `1–${PRODUCT_NAME_MAX_LENGTH} karakter (şimdi ${name.length})`);
|
|
608
|
+
if (!description.trim() || description.length > PRODUCT_DESCRIPTION_MAX_LENGTH)
|
|
609
|
+
add(`texts.${locale}.description`, `1–${PRODUCT_DESCRIPTION_MAX_LENGTH} karakter (şimdi ${description.length})`);
|
|
610
|
+
}
|
|
611
|
+
return errors;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Mağazaya gitmiş üründe değişemeyen alanlar (backend `MobileStoreProductLockedException`, 422): mağaza onları ürünün kimliği sayar.
|
|
615
|
+
* `grantsAmount` kilitli değildir (mağaza alanı değil; eski satışlar eski miktarla kalır).
|
|
616
|
+
*/
|
|
617
|
+
export function productLockedChanges(current, next) {
|
|
618
|
+
return isProductSent(current) ? ['key', 'kind', 'entitlement', 'balance'].filter(field => (current[field] ?? null) !== (next[field] ?? null)) : [];
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Satın alma hazırlık satırının ne istediği ve kimin yaptığı — anahtarlar backend altın listesi
|
|
622
|
+
* `TestData/mobile-store/purchase-readiness-rows.json` ile kilitli (`test/mobile-purchases.test.ts`). Gizli bilgi (Apple
|
|
623
|
+
* satın alma anahtarı, hesaplar) MCP'den yazılmaz; konsol satırları insanın işidir.
|
|
624
|
+
*/
|
|
625
|
+
export const PURCHASE_READINESS_HINTS = {
|
|
626
|
+
'ios.connection': "Organizasyonun Apple hesabı (App Store Connect API anahtarı) bağlı değil — Studio › Yayınla › Hesaplar (gizli bilgi, MCP yazmaz).",
|
|
627
|
+
'ios.paidAgreement': "App Store Connect › Business'ta ücretli uygulama sözleşmesi, banka ve vergi bilgisi — insanın işi. missing: son gönderim paid_agreement hatası aldı; sözleşme imzalanınca mobile_product_send { retrySendId }.",
|
|
628
|
+
'ios.iapKey': "App Store Server API satın alma anahtarı (.p8 + Key ID + Issuer ID) — Studio › Satın almalar › Mağaza hazırlığı'nda yüklenir (gizli bilgi, MCP yazmaz).",
|
|
629
|
+
'ios.notificationUrl': "App Store Server Notifications V2 adresi: copyValue'yu App Store Connect › Uygulama › App Information'a (production ve sandbox aynı adres) yapıştırın; ilk bildirim gelince ready olur. fix: kayıtlı adres farklı (detail).",
|
|
630
|
+
'android.connection': "Play hizmet hesabı bağlı değil — Studio › Yayınla › Hesaplar (gizli bilgi, MCP yazmaz).",
|
|
631
|
+
'android.merchant': "Play Console › Ödeme profili (merchant hesabı) — insanın işi.",
|
|
632
|
+
'android.billingBuild': `Google ürün açmadan önce faturalandırma izinli (BILLING) bir derlemenin Play'e yüklenmiş olmasını ister: ürün kaydedildikten sonra kabuk ≥ ${PURCHASES_SHELL_VERSION} ile mobile_build_start { platform: 'android', target: 'internal' }; ilk Android ürünü mağazada açılınca ready.`,
|
|
633
|
+
'android.permissions': "fix: son gönderim Play'den 403 aldı (play_permission) — Play Console › Kullanıcılar ve izinler'de hizmet hesabına uygulama içi ürünleri yönetme izni verin, sonra mobile_product_send { retrySendId }.",
|
|
634
|
+
'android.rtdnTopic': "Play Console › Para kazanma kurulumu › Gerçek zamanlı geliştirici bildirimleri: Pub/Sub konusu + kimlik doğrulamalı push aboneliği (uç copyValue, hizmet hesabı Zeyrek'e yüklenen Play hesabı) — insanın işi; ilk bildirim gelince ready."
|
|
635
|
+
};
|
|
636
|
+
/**
|
|
637
|
+
* FLOW-045'in katalog ayağı (araç katmanı — politika kuralı tanımı görür, kataloğu görmez): statik `purchase.value`
|
|
638
|
+
* uygulamanın ürün anahtarı değilse cihaz ve önizleme `invalid` döner. Backend akış doğrulayıcısı kataloğu okumaz
|
|
639
|
+
* (iş sahibi kararı b-04, 2026-10-23: Studio → Core bağımlılığı açılmaz; mağaza yayını ön kontrolü P-23
|
|
640
|
+
* `purchases_unknown_product` uyarır), ajanın yazımını gateway `block` ile durdurur — istek gitmez, hata katalogdaki
|
|
641
|
+
* anahtarları sayar. Şablonlu değer (`{{ }}`) ve boş değer burada susar (boşu politika kuralı `block` eder); desen dışı ya da
|
|
642
|
+
* tırnaklı değer de katalogda olamayacağı için durur — nasıl düzeltileceğini politika kuralının uyarısı söyler.
|
|
643
|
+
*/
|
|
644
|
+
export function purchaseCatalogFindings(steps, products) {
|
|
645
|
+
const keys = products.map(product => String(product.key));
|
|
646
|
+
return steps.flatMap((step) => {
|
|
647
|
+
const value = isObject(step.args) && typeof step.args.value === 'string' ? step.args.value.trim() : '';
|
|
648
|
+
if (!value || value.includes('{{') || keys.includes(value))
|
|
649
|
+
return [];
|
|
650
|
+
return [{
|
|
651
|
+
ruleId: 'FLOW-045',
|
|
652
|
+
severity: 'block',
|
|
653
|
+
message: `'${String(step.stepId ?? step.name ?? '?')}' purchase adımının ürünü '${value}' uygulamanın ürün kataloğunda yok — cihaz ve önizleme 'invalid' döner. ${keys.length > 0 ? `Katalog: ${keys.join(', ')}.` : 'Uygulamada hiç ürün yok.'} Ürünü önce mobile_product_upsert ile ekleyin ya da anahtarı katalogdakiyle değiştirin; seçimden gelen anahtar için '{{ … }}' şablonu yazın.`,
|
|
654
|
+
guide: 'ngapps://guides/mobile-steps'
|
|
655
|
+
}];
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
const stepText = (step, field) => isObject(step.args) && typeof step.args[field] === 'string' ? step.args[field].trim() : '';
|
|
659
|
+
/** Katalogdaki tüketilen ürünlerin bakiye adları (sırayla, tekrarsız) */
|
|
660
|
+
export const catalogBalances = (products) => [...new Set(products.filter(product => product.kind === 'consumable').map(product => String(product.balance ?? '')).filter(balance => balance !== ''))];
|
|
661
|
+
/** Katalogdaki hak adları (abonelik ve tek seferlik ürünlerin `entitlement`'ı) */
|
|
662
|
+
export const catalogEntitlements = (products) => [...new Set(products.filter(product => product.kind !== 'consumable').map(product => String(product.entitlement ?? '')).filter(entitlement => entitlement !== ''))];
|
|
663
|
+
/**
|
|
664
|
+
* FLOW-046'nın katalog ayağı (araç katmanı — politika kuralı tanımı görür, kataloğu görmez), FLOW-045'in `purchaseCatalogFindings`
|
|
665
|
+
* emsali: statik `balance-spend.value` uygulamanın tüketilen ürünlerinden birinin bakiye adı değilse sunucu `invalid` döner
|
|
666
|
+
* (katalogda olmayan bakiye adı 422). Şablonlu (`{{ }}`) ve boş değer burada susar (boşu politika kuralı `block` eder).
|
|
667
|
+
* Katalog `block` bulgusu üretir; çağıran yazımdan ÖNCE `applyPolicyMode` + `enforcePolicy` ile uygular.
|
|
668
|
+
*/
|
|
669
|
+
export function balanceCatalogFindings(steps, products) {
|
|
670
|
+
const balances = catalogBalances(products);
|
|
671
|
+
return steps.flatMap((step) => {
|
|
672
|
+
const value = stepText(step, 'value');
|
|
673
|
+
if (!value || value.includes('{{') || balances.includes(value))
|
|
674
|
+
return [];
|
|
675
|
+
return [{
|
|
676
|
+
ruleId: 'FLOW-046',
|
|
677
|
+
severity: 'block',
|
|
678
|
+
message: `'${String(step.stepId ?? step.name ?? '?')}' balance-spend adımının bakiyesi '${value}' uygulamanın tüketilen ürünlerinde yok — sunucu 'invalid' döner. ${balances.length > 0 ? `Katalogdaki bakiyeler: ${balances.join(', ')}.` : 'Uygulamada tüketilen ürün yok (abonelik ve tek seferlik ürün bakiye vermez).'} Önce mobile_product_upsert { kind: 'consumable', balance: '${value}', grantsAmount } ile ürünü ekleyin ya da adı katalogdakiyle değiştirin; seçimden gelen ad için '{{ … }}' şablonu yazın.`,
|
|
679
|
+
guide: 'ngapps://guides/mobile-steps'
|
|
680
|
+
}];
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Workflow `balance` adımı için aynı katalog denetimi (FLOW-046, workflow yüzeyi): girdi `rule.expression` nesne literalidir
|
|
685
|
+
* (`{ balance: 'kredi', amount: 10, idempotencyKey: … }`), `balanceOperation` işlemi söyler. Statik `balance` (spend/grant/read) tüketilen
|
|
686
|
+
* ürünün bakiye adı, statik `entitlement` (entitled) bir ürünün hakkı olmalı — olmayan ad sunucuda hata dalına düşer.
|
|
687
|
+
* `spend`/`grant` `idempotencyKey` vermiyorsa her adım çalışması yeni satır yazar (b-10, tekrar güvenliği varsayılan değildir): uyarı.
|
|
688
|
+
*/
|
|
689
|
+
export function workflowBalanceFindings(sites, products) {
|
|
690
|
+
const balances = catalogBalances(products);
|
|
691
|
+
const entitlements = catalogEntitlements(products);
|
|
692
|
+
const literal = (expression, key) => new RegExp(`(?:^|[{,\\s])${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(expression)?.[2];
|
|
693
|
+
return sites.flatMap(({ label, rule }) => {
|
|
694
|
+
const operation = String(rule.balanceOperation ?? '');
|
|
695
|
+
const expression = typeof rule.expression === 'string' ? rule.expression : '';
|
|
696
|
+
const findings = [];
|
|
697
|
+
const guide = 'ngapps://guides/workflow';
|
|
698
|
+
if (operation === 'entitled') {
|
|
699
|
+
const entitlement = literal(expression, 'entitlement');
|
|
700
|
+
if (entitlement && !entitlements.includes(entitlement)) {
|
|
701
|
+
findings.push({ ruleId: 'FLOW-046', severity: 'block', message: `'${label}' balance adımı (entitled) '${entitlement}' hakkını soruyor ama uygulamanın hiçbir ürünü bu hakkı vermiyor — cevap hep false olur. ${entitlements.length > 0 ? `Katalogdaki haklar: ${entitlements.join(', ')}.` : 'Uygulamada hak veren ürün yok.'} Hak adı mobile_product_list'teki ürünün entitlement alanıdır.`, guide });
|
|
702
|
+
}
|
|
703
|
+
return findings;
|
|
704
|
+
}
|
|
705
|
+
const balance = literal(expression, 'balance');
|
|
706
|
+
if (balance && !balances.includes(balance)) {
|
|
707
|
+
findings.push({ ruleId: 'FLOW-046', severity: 'block', message: `'${label}' balance adımı (${operation || '?'}) '${balance}' bakiyesini kullanıyor ama uygulamanın tüketilen ürünlerinde yok — sunucu geçersiz girdi ile hata dalına düşer. ${balances.length > 0 ? `Katalogdaki bakiyeler: ${balances.join(', ')}.` : 'Uygulamada tüketilen ürün yok.'} Önce mobile_product_upsert { kind: 'consumable', balance } ile ürünü ekleyin ya da adı katalogdakiyle değiştirin.`, guide });
|
|
708
|
+
}
|
|
709
|
+
if ((operation === 'spend' || operation === 'grant') && !/(?:^|[{,\s])idempotencyKey\s*:/.test(expression)) {
|
|
710
|
+
findings.push({ ruleId: 'FLOW-046', severity: 'warn', message: `'${label}' balance adımı (${operation}) idempotencyKey vermiyor: anahtar yoksa her adım çalışması YENİ satır yazar — yeniden koşabilen akış (yeniden deneme, döngü, aynı olayın iki kez gelmesi) bakiyeyi iki kez ${operation === 'spend' ? 'düşer' : 'artırır'}. Olayı tanımlayan bir değer verin: idempotencyKey: input.davetId.`, guide });
|
|
711
|
+
}
|
|
712
|
+
return findings;
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* MOB-036 (K9): tüketilen ürünü olan ama kullanıcı girişi (access.session.create uçlu microservice) olmayan uygulamada bakiye
|
|
717
|
+
* yalnız kuruluma yazılır; tüketilen ürün mağazada "geri yüklenemez" — telefon değişince ya da uygulama silinip kurulunca bakiye
|
|
718
|
+
* gider. `microservices` okunamadıysa `undefined`: bilmediğimizi uyarı diye söylemeyiz.
|
|
719
|
+
*/
|
|
720
|
+
export function balanceWithoutSignInWarnings(products, microservices) {
|
|
721
|
+
if (!microservices || catalogBalances(products).length === 0)
|
|
722
|
+
return [];
|
|
723
|
+
if (microservices.some(service => service.systemOperation === 'access.session.create'))
|
|
724
|
+
return [];
|
|
725
|
+
return [`MOB-036: Bu uygulamada tüketilen ürün (${catalogBalances(products).join(', ')}) var ama kullanıcı girişi yok — bakiye yalnız bu telefonun kurulumuna yazılır. Tüketilen ürün mağazada "Geri yükle" ile geri gelmez: kullanıcı telefon değiştirir ya da uygulamayı silip kurarsa satın aldığı kredi gider (K9). Girişli uygulamada bakiye hesaba taşınır: access.session.create uçlu bir giriş (platform_guide('access')) kurun ya da kullanıcıya bakiyenin telefona bağlı olduğunu açıkça söyleyin; satış yapmadan önce karar verin.`];
|
|
726
|
+
}
|
|
@@ -1669,11 +1669,12 @@ const SCREEN_EVENT_NAMES = ['mount', 'destroy'];
|
|
|
1669
1669
|
* koşmaz. Web her gezinmede sayfayı baştan kurar (`Runtime.svelte` focus'u tanımaz), layout'un focus'u
|
|
1670
1670
|
* yoktur. Ayna: `ng-apps-ui-native` `SCREEN_LIFECYCLE_EVENTS` (parite: `test/page-meta.test.ts`).
|
|
1671
1671
|
*/
|
|
1672
|
-
const MOBILE_PAGE_EVENT_NAMES = [...SCREEN_EVENT_NAMES, 'focus'];
|
|
1672
|
+
const MOBILE_PAGE_EVENT_NAMES = [...SCREEN_EVENT_NAMES, 'focus', 'offlineQueueChange'];
|
|
1673
1673
|
const SCREEN_EVENT_HINTS = {
|
|
1674
1674
|
mount: 'mount (yüklendiğinde)',
|
|
1675
1675
|
destroy: 'destroy (ayrılırken; temizlik akışları için)',
|
|
1676
|
-
focus: 'focus (ekran her göründüğünde — ilk açılış ve geri dönüş; listeyi yeniden okumak için)'
|
|
1676
|
+
focus: 'focus (ekran her göründüğünde — ilk açılış ve geri dönüş; listeyi yeniden okumak için)',
|
|
1677
|
+
offlineQueueChange: 'offlineQueueChange (servis yazma kuyruğu değişince — ağsız kayıt girdi, gönderildi, reddedildi; listeyi yeniden okumak için, sayaç Device.offline)'
|
|
1677
1678
|
};
|
|
1678
1679
|
export function screenEventNames(kind, platform) {
|
|
1679
1680
|
return kind === 'page' && platform === 'mobile' ? MOBILE_PAGE_EVENT_NAMES : SCREEN_EVENT_NAMES;
|
|
@@ -1688,7 +1689,7 @@ export function invalidScreenEventNames(events, code, names = SCREEN_EVENT_NAMES
|
|
|
1688
1689
|
.map(event => String(event.name ?? ''))
|
|
1689
1690
|
.filter(name => name !== '' && !names.includes(name))
|
|
1690
1691
|
.map(name => MOBILE_PAGE_EVENT_NAMES.includes(name)
|
|
1691
|
-
? `${code}: '${name}' yalnız mobil uygulamanın SAYFASINDA vardır (layout'ta ve web sayfasında yok; web sayfayı her gezinmede baştan kurar, mount yeter) — burada geçerli adlar: ${valid}.`
|
|
1692
|
+
? `${code}: '${name}' yalnız mobil uygulamanın SAYFASINDA vardır (layout'ta ve web sayfasında yok; ${name === 'offlineQueueChange' ? 'web barındırıcısı servis yazma kuyruğu tutmaz, olay hiç yayılmaz' : 'web sayfayı her gezinmede baştan kurar, mount yeter'}) — burada geçerli adlar: ${valid}.`
|
|
1692
1693
|
: `${code}: sayfa/layout düzeyinde '${name}' diye bir olay YOK — geçerli adlar: ${valid}. Platform kaydı kabul eder ama bağ sessizce ölü kalır.`);
|
|
1693
1694
|
}
|
|
1694
1695
|
const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -116,7 +116,7 @@ export const mobileProfile = Object.freeze({
|
|
|
116
116
|
},
|
|
117
117
|
style: { freeStyle: false, hoverStyle: false, resolver: 'native' },
|
|
118
118
|
steps: {
|
|
119
|
-
registered: new Set([...ENGINE_STEP_TYPES, 'alert', 'confirm', 'redirect', 'navigate', 'storage', 'setcookie', 'download', 'share', 'haptic', 'location', 'notification-permission', 'notification-schedule', 'notification-cancel', 'permission', 'open-settings', 'ad-show', 'back', 'biometric-auth', 'scan-code']),
|
|
119
|
+
registered: new Set([...ENGINE_STEP_TYPES, 'alert', 'confirm', 'redirect', 'navigate', 'storage', 'setcookie', 'download', 'share', 'haptic', 'location', 'notification-permission', 'notification-schedule', 'notification-cancel', 'permission', 'open-settings', 'ad-show', 'back', 'biometric-auth', 'scan-code', 'purchase', 'restore-purchases', 'balance-spend']),
|
|
120
120
|
aliases: STEP_ALIASES,
|
|
121
121
|
infoOnly: new Set(['download', 'setcookie'])
|
|
122
122
|
},
|
|
@@ -144,6 +144,24 @@ export const MOBILE_RULES = {
|
|
|
144
144
|
severity: 'warn',
|
|
145
145
|
sourceRef: 'docs/adr/0023-mobil-cevrimdisi-yerel-kopya-ve-uygulama-paketi.md K13, K8; zeyrek-frontend/packages/ng-apps-ui-native/src/shell/mobile-offline.ts (okuyucu; ayna mobileOffline.ts); ng-apps-flow tasks/microservice/microservice.ts (error.network yalnız veri önbelleği açıkken)',
|
|
146
146
|
summary: "properties.mobile.offline okunamıyor (nesne değil, boolean olmayan screens/data/banner, 1 | 7 | 30 dışı maxAgeDays, tanınmayan anahtar) — cihaz varsayılana düşer; akış ağ hatasını error.status 503 ile ayırıyor (bilgi: error.network)"
|
|
147
|
+
},
|
|
148
|
+
'MOB-034': {
|
|
149
|
+
id: 'MOB-034',
|
|
150
|
+
severity: 'warn',
|
|
151
|
+
sourceRef: 'docs/adr/0027-uygulama-ici-satin-alma.md madde 6 (telefon değişince Geri yükle); App Store Review Guidelines 3.1.1 (geri yüklenebilir satın almada geri yükleme yolu); @ng-apps/flow restore-purchases',
|
|
152
|
+
summary: 'Satın alma adımı olan ekranda hiçbir akışta restore-purchases yok — mağaza incelemesi geri yükleme yolunu satın alma ekranında arar'
|
|
153
|
+
},
|
|
154
|
+
'MOB-036': {
|
|
155
|
+
id: 'MOB-036',
|
|
156
|
+
severity: 'warn',
|
|
157
|
+
sourceRef: 'docs/adr/0027-uygulama-ici-satin-alma.md bakiye bölümü (K9: tüketilen ürün mağazada geri yüklenemez, bakiye telefon değişince yalnız hesapla taşınır); plans/mobil-uygulama-ici-satin-alma README K9; backend 04-bakiye-defteri.md (restore tüketileni geri yüklemez)',
|
|
158
|
+
summary: 'Tüketilen ürünü olan uygulamada kullanıcı girişi (access.session.create) yok — bakiye yalnız kuruluma yazılır, telefon değişince ya da uygulama silinip kurulunca kaybolur'
|
|
159
|
+
},
|
|
160
|
+
'MOB-042': {
|
|
161
|
+
id: 'MOB-042',
|
|
162
|
+
severity: 'warn',
|
|
163
|
+
sourceRef: 'App Store Review Guidelines 3.1.2 (abonelik bilgilendirmesi: süre, yenileme, iptal; kullanım koşulları ve gizlilik bağlantısı); Play abonelik politikası; Studio teklif ekranı şablonu (features/purchases/paywall-template.functions.ts: yenileme metni + gizlilik bağlantısı)',
|
|
164
|
+
summary: 'Abonelik satan sayfada otomatik yenileme metni ya da koşullar/gizlilik bağlantısı yok'
|
|
147
165
|
}
|
|
148
166
|
};
|
|
149
167
|
/**
|
|
@@ -260,3 +278,64 @@ export function mobileComponentNotes(slug) {
|
|
|
260
278
|
...(wave === undefined ? [] : [`Mobil bileşen dalgası: ${wave} (plans/mobil-uygulama-expo §11).`])
|
|
261
279
|
];
|
|
262
280
|
}
|
|
281
|
+
const recordsOf = (value) => (Array.isArray(value) ? value : []).filter((entry) => typeof entry === 'object' && entry !== null && !Array.isArray(entry));
|
|
282
|
+
const flowSteps = (flow) => [...recordsOf(flow.steps), ...recordsOf(flow.errorSteps)];
|
|
283
|
+
/**
|
|
284
|
+
* MOB-034 — yazılan akışta `purchase` var ve bu ekranın (sayfa, layout ya da widget) hiçbir akışında `restore-purchases`
|
|
285
|
+
* yok. Uygulama genelinde arayan uç yok (bir uygulamada yüzlerce sayfa var); denetim ekran düzeyindedir ve Apple
|
|
286
|
+
* incelemesinin baktığı yer de satın alma ekranıdır. Geri yükleme bilinçli olarak başka bir ekrandaysa (Ayarlar) uyarı yok
|
|
287
|
+
* sayılır — mesaj bunu söyler. `otherFlows` ekranın kayıtlı öteki akışlarıdır (yazılanın eski hâli hariç).
|
|
288
|
+
* Ekran yalnız tüketilen ürün satıyorsa uyarı susar: tüketilen ürün mağazada geri yüklenmez (K9). `products` okunamadıysa
|
|
289
|
+
* ürünün türünü bilmiyoruz, uyarı kalır.
|
|
290
|
+
*/
|
|
291
|
+
export function restorePathWarnings(written, otherFlows, products) {
|
|
292
|
+
const purchases = written.filter(step => step.type === 'purchase');
|
|
293
|
+
if (purchases.length === 0)
|
|
294
|
+
return [];
|
|
295
|
+
const consumables = (products ?? []).filter(product => product.kind === 'consumable').map(product => String(product.key));
|
|
296
|
+
const sold = [...purchases, ...otherFlows.flatMap(flowSteps).filter(step => step.type === 'purchase')];
|
|
297
|
+
const onlyConsumables = consumables.length > 0 && sold.every((step) => {
|
|
298
|
+
const value = typeof step.args?.value === 'string' ? String(step.args.value).trim() : '';
|
|
299
|
+
return value.includes('{{') ? consumables.length === products?.length : consumables.includes(value);
|
|
300
|
+
});
|
|
301
|
+
if (onlyConsumables)
|
|
302
|
+
return [];
|
|
303
|
+
if ([...written, ...otherFlows.flatMap(flowSteps)].some(step => step.type === 'restore-purchases'))
|
|
304
|
+
return [];
|
|
305
|
+
return [`${MOBILE_RULES['MOB-034'].id}: '${String(purchases[0]?.stepId ?? purchases[0]?.name ?? '?')}' satın alma adımının ekranında hiçbir akışta restore-purchases yok — telefon değiştiren ya da uygulamayı yeniden kuran kullanıcı hakkını yalnız Geri yükle ile alır ve App Store incelemesi satın alma ekranında bu yolu arar. Ekrana bir 'Satın almaları geri yükle' düğmesi ve ona restore-purchases adımlı bir akış ekleyin (always: Entitlements güncellenir; error dalında Steps.<ad>.error.status). Uyarı yalnız satın alma akışı yazılırken denetlenir: geri yükleme akışını önce yazın — sonra yazılırsa bu uyarı eskimiş kalır, yok sayın. Geri yükleme bilinçli olarak başka bir ekrandaysa (Ayarlar) da yok sayın.`];
|
|
306
|
+
}
|
|
307
|
+
/** Otomatik yenileme cümlesi, dört platform dilinde (tr, en, ru, ar) */
|
|
308
|
+
const RENEWAL_TEXT = /yenile[nm]|renew|продл|تجدي|يتجدد|تتجدد/i;
|
|
309
|
+
/** Çeviri anahtarıyla yazılmış metin (`{{ t('…') }}`, prop türü translation): gateway metni okuyamaz */
|
|
310
|
+
const TRANSLATED_TEXT = /\bt\(\s*\\?['"]|"type"\s*:\s*"translation"/;
|
|
311
|
+
/** Dış bağlantı: düğüm prop'u `href` dolu, metindeki bağlantı (markdown, html) */
|
|
312
|
+
const LINK_IN_PAGE = /"href"\s*:\s*(?:"[^"]+"|\{[^{}]*"(?:data|ref)"\s*:\s*"[^"]+")|\]\(https?:|href=\\?["']?https?:/;
|
|
313
|
+
/**
|
|
314
|
+
* MOB-042 — abonelik satan sayfa (statik anahtarı katalogda abonelik olan ya da şablonlu değerle satan ve katalogda
|
|
315
|
+
* abonelik bulunan `purchase` adımı) yenileme koşulunu ve koşullar/gizlilik bağlantısını göstermiyor. Kural metinseldir:
|
|
316
|
+
* sayfa ağacının JSON'unda yenileme sözcüğü ve dolu bir `href` (ya da metin içi bağlantı) aranır; ekranın akışlarından
|
|
317
|
+
* dışarı açan `redirect` (`https://…`) de bağlantı sayılır. Çeviri anahtarıyla yazılmış sayfada metin okunamaz, yenileme
|
|
318
|
+
* denetimi susar (bilmediğimizi uyarı diye söylemeyiz).
|
|
319
|
+
*/
|
|
320
|
+
export function subscriptionDisclosureWarnings(written, products, pageNodes, hostFlows) {
|
|
321
|
+
const purchases = written.filter(step => step.type === 'purchase');
|
|
322
|
+
const subscriptions = products.filter(product => product.kind === 'subscription').map(product => String(product.key));
|
|
323
|
+
const sells = purchases.some((step) => {
|
|
324
|
+
const value = typeof step.args?.value === 'string' ? String(step.args.value).trim() : '';
|
|
325
|
+
return value.includes('{{') ? subscriptions.length > 0 : subscriptions.includes(value);
|
|
326
|
+
});
|
|
327
|
+
if (!sells)
|
|
328
|
+
return [];
|
|
329
|
+
// Türkçe büyük harf (`YENİLENİR`) `i` bayrağıyla eşleşmez: metin önce Türkçe küçük harfe çevrilir.
|
|
330
|
+
const page = JSON.stringify(pageNodes ?? null);
|
|
331
|
+
const lowered = page.toLocaleLowerCase('tr');
|
|
332
|
+
const opensOutside = [...written, ...hostFlows.flatMap(flowSteps)]
|
|
333
|
+
.some(step => step.type === 'redirect' && /^https?:\/\//.test(String(step.args?.path ?? '').trim().replace(/^['"`]/, '')));
|
|
334
|
+
const missing = [
|
|
335
|
+
...(RENEWAL_TEXT.test(lowered) || RENEWAL_TEXT.test(page) || TRANSLATED_TEXT.test(page) ? [] : ['otomatik yenileme metni (dönem sonunda yenilenir, mağaza hesabından iptal edilir)']),
|
|
336
|
+
...(LINK_IN_PAGE.test(page) || opensOutside ? [] : ['kullanım koşulları / gizlilik bağlantısı (href taşıyan düğme ya da metin)'])
|
|
337
|
+
];
|
|
338
|
+
if (missing.length === 0)
|
|
339
|
+
return [];
|
|
340
|
+
return [`${MOBILE_RULES['MOB-042'].id}: bu sayfa abonelik satıyor ama ekranda ${missing.join(' ve ')} yok — App Store (3.1.2) ve Play abonelik politikası satın alma ekranında süreyi, fiyatı, otomatik yenilemeyi, iptal yolunu ve koşullar/gizlilik bağlantısını ister; eksik ekran incelemeden döner. Fiyat ve süre Products.<anahtar>.price / .period ile yazılır; gizlilik adresi mobile_store_get urls.privacyPolicy. Kalıp: platform_guide('mobile-steps') §Satın alma tarifi. Metin çeviri anahtarıyla yazıldıysa gateway okuyamaz — çeviride varsa yok sayın.`];
|
|
341
|
+
}
|