237 lines
20 KiB
TypeScript
237 lines
20 KiB
TypeScript
// DEV-ONLY FIXTURE DATA for the screens that cover the rest of the CLI (prices, store art, growth, app info). Made-up
|
|
// numbers around the two demo apps; drawn SVG stand-ins for the rendered screenshots. Only src/dev/fixtures.ts imports
|
|
// this module, and only fixture mode (dev builds) loads that.
|
|
import type { AnalyticsCompare, AsoAuditView, DeclarationsResult, ListingText, PpoStatus, PriceRowData, PricesCurrent, PricesDiff, RendersResult, RenderTile } from '../api';
|
|
|
|
const HOME = '/home/you';
|
|
const NOW = Date.now();
|
|
const day = (daysAgo: number) => new Date(NOW - daysAgo * 86_400_000).toISOString().slice(0, 10);
|
|
const iso = (minutesAgo: number) => new Date(NOW - minutesAgo * 60_000).toISOString();
|
|
|
|
// ---- prices: products x territories on both stores ----
|
|
const REGIONS: [string, string, number, number][] = [
|
|
// region, currency, factor vs USD (Apple), factor (Play)
|
|
['US', 'USD', 1, 1], ['GB', 'GBP', 0.99, 0.95], ['DE', 'EUR', 1.09, 1.05], ['FR', 'EUR', 1.09, 1.05], ['IT', 'EUR', 1.09, 1.05], ['ES', 'EUR', 1.09, 1.05],
|
|
['NL', 'EUR', 1.09, 1.05], ['JP', 'JPY', 150, 148], ['KR', 'KRW', 1400, 1350], ['BR', 'BRL', 5, 4.1], ['IN', 'INR', 90, 79], ['TR', 'TRY', 30, 23],
|
|
['MX', 'MXN', 18, 17], ['CA', 'CAD', 1.39, 1.35], ['AU', 'AUD', 1.59, 1.55], ['PL', 'PLN', 4.3, 4.1], ['SE', 'SEK', 11, 10.5], ['ID', 'IDR', 15000, 15000],
|
|
['SA', 'SAR', 3.75, 3.7], ['AE', 'AED', 3.67, 3.6], ['CH', 'CHF', 1, 0.95], ['ZA', 'ZAR', 18, 17], ['TH', 'THB', 35, 33], ['VN', 'VND', 25000, 23000],
|
|
];
|
|
const BASE: Record<string, number> = { coins_small: 0.99, coins_large: 4.99, no_ads: 2.99, starter_pack: 1.99, tip_jar: 1.99 };
|
|
const round = (v: number, cur: string) => (['JPY', 'KRW', 'IDR', 'VND'].includes(cur) ? Math.round(v / 10) * 10 : ['INR', 'TRY', 'THB', 'ZAR', 'MXN', 'SEK'].includes(cur) ? Math.round(v) - 0.01 : Math.round(v * 100) / 100);
|
|
function priceRows(products: string[], missingPlay: string[]): PriceRowData[] {
|
|
const rows: PriceRowData[] = [];
|
|
for (const p of products) {
|
|
for (const [region, cur, fa, fp] of REGIONS) {
|
|
const manual = region === 'US' || (p === 'starter_pack' && region === 'TR') || (p === 'coins_large' && region === 'IN');
|
|
rows.push({ product: p, store: 'apple', region, currency: cur, price: round(BASE[p]! * fa * (region === 'TR' && p === 'starter_pack' ? 1.5 : 1), cur), kind: manual ? 'manual' : 'auto' });
|
|
if (!missingPlay.includes(p)) rows.push({ product: p, store: 'play', region, currency: cur, price: round(BASE[p]! * fp, cur) });
|
|
}
|
|
}
|
|
rows.push({ product: 'no_ads', store: 'apple', region: 'BR', currency: 'BRL', price: 19.9, startDate: day(-12), kind: 'manual' });
|
|
return rows;
|
|
}
|
|
export const PRICES: Record<string, PricesCurrent> = {
|
|
'p-prism': { csv: `${HOME}/apps/prism-puddle/store/prices.csv`, products: ['coins_small', 'coins_large', 'no_ads', 'starter_pack'], stores: ['apple', 'play'],
|
|
missing: [{ product: 'starter_pack', store: 'play' }], rows: priceRows(['coins_small', 'coins_large', 'no_ads', 'starter_pack'], ['starter_pack']) },
|
|
'p-tidy': { csv: `${HOME}/apps/tidy-lists/store/prices.csv`, products: ['tip_jar'], stores: ['apple', 'play'], missing: [], rows: priceRows(['tip_jar'], []) },
|
|
};
|
|
export const PRICE_DIFF: Record<string, PricesDiff> = {
|
|
'p-prism': {
|
|
csv: `${HOME}/apps/prism-puddle/store/prices.csv`, changed: ['coins_small|play', 'coins_small|apple', 'no_ads|apple', 'coins_large|play'],
|
|
diff: [
|
|
{ product: 'coins_small', store: 'play', region: 'TR', currency: 'TRY', from: 21.99, to: 24.99, change: 'change' },
|
|
{ product: 'coins_small', store: 'play', region: 'BR', currency: 'BRL', from: 3.99, to: 4.49, change: 'change' },
|
|
{ product: 'coins_small', store: 'apple', region: 'TR', currency: 'TRY', from: 29.99, to: 24.99, change: 'change' },
|
|
{ product: 'no_ads', store: 'apple', region: 'BR', startDate: day(-14), currency: 'BRL', from: 14.9, to: 12.9, change: 'change' },
|
|
{ product: 'coins_large', store: 'play', region: 'IN', currency: 'INR', from: 393, to: 449, change: 'change' },
|
|
{ product: 'coins_large', store: 'play', region: 'KR', currency: 'KRW', from: null, to: 6500, change: 'new' },
|
|
{ product: 'coins_large', store: 'apple', region: 'US', currency: 'USD', from: 4.99, to: 4.99, change: 'same' },
|
|
],
|
|
},
|
|
'p-tidy': { csv: `${HOME}/apps/tidy-lists/store/prices.csv`, changed: [], diff: [{ product: 'tip_jar', store: 'apple', region: 'US', currency: 'USD', from: 1.99, to: 1.99, change: 'same' }] },
|
|
};
|
|
|
|
// ---- declarations ----
|
|
const PRISM_CSV = 'Question ID (machine readable),Response ID (machine readable),Response value,Answer requirement,Human-friendly question label\nPSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA,,TRUE,REQUIRED,Does your app collect or share any of the required user data types?\n';
|
|
export const DECLARATIONS: Record<string, DeclarationsResult> = {
|
|
'p-prism': {
|
|
profile: 'game', stores: { apple: true, play: true },
|
|
dataSafety: {
|
|
collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_NONE'], partialDeletion: null, extra: 0, csv: PRISM_CSV, rows: 431,
|
|
types: [
|
|
{ id: 'PSL_DEVICE_ID', category: 'identifiers', shared: true, ephemeral: false, optional: false, collect: ['advertising', 'analytics'], share: ['advertising'] },
|
|
{ id: 'PSL_APPROX_LOCATION', category: 'location', shared: true, ephemeral: false, optional: false, collect: ['advertising'], share: ['advertising'] },
|
|
{ id: 'PSL_USER_INTERACTION', category: 'app activity', shared: false, ephemeral: false, optional: false, collect: ['analytics', 'functionality'], share: [] },
|
|
{ id: 'PSL_PURCHASE_HISTORY', category: 'financial', shared: false, ephemeral: false, optional: false, collect: ['functionality'], share: [] },
|
|
{ id: 'PSL_CRASH_LOGS', category: 'app performance', shared: false, ephemeral: false, optional: false, collect: ['analytics'], share: [] },
|
|
],
|
|
uploaded: { at: iso(60 * 24 * 9), changes: [
|
|
{ question: 'PSL_DATA_TYPES_LOCATION', response: 'PSL_APPROX_LOCATION', from: '', to: 'TRUE' },
|
|
{ question: 'PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:PSL_DATA_USAGE_COLLECTION_AND_SHARING', response: 'PSL_DATA_USAGE_ONLY_SHARED', from: '', to: 'TRUE' },
|
|
{ question: 'PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_SHARING_PURPOSE', response: 'PSL_ADVERTISING', from: '', to: 'TRUE' },
|
|
] },
|
|
},
|
|
appPrivacy: { fromDataSafety: true, items: [
|
|
{ apple: 'Device ID', category: 'Identifiers', from: ['PSL_DEVICE_ID'], purposes: ['Analytics', 'Third-Party Advertising'], thirdParty: true },
|
|
{ apple: 'Coarse Location', category: 'Location', from: ['PSL_APPROX_LOCATION'], purposes: ['Third-Party Advertising'], thirdParty: true },
|
|
{ apple: 'Product Interaction', category: 'Usage Data', from: ['PSL_USER_INTERACTION'], purposes: ['Analytics', 'App Functionality'], thirdParty: false },
|
|
{ apple: 'Purchase History', category: 'Purchases', from: ['PSL_PURCHASE_HISTORY'], purposes: ['App Functionality'], thirdParty: false },
|
|
{ apple: 'Crash Data', category: 'Diagnostics', from: ['PSL_CRASH_LOGS'], purposes: ['Analytics'], thirdParty: false },
|
|
] },
|
|
appInfo: { categories: { primary: 'GAMES', primarySubOne: 'GAMES_PUZZLE', primarySubTwo: 'GAMES_CASUAL' }, ageRating: [], contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT', free: true },
|
|
encryption: false,
|
|
review: { signInRequired: false, accounts: false, contact: 's***@example.com', demoAccount: null, notes: 'Offline puzzle game, no sign-in. Remove Ads is restored from Settings > Restore purchases. The daily drop unlocks at midnight local time.', notesBytes: 148, notesMax: 4000 },
|
|
ask: [
|
|
{ area: 'appInfo', id: 'ai-age', text: 'No age rating answers: storebound would answer every content question "none".', fix: 'Confirm that is true (ads, user-generated content, chat, loot boxes, web access count), or set apple.info.ageRating overrides.' },
|
|
{ area: 'appPrivacy', id: 'ap-linked', text: 'App Privacy asks per data type whether it is linked to the user\'s identity and whether it is used for tracking; the config cannot prove either.', fix: 'Answer both in App Store Connect > App Privacy for each type below (tracking also needs App Tracking Transparency in the app).' },
|
|
],
|
|
},
|
|
'p-tidy': {
|
|
profile: 'app', stores: { apple: true, play: true },
|
|
dataSafety: {
|
|
collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'], partialDeletion: false, extra: 1, csv: PRISM_CSV, rows: 432,
|
|
types: [
|
|
{ id: 'PSL_EMAIL', category: 'personal', shared: false, ephemeral: false, optional: false, collect: ['functionality', 'account'], share: [] },
|
|
{ id: 'PSL_USER_ACCOUNT', category: 'personal', shared: false, ephemeral: false, optional: false, collect: ['functionality', 'account'], share: [] },
|
|
{ id: 'PSL_USER_GENERATED_CONTENT', category: 'app activity', shared: false, ephemeral: false, optional: false, collect: ['functionality'], share: [] },
|
|
],
|
|
uploaded: { at: iso(60 * 24 * 3), changes: [] },
|
|
},
|
|
appPrivacy: { fromDataSafety: true, items: [
|
|
{ apple: 'Email Address', category: 'Contact Info', from: ['PSL_EMAIL'], purposes: ['App Functionality'], thirdParty: false },
|
|
{ apple: 'User ID', category: 'Identifiers', from: ['PSL_USER_ACCOUNT'], purposes: ['App Functionality'], thirdParty: false },
|
|
{ apple: 'Other User Content', category: 'User Content', from: ['PSL_USER_GENERATED_CONTENT'], purposes: ['App Functionality'], thirdParty: false },
|
|
] },
|
|
appInfo: { categories: { primary: 'PRODUCTIVITY' }, ageRating: [{ question: 'userGeneratedContent', value: 'true' }], contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT', free: true },
|
|
encryption: null,
|
|
review: { signInRequired: true, accounts: true, contact: 's***@example.com', demoAccount: 'd***@example.com', notes: 'Sign in with the demo account. Lists are shared from the share button on a list.\n\nSign-in: use the demo account above.\n\nAccount deletion: Settings > Account > Delete account', notesBytes: 176, notesMax: 4000 },
|
|
ask: [
|
|
{ area: 'encryption', id: 'enc-unset', text: 'Export compliance is not answered (apple.usesNonExemptEncryption).', fix: 'Decide it with the export-compliance skill; false only when the app uses no encryption beyond the OS. storebound never guesses it.' },
|
|
{ area: 'appPrivacy', id: 'ap-linked', text: 'App Privacy asks per data type whether it is linked to the user\'s identity and whether it is used for tracking; the config cannot prove either.', fix: 'Answer both in App Store Connect > App Privacy for each type below.' },
|
|
],
|
|
},
|
|
};
|
|
export const LISTING_TEXT: Record<string, ListingText> = {
|
|
'p-prism': { play: { path: `${HOME}/apps/prism-puddle/store/listing.md`, text: '## en-US\n\n### title\n```\nPrism Puddle\n```\n' }, apple: { path: `${HOME}/apps/prism-puddle/store/appstore.md`, text: '## en-US\n\n### name\n```\nPrism Puddle\n```\n' } },
|
|
'p-tidy': { play: { path: `${HOME}/apps/tidy-lists/store/listing.md`, text: '## en-US\n' }, apple: { path: `${HOME}/apps/tidy-lists/store/appstore.md`, text: '## en-US\n' } },
|
|
};
|
|
|
|
// ---- store art: stand-ins for the renders, PNGs from tools/fixture-art.ts (four scenes in four shapes) ----
|
|
const SHAPE: Record<string, string> = { ios69: 'phone', ios65: 'phone', ipad13: 'ipad', play: 'play', feature: 'feature', poster: 'phone' };
|
|
function shot(scene: number, size: string, variant: string): string {
|
|
const order = variant === 'b' ? [1, 0, 3, 2] : [0, 1, 2, 3];
|
|
return new URL(`./art/scene${order[scene % 4]! + 1}-${SHAPE[size] ?? 'phone'}.png`, import.meta.url).href;
|
|
}
|
|
const SIZES: [string, string, 'apple' | 'play', number, number][] = [
|
|
['ios69', 'App Store iPhone 6.9"', 'apple', 1320, 2868], ['ios65', 'App Store iPhone 6.5"', 'apple', 1284, 2778], ['ipad13', 'App Store iPad 13"', 'apple', 2064, 2752],
|
|
['play', 'Google Play phone', 'play', 1080, 1920], ['feature', 'Google Play feature graphic', 'play', 1024, 500],
|
|
];
|
|
const ART: Record<string, string> = {};
|
|
function renders(langs: string[], variants: string[]): RendersResult {
|
|
let images = 0, errors = 0, warnings = 0, videos = 0;
|
|
const out: RendersResult = { title: 'store renders', generated: day(0) + ' 09:12', dir: 'store/renders', variants: [], totals: { images: 0, videos: 0, errors: 0, warnings: 0 } };
|
|
for (const v of ['', ...variants]) {
|
|
out.variants.push({ id: v, label: v ? `variant ${v}` : 'base', langs: langs.map(lang => ({
|
|
lang,
|
|
rows: [...SIZES.filter(s => !(v && s[0] === 'feature')).map(([size, label, store, w, h]) => {
|
|
const n = size === 'feature' ? 1 : lang === 'ar' && size === 'ipad13' ? 0 : 4;
|
|
const tiles: RenderTile[] = Array.from({ length: n }, (_, i) => {
|
|
const src = `${v ? `${v}/` : ''}${lang}/${size}/${size === 'feature' ? '1024x500' : String(i + 1).padStart(2, '0')}.png`;
|
|
ART[src] = shot(i, size, v);
|
|
const issues = lang === 'ja' && size === 'play' && i === 1 ? [{ level: 'warn' as const, code: 'overflow', msg: 'title line 2 shrunk to 72 %: consider shorter words' }]
|
|
: lang === 'tr' && size === 'feature' ? [{ level: 'warn' as const, code: 'bytes', msg: 'PNG is 1.4 MB: the JPEG copy uploads 4x faster (storebound shots jpeg)' }] : [];
|
|
images++; warnings += issues.length;
|
|
return { src, kind: 'image' as const, name: src.split('/').pop()!, w, h, bytes: Math.round(w * h * 0.42), issues };
|
|
});
|
|
const rowIssues = n === 0 ? [{ level: 'error' as const, code: 'count', msg: `no ${label} images (needs 1-10)` }] : [];
|
|
errors += rowIssues.length;
|
|
return { size, label, store, tiles, issues: rowIssues };
|
|
}), ...(!v && lang !== 'ar' ? [{ size: 'preview/iphone', label: 'App preview iPhone', store: 'apple' as const, issues: [], tiles: [(() => {
|
|
const src = `${lang}/preview/iphone/01.mp4`, poster = `${lang}/preview/iphone/01-poster.png`;
|
|
ART[poster] = shot(1, 'poster', '');
|
|
videos++;
|
|
return { src, kind: 'video' as const, poster, name: '01.mp4', w: 886, h: 1920, bytes: 18_400_000, issues: [] };
|
|
})()] }] : [])],
|
|
})) });
|
|
}
|
|
out.totals = { images, videos, errors, warnings };
|
|
return out;
|
|
}
|
|
export const RENDERS: Record<string, RendersResult> = { 'p-prism': renders(['en', 'tr', 'ja', 'ar'], ['b']), 'p-tidy': renders(['en', 'tr'], []) };
|
|
export const renderArt = (file: string): string | null => ART[file] ?? null;
|
|
|
|
// ---- Product Page Optimization ----
|
|
export const PPO: Record<string, PpoStatus> = {
|
|
'p-prism': { tests: [
|
|
{ id: 'e2', name: 'storebound b', state: 'ACCEPTED', trafficProportion: 30, startDate: day(6), endDate: null, treatments: [{ id: 't1', name: 'b', locales: 4 }] },
|
|
{ id: 'e1', name: 'night palette', state: 'COMPLETED', trafficProportion: 50, startDate: day(70), endDate: day(40), treatments: [] },
|
|
] },
|
|
'p-tidy': { tests: [] },
|
|
};
|
|
export const PPO_STORE: PpoStatus = { tests: [{ id: 'e9', name: 'calm icons', state: 'PREPARE_FOR_SUBMISSION', trafficProportion: 20, startDate: null, endDate: null, treatments: [{ id: 't9', name: 'calm', locales: 2 }] }] };
|
|
|
|
// ---- analytics ----
|
|
function analytics(days: number, seed: number): AnalyticsCompare {
|
|
const apple: AnalyticsCompare['series']['apple'] = [], play: AnalyticsCompare['series']['play'] = [];
|
|
for (let i = days; i >= 1; i--) {
|
|
const d = day(i), k = days - i;
|
|
const wave = 1 + Math.sin(k / 3.2 + seed) * 0.12 + (k % 7 >= 5 ? 0.18 : 0);
|
|
const lift = k > days * 0.55 ? 1.16 : 1;
|
|
const imp = Math.round(4200 * wave * (1 + k / days * 0.25)), views = Math.round(imp * 0.31);
|
|
apple.push({ date: d, impressions: imp, uniqueImpressions: Math.round(imp * 0.72), pageViews: views, uniquePageViews: Math.round(views * 0.8), downloads: Math.round(imp * 0.041 * lift), firstTimeDownloads: Math.round(imp * 0.033 * lift) });
|
|
const vis = Math.round(2600 * wave * (1 + k / days * 0.2));
|
|
play.push({ date: d, visitors: vis, acquisitions: Math.round(vis * 0.27 * lift), installs: Math.round(vis * 0.3 * lift) });
|
|
}
|
|
const ev = (daysAgo: number, commit: string, subject: string, files: string[]) => ({ date: day(daysAgo), commit, subject, files });
|
|
const events = [ev(Math.round(days * 0.45), 'a41c9e2', 'store: combo screenshot first (variant b won)', ['store/shots/copy.json', 'storebound.config.ts']), ev(Math.round(days * 0.75), '9be03d1', 'listing: shorter subtitle and keywords', ['store/appstore.md']), ev(Math.round(days * 0.2), 'c07f5aa', 'listing: new full description', ['store/listing.md'])];
|
|
const tot = (xs: { a: number; b: number }[]) => { const a = xs.reduce((n, x) => n + x.a, 0), b = xs.reduce((n, x) => n + x.b, 0); return { a, b, conv: a ? b / a : null }; };
|
|
const effects: AnalyticsCompare['effects'] = [];
|
|
for (const e of events) {
|
|
for (const store of ['apple', 'play'] as const) {
|
|
const s = store === 'apple' ? apple.map(x => ({ date: x.date, a: x.impressions, b: x.downloads })) : play.map(x => ({ date: x.date, a: x.visitors, b: x.acquisitions }));
|
|
const i = s.findIndex(x => x.date >= e.date);
|
|
if (i < 0) continue;
|
|
const before = tot(s.slice(Math.max(0, i - 7), i)), after = tot(s.slice(i, i + 7));
|
|
effects.push({ event: e, store, daysBefore: Math.min(7, i), daysAfter: Math.min(7, s.length - i),
|
|
before: store === 'apple' ? { impressions: before.a, downloads: before.b, conversion: before.conv } : { visitors: before.a, acquisitions: before.b, conversion: before.conv },
|
|
after: store === 'apple' ? { impressions: after.a, downloads: after.b, conversion: after.conv } : { visitors: after.a, acquisitions: after.b, conversion: after.conv } });
|
|
}
|
|
}
|
|
return { snapshots: Math.ceil(days / 28) + 1, since: day(days), series: { apple, play }, effects };
|
|
}
|
|
export const ANALYTICS: Record<string, AnalyticsCompare | null> = { 'p-prism': analytics(56, 1), 'p-tidy': null };
|
|
|
|
// ---- aso.audit: `aso audit --json` of the demo apps (made up: four rank snapshots two weeks apart) ----
|
|
function asoAudit(withRanks: boolean): AsoAuditView {
|
|
const dates = [day(42), day(28), day(14), day(0)];
|
|
const series = [
|
|
{ storefront: 'us', keyword: 'match', ranks: [48, 39, 31, 24] },
|
|
{ storefront: 'us', keyword: 'puzzle', ranks: [88, 92, 71, 66] },
|
|
{ storefront: 'us', keyword: 'tiles', ranks: [null, 140, 118, 121] },
|
|
{ storefront: 'us', keyword: 'offline', ranks: [null, null, null, null] },
|
|
];
|
|
const rows = series.map(x => {
|
|
const rank = x.ranks.at(-1) ?? null, previous = x.ranks.at(-2) ?? null;
|
|
const known = x.ranks.filter((v): v is number => v !== null);
|
|
return { storefront: x.storefront, keyword: x.keyword, rank, previous, change: rank !== null && previous !== null ? previous - rank : null,
|
|
arrow: rank === null ? '·' as const : previous === null ? 'new' as const : rank < previous ? '↑' as const : rank > previous ? '↓' as const : '→' as const,
|
|
best: known.length ? Math.min(...known) : null, snapshots: x.ranks.length };
|
|
});
|
|
return {
|
|
generated: day(0),
|
|
keywordField: { errors: 0, warnings: 1, hints: 2, findings: [{ level: 'warn', where: 'apple en-US', msg: 'keywords: 71/100 bytes used; fill at least 90 (29 bytes free)' }] },
|
|
drafts: [],
|
|
ranks: withRanks ? { snapshots: 4, latest: day(0), rows, dates, series } : { snapshots: 0, latest: null, rows: [], dates: [], series: [] },
|
|
competitors: withRanks ? { date: day(3), gaps: [{ storefront: 'us', locale: 'en-US', gaps: [{ word: 'blast', apps: 4, developers: 3 }, { word: 'block', apps: 3, developers: 3 }] }] } : { date: null, gaps: [] },
|
|
experiments: [{ store: 'apple', what: 'first screenshot caption', why: 'the first screenshot is what search results show', how: 'storebound apple ppo create --variant b --dry' }],
|
|
next: withRanks ? [
|
|
{ what: 'fill the keyword field of en-US (71/100 bytes used)', why: 'unused bytes are unused search reach', command: 'storebound aso suggest --locale en-US' },
|
|
{ what: 'consider "blast", "block" for en-US (in the names of 3 developers\' top apps)', why: 'words the top apps lead with; only if they describe your app', command: 'storebound aso suggest --locale en-US' },
|
|
{ what: 'look at the keywords that dropped: us "tiles" 118 -> 121', why: 'a drop after a listing change is the signal to revert or adjust it' },
|
|
] : [
|
|
{ what: 'take a first rank snapshot', why: 'trends need a baseline before the next listing change', command: 'storebound aso rank --dry, then storebound aso rank' },
|
|
{ what: 'read the competitors for your keywords', why: 'coverage gaps and suggest need the top apps per term', command: 'storebound aso competitors --dry, then storebound aso competitors' },
|
|
],
|
|
};
|
|
}
|
|
export const ASO: Record<string, AsoAuditView> = { 'p-prism': asoAudit(true), 'p-tidy': asoAudit(false) };
|