iPhone Duo screenshots (render, check, upload skip with console link while the API has no display type) and the medium display class as the required iPhone set, labels as in the console
This commit is contained in:
parent
0b32df57f0
commit
3381673ae6
17 changed files with 163 additions and 16 deletions
|
|
@ -16,6 +16,8 @@ import { numberedImages } from '../play/images';
|
|||
import { variantRoot } from '../shots/lib';
|
||||
import { findOrCreate } from '../lib/upsert';
|
||||
import { pulledChecksum } from '../lib/art-pull';
|
||||
import { consoleLink } from '../lib/console-links';
|
||||
import { IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API } from '../shots/spec';
|
||||
import { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
|
||||
|
||||
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
|
||||
|
|
@ -28,12 +30,22 @@ export const MEDIUM_DISPLAY: Record<string, string> = { 'iphone-medium': 'APP_IP
|
|||
|
||||
/** Display sets this project uploads: the iPhone sets, plus the 6.3" set with apple.iphoneMedium and the 13" iPad set
|
||||
* with apple.ipad. */
|
||||
export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean } | undefined): Record<string, string> =>
|
||||
({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}) });
|
||||
export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iphoneDuo?: boolean } | undefined): Record<string, string> =>
|
||||
({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}),
|
||||
...(apple?.iphoneDuo ? Object.fromEntries(Object.entries(IPHONE_DUO_DISPLAY).filter((e): e is [string, string] => !!e[1])) : {}) });
|
||||
|
||||
/** Display types `validate` requires on every locale: apple.screenshotTypes, else 6.9" iPhone (+ 13" iPad with ipad). */
|
||||
/** iPhone Duo size keys `apple screenshots` skips (apple.iphoneDuo on, no API display type yet), with the message. */
|
||||
export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } | undefined): { dirs: string[]; msg: string } | null {
|
||||
const dirs = apple?.iphoneDuo ? Object.entries(IPHONE_DUO_DISPLAY).filter(([, t]) => !t).map(([k]) => k) : [];
|
||||
// the console takes them (its own "iPhone Duo" tab): send the owner straight to the version page
|
||||
return dirs.length ? { dirs, msg: `${dirs.join(', ')}: ${IPHONE_DUO_NOT_IN_API}. Add them in ${consoleLink('asc.version', '> App Previews and Screenshots > iPhone Duo', { appleAppId: apple?.appId })}` } : null;
|
||||
}
|
||||
|
||||
/** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's
|
||||
* "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by
|
||||
* scaling a 6.9" or 6.5" set down (ACCEPTED_INSTEAD), plus iPad 13" display with ipad. iPhone Duo is optional. */
|
||||
export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] =>
|
||||
apple?.screenshotTypes ?? ['APP_IPHONE_67', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
||||
apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
||||
|
||||
/** Makes one screenshot set (of a version localization, a custom product page localization or a Product Page
|
||||
* Optimization treatment localization) hold exactly `files`, in order: left alone when the MD5s match, else the
|
||||
|
|
@ -73,6 +85,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const version = await c.editableVersion();
|
||||
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
|
||||
const duoSkip = skippedDuoSets(config.apple);
|
||||
if (duoSkip) warn('apple', duoSkip.msg);
|
||||
const langOf = byAppleLocale(config.languages);
|
||||
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
|
||||
|
||||
|
|
|
|||
|
|
@ -408,7 +408,7 @@ type LiveSet = LiveArtSet;
|
|||
type LiveResult = LiveArtResult;
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'App Store iPhone 6.3"', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'App Store iPad 13"', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)',
|
||||
APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'iPad 13" display', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)',
|
||||
IPHONE_67: 'App Store iPhone app preview', IPAD_PRO_3GEN_129: 'App Store iPad app preview', featureGraphic: 'Play feature graphic', phoneScreenshots: 'Play phone', sevenInchScreenshots: 'Play 7" tablet', tenInchScreenshots: 'Play 10" tablet', tvScreenshots: 'Play TV', wearScreenshots: 'Play Wear OS',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { allSubscriptions } from './lib/subscriptions';
|
|||
import { type CopyFile, validateCopy } from './shots/lib';
|
||||
import { posix, readPulled } from './lib/art-pull';
|
||||
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
|
||||
import { iphoneDuoNote } from './shots/spec';
|
||||
|
||||
/** The newest .ipa in a folder (apple build's export), or null. */
|
||||
function newestIpa(dir: string): string | null {
|
||||
|
|
@ -146,6 +147,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try { for (const n of (await runScan(ctx.loaded.root, { config: ctx.loaded.path })).report.notes) if (n.startsWith('scan.ignore[')) note(n); } catch (e) { note(`scan.ignore not checked: ${(e as Error).message}`); }
|
||||
}
|
||||
if (config.apple && !config.app.copyright?.trim()) problems.push(COPYRIGHT_MISSING);
|
||||
const duo = iphoneDuoNote({ apple: !!config.apple, duo: !!config.apple?.iphoneDuo, play: !!config.play });
|
||||
if (duo?.level === 'info') { hints.push(duo.msg); log('check', `hint: ${duo.msg}`); } else if (duo) note(duo.msg);
|
||||
// the scaffolder's own placeholders would reach the stores as the app's copyright, contact and legal pages
|
||||
const tph = templatePlaceholders(config as never);
|
||||
if (tph.length) problems.push(`template placeholders still in the config: ${tph.join(', ')} ("Example Studio", example.com): the stores would show them as your copyright, contact address and legal pages; put the real values in`);
|
||||
|
|
|
|||
|
|
@ -622,6 +622,10 @@ export interface ShipConfig {
|
|||
* pixel-exact art on 6.3" phones, or if Apple stops scaling. (Screenshot specifications, App Store Connect Help.)
|
||||
*/
|
||||
iphoneMedium?: boolean;
|
||||
/** Also render and check iPhone Duo (foldable) screenshots: outer 1398 x 2034 and inner 2007 x 2853 (landscape too).
|
||||
* Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. Not uploaded while the API has no
|
||||
* display type for them (cli/shots/spec.ts IPHONE_DUO_DISPLAY). */
|
||||
iphoneDuo?: boolean;
|
||||
/** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */
|
||||
testflight?: TfConfig;
|
||||
/** Certificates, profiles and capabilities through the API (`storebound apple signing`), signing style of `apple build`. */
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { consoleLink, consoleUrl } from './console-links';
|
||||
import { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions';
|
||||
import { iphoneDuoNote } from '../shots/spec';
|
||||
|
||||
export type Level = 'blocker' | 'warn' | 'ok';
|
||||
|
||||
|
|
@ -11,11 +12,16 @@ export type Level = 'blocker' | 'warn' | 'ok';
|
|||
* (either 13" display type). The smaller sizes are scaled down from these. A live app with only the 6.5" set is fine
|
||||
* (store-pitfalls 44). */
|
||||
export const ACCEPTED_INSTEAD: Record<string, string[]> = {
|
||||
// the console's required iPhone class (medium display): smaller sizes scale down from a larger set automatically
|
||||
APP_IPHONE_61: ['APP_IPHONE_67', 'APP_IPHONE_65'],
|
||||
APP_IPHONE_67: ['APP_IPHONE_65'],
|
||||
APP_IPHONE_65: ['APP_IPHONE_67'],
|
||||
APP_IPAD_PRO_3GEN_129: ['APP_IPAD_PRO_129'],
|
||||
APP_IPAD_PRO_129: ['APP_IPAD_PRO_3GEN_129'],
|
||||
};
|
||||
/** Required class -> larger sets App Store Connect scales down into it ("View All Sizes in Media Manager"). */
|
||||
const SCALES_DOWN: Record<string, string[]> = { APP_IPHONE_61: ['APP_IPHONE_67', 'APP_IPHONE_65'] };
|
||||
const LABEL: Record<string, string> = { APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPAD_PRO_3GEN_129: 'iPad 13" display' };
|
||||
export interface Finding { store: 'apple' | 'play'; level: Level; code: string; msg: string; fix?: string;
|
||||
/** Absolute deep link to the console page the message points to (desktop / MCP render it as an Open link). */
|
||||
url?: string;
|
||||
|
|
@ -58,6 +64,10 @@ export interface AppleSnapshot {
|
|||
expectedLocales: string[];
|
||||
locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; screenshots: Record<string, number> }[];
|
||||
requiredDisplayTypes: string[];
|
||||
/** apple.iphoneDuo (iPhone Duo renders on) and whether the project has a play section, for the Duo note. */
|
||||
iphoneDuo?: boolean;
|
||||
play?: boolean;
|
||||
today?: Date;
|
||||
reviewDetail: { contactEmail?: string | null; contactPhone?: string | null; contactFirstName?: string | null; contactLastName?: string | null; demoAccountRequired?: boolean; demoAccountName?: string | null; notes?: string | null } | null;
|
||||
/** Age rating declaration attributes (null = not readable). */
|
||||
ageRating: Record<string, unknown> | null;
|
||||
|
|
@ -212,7 +222,8 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
for (const t of s.requiredDisplayTypes) {
|
||||
if (l.screenshots[t]) continue;
|
||||
const alt = (ACCEPTED_INSTEAD[t] ?? []).find(x => l.screenshots[x]);
|
||||
if (alt) add('warn', 'screenshots-size', `${l.locale}: no ${t} set; Apple accepts the ${alt} set (${l.screenshots[alt]} screenshots) and scales it for the other sizes. Recommended, not required: add ${t}`, 'storebound apple screenshots');
|
||||
if (alt && SCALES_DOWN[t]?.includes(alt)) add('ok', 'screenshots-size', `${l.locale}: ${LABEL[t] ?? t} filled by scaling the ${alt} set down (${l.screenshots[alt]} screenshots)`);
|
||||
else if (alt) add('warn', 'screenshots-size', `${l.locale}: no ${t} set; Apple accepts the ${alt} set (${l.screenshots[alt]} screenshots) and scales it for the other sizes. Recommended, not required: add ${t}`, 'storebound apple screenshots');
|
||||
else add('blocker', 'screenshots', `${l.locale}: no ${t} screenshots${ACCEPTED_INSTEAD[t] ? ` (or ${ACCEPTED_INSTEAD[t]!.join(' / ')})` : ''}`, 'storebound apple screenshots');
|
||||
}
|
||||
}
|
||||
|
|
@ -254,6 +265,9 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
|
||||
if (s.profile) f.push(...profileRules('apple', s.profile, { reviewDetail: s.reviewDetail, descriptions: s.locales.map(l => ({ locale: l.locale, text: l.description ?? '' })) }));
|
||||
f.push(...rendersFindings('apple', s.renders));
|
||||
// iPhone Duo: optional until April 2027, so a passing note (never a blocker); a warning after that date
|
||||
const duo = iphoneDuoNote({ apple: true, duo: !!s.iphoneDuo, play: s.play, today: s.today });
|
||||
if (duo) add(duo.level === 'info' ? 'ok' : 'warn', 'screenshots-duo', duo.msg, 'apple.iphoneDuo: true, then storebound shots render');
|
||||
return sortFindings(f);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export const TOOLS: ToolDef[] = [
|
|||
...(i.concurrency ? ['--concurrency', String(i.concurrency)] : []), ...(i.mode === 'force' ? ['--force'] : i.mode === 'only-missing' ? ['--only-missing'] : [])] },
|
||||
{ name: 'shots_render', title: 'Render store screenshots', command: ['shots', 'render'], write: true, network: false, idempotent: true,
|
||||
description: 'Store screenshots + Play feature graphic in every size and language from the frames and the copy file (local PNGs, needs Playwright + ffmpeg; slow for many languages). dry_run lists what would be rendered.',
|
||||
props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, ipad13, play, tab7, tab10, feature' },
|
||||
props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, iphone-duo-outer, iphone-duo-inner, ipad13, play, tab7, tab10, feature' },
|
||||
scenes: { type: 'string', description: 'Comma-separated scene ids' }, variant, all_variants: { type: 'boolean', default: false, description: 'Base + every variant' },
|
||||
concurrency: { type: 'integer', minimum: 1, maximum: 32, description: 'Parallel pages (default min(4, CPU cores / 2))' } },
|
||||
args: i => [...opt('lang', i.lang), ...opt('size', i.size), ...opt('scenes', i.scenes), ...(i.all_variants === true ? ['--variants'] : opt('variant', i.variant)),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export const SIZES: Record<string, { w: number; h: number; device?: 'tablet'; fe
|
|||
ios65: { w: 1284, h: 2778 },
|
||||
'iphone-medium': { w: 1206, h: 2622 },
|
||||
ipad13: { w: 2064, h: 2752, device: 'tablet' },
|
||||
// iPhone Duo (foldable), only with apple.iphoneDuo: near-tablet aspect, so the tablet frames fill it
|
||||
'iphone-duo-outer': { w: 1398, h: 2034, device: 'tablet' },
|
||||
'iphone-duo-inner': { w: 2007, h: 2853, device: 'tablet' },
|
||||
play: { w: 1080, h: 1920 },
|
||||
tab7: { w: 1080, h: 1920, device: 'tablet' },
|
||||
tab10: { w: 1440, h: 2560, device: 'tablet' },
|
||||
|
|
@ -120,13 +123,14 @@ export function frameDims(config: Pick<ShipConfig, 'apple' | 'shots'>): Record<'
|
|||
export const PLAY_TABLET_SIZES: readonly string[] = ['tab7', 'tab10'];
|
||||
|
||||
/** Size keys this project renders by default: Apple sizes only with an apple section (iPad only with apple.ipad, the
|
||||
* 6.3" iPhone only with apple.iphoneMedium),
|
||||
* 6.3" iPhone only with apple.iphoneMedium, the iPhone Duo sizes only with apple.iphoneDuo),
|
||||
* Play sizes only with a play section (both when neither is configured); the Play tablet sizes only when `tablets`
|
||||
* (resolved play.tablets, see resolvedTablets in report.ts; unset here = play.tablets ?? true). */
|
||||
export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>, tablets?: boolean): string[] {
|
||||
const apple = !!config.apple || !config.play, play = !!config.play || !config.apple;
|
||||
const tabs = tablets ?? config.play?.tablets ?? true;
|
||||
return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium
|
||||
: k.startsWith('iphone-duo') ? apple && !!config.apple?.iphoneDuo
|
||||
: k.startsWith('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,10 +40,14 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
|
|||
// 6.3" iPhone with Dynamic Island, medium display (iPhone 14 Pro .. 18 Pro): optional here (apple.iphoneMedium). Apple
|
||||
// scales the 6.5" (Face ID large) set down for it when it is missing. APP_IPHONE_61 is inferred: the enum has no
|
||||
// descriptions and 61 is the only medium iPhone bucket (NOT VERIFIED against an upload).
|
||||
'iphone-medium': { store: 'apple', type: 'APP_IPHONE_61', label: 'App Store iPhone 6.3"', sizes: [[1206, 2622], [1179, 2556]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
'iphone-medium': { store: 'apple', type: 'APP_IPHONE_61', label: 'iPhone with Dynamic Island (medium display)', sizes: [[1206, 2622], [1179, 2556]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
ios65: { store: 'apple', type: 'APP_IPHONE_65', label: 'App Store iPhone 6.5"', sizes: [[1284, 2778], [1242, 2688]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
// 13" iPad display (iPad Pro M4/M5, iPad Air M2+): required when the app runs on iPad. API display type keeps the old name.
|
||||
ipad13: { store: 'apple', type: 'APP_IPAD_PRO_3GEN_129', label: 'App Store iPad 13"', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
ipad13: { store: 'apple', type: 'APP_IPAD_PRO_3GEN_129', label: 'iPad 13" display', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
// iPhone Duo (foldable, Screenshot specifications, October 2026): outer and inner display, portrait or landscape,
|
||||
// optional (apple.iphoneDuo). `type` is not an ASC display type: see IPHONE_DUO_DISPLAY. No app preview sizes yet.
|
||||
'iphone-duo-outer': { store: 'apple', type: 'IPHONE_DUO_OUTER', label: 'iPhone Duo (outer display)', sizes: [[1398, 2034]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
'iphone-duo-inner': { store: 'apple', type: 'IPHONE_DUO_INNER', label: 'iPhone Duo (inner display)', sizes: [[2007, 2853]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
play: { store: 'play', type: 'phoneScreenshots', label: 'Play phone', sizes: [[1080, 1920]], min: 2, max: 8, maxBytes: 8 * MB, alpha: false },
|
||||
tab7: { store: 'play', type: 'sevenInchScreenshots', label: 'Play 7" tablet', sizes: [[1080, 1920]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false },
|
||||
tab10: { store: 'play', type: 'tenInchScreenshots', label: 'Play 10" tablet', sizes: [[1440, 2560]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false },
|
||||
|
|
@ -51,6 +55,23 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
|
|||
feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false },
|
||||
};
|
||||
|
||||
/** ASC screenshotDisplayType per iPhone Duo size key. null = NOT IN THE API YET: ScreenshotDisplayType in App Store
|
||||
* Connect API 4.5.1 (spec checked 2026-10-18) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO).
|
||||
* When Apple adds it: regenerate (bun tools/gen-api.ts) and put the exact enum names here; uploads then include them. */
|
||||
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
|
||||
export const IPHONE_DUO_NOT_IN_API = 'App Store Connect does not take iPhone Duo screenshots through the API yet; storebound keeps them in store/renders';
|
||||
/** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */
|
||||
export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01';
|
||||
const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)';
|
||||
|
||||
/** check / validate note for an iPhone app without iPhone Duo screenshots: info before IPHONE_DUO_REQUIRED_FROM, a
|
||||
* warning after (never an error: the rule depends on the SDK the build uses). null when Duo renders exist or no iPhone app. */
|
||||
export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean; today?: Date }): { level: 'info' | 'warn'; msg: string } | null {
|
||||
if (!o.apple || o.duo) return null;
|
||||
const late = (o.today ?? new Date()).toISOString().slice(0, 10) >= IPHONE_DUO_REQUIRED_FROM;
|
||||
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
|
||||
}
|
||||
|
||||
/** Play screenshots in general (any size the store accepts): 320..3840 px per side, long side at most 2x the short. */
|
||||
export function playScreenshotOk(w: number, h: number): boolean {
|
||||
const lo = Math.min(w, h), hi = Math.max(w, h);
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
build, locales, reviewDetail, ageRating,
|
||||
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
||||
requiredDisplayTypes: requiredDisplayTypes(config.apple),
|
||||
iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play,
|
||||
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
|
||||
availability: !!avail?.data,
|
||||
firstProductGaps,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue