< 0) {
console.debug(`STOQ - Client clock appears ahead of server by ${Math.abs(Math.round(liquidCacheAge / 60))} minutes, assuming cache fresh`);
window._RestockRocketConfig.isLiquidCacheFresh = true;
} else if (liquidCacheAge <= LIQUID_CACHE_MAX_AGE) {
console.debug(`STOQ - Liquid cache is fresh (${Math.round(liquidCacheAge / 60)} minutes old)`);
window._RestockRocketConfig.isLiquidCacheFresh = true;
} else {
console.debug(`STOQ - Liquid cache is stale (${Math.round(liquidCacheAge / 60)} minutes old, max ${Math.round(LIQUID_CACHE_MAX_AGE / 60)} minutes)`);
window._RestockRocketConfig.isLiquidCacheFresh = false;
}
}
function checkSettingsExpiry(settings) {
try {
if (!settings || !settings.updated_at) {
console.debug('STOQ - Invalid settings data structure');
return null;
}
if (!settings.cache) {
console.debug('STOQ - settings caching disabled');
return null;
}
// Check if translations are enabled but missing from cache
// This handles the backfill period where DB has translations but metafield doesn't
if (settings.multi_language_enabled) {
if (!settings.translations) {
// Translations enabled but no translation data in metafield
// Metafield hasn't been backfilled yet - force refresh
console.debug('STOQ - multi-language enabled but no translation data in cache, fetching fresh');
return null;
}
// Translations object exists in metafield - cache is valid
// If current locale isn't translated, applyTranslations will gracefully use default locale from base fields
if (window._RestockRocketConfig.normalizedLocale &&
!Object.prototype.hasOwnProperty.call(settings.translations, window._RestockRocketConfig.normalizedLocale)) {
console.debug('STOQ - locale not explicitly translated, will use default language from cache');
}
// Don't return null - continue using cache even for untranslated locales
}
const updatedAt = new Date(settings.updated_at);
if (isNaN(updatedAt.getTime())) {
console.debug('STOQ - Invalid updated_at date format in settings');
return null;
}
const age = Date.now() - updatedAt.getTime();
if (age < SETTINGS_CACHE_DURATION) {
console.debug('STOQ - settings changed recently, skipping cache');
return null;
}
return settings;
} catch (error) {
console.debug('STOQ - Error checking settings cache:', error);
return null;
}
}
function createRestockRocketContainer() {
const restockRocketContainer = document.createElement('div');
restockRocketContainer.id = 'restock-rocket';
document.body.appendChild(restockRocketContainer);
}
function createRestockRocketScript(scriptUrl) {
const restockRocketScriptElement = document.createElement('script');
restockRocketScriptElement.setAttribute('defer', 'defer');
restockRocketScriptElement.src = scriptUrl;
document.body.appendChild(restockRocketScriptElement);
}
createRestockRocketContainer()
console.debug('STOQ - extension activated')
// Fire stoq_initialized once per page load so the funnel pipeline has a definitive
// "our code ran on this page" signal independent of any customer interaction.
// Detected variants: the variants present in this page's Liquid context (product page has them;
// collection/index/etc. don't expose variants from Liquid). Used to disambiguate "embed didn't
// load" vs "embed loaded but the variant wasn't a preorder/BIS candidate" in order debug.
try {
const _stoqInitConfig = window._RestockRocketConfig;
const _stoqDetectedVariantIds = (_stoqInitConfig.product && Array.isArray(_stoqInitConfig.product.variants))
? _stoqInitConfig.product.variants.map(function(v) { return v.id })
: [];
const _stoqSelectedVariantId = _stoqInitConfig.selected_variant_id;
Shopify?.analytics?.publish?.('stoq_initialized', {
cart_token: _stoqInitConfig.cartToken || '',
page_url: window.location.href,
page_type: _stoqInitConfig.pageType || '',
shop_domain: _stoqInitConfig.shop || '',
market_id: _stoqInitConfig.marketId || '',
detected_variant_ids: _stoqDetectedVariantIds,
selected_variant_id: _stoqSelectedVariantId || '',
liquid_rendered_at: _stoqInitConfig.liquidRenderedAt || 0,
app_version: _stoqInitConfig.appVersion || '',
liquid_cache_age: _stoqInitConfig.liquidCacheAge,
// Selected variant's stock posture as our app saw it at render — explains
// whether we *should* have treated it as a preorder candidate.
inventory_policy: (_stoqInitConfig.variantsInventoryPolicy || {})[_stoqSelectedVariantId] || '',
inventory_quantity: (_stoqInitConfig.variantsInventoryQuantity || {})[_stoqSelectedVariantId],
});
} catch (e) {
console.debug('STOQ - stoq_initialized publish failed:', e);
}
function applyTranslations(settings) {
try {
// Skip translation logic entirely if multi-language is not enabled
if (!settings || !settings.multi_language_enabled) {
return settings;
}
if (!settings.translations) {
console.debug('STOQ - No translations found, skipping translation');
return settings;
}
const normalizedLocale = window._RestockRocketConfig.normalizedLocale;
const translations = settings.translations;
if (!normalizedLocale) {
// No matching locale has translations; drop payload to save memory
console.debug('STOQ - No matching locale for translations. Available:', Object.keys(translations || {}));
delete settings.translations;
return settings;
}
console.debug(`STOQ - Applying translations for normalized locale: ${normalizedLocale} (original: ${window._RestockRocketConfig.locale})`);
const translatedFields = translations[normalizedLocale];
if (translatedFields && typeof translatedFields === 'object') {
Object.keys(translatedFields).forEach(function(key) {
const value = translatedFields[key];
if (value !== null && value !== undefined && value !== '') {
settings[key] = value;
}
});
} else {
console.debug('STOQ - No translated fields found for locale:', normalizedLocale);
}
delete settings.translations;
return settings;
} catch (e) {
console.debug('STOQ - error applying translations:', e);
return settings;
}
}
// Setup event listener for cart selling plan updates
// This must be called before any scripts are loaded to avoid race conditions
function setupCartSellingPlanUpdater(settings) {
// Setup listener regardless - updateCartSellingPlans has its own guards
// This ensures cleanup happens even when preorders are disabled globally
// Listen for stoq:inventory-data-loaded event dispatched by api.js
window.addEventListener('stoq:inventory-data-loaded', function(event) {
console.debug('STOQ - Inventory data loaded, updating cart selling plans');
if (window._RestockRocket && window._RestockRocket.updateCartSellingPlans) {
window._RestockRocket.updateCartSellingPlans()
.then(hasUpdates => {
if (hasUpdates) {
console.debug('STOQ - cart selling plans updated successfully');
} else {
console.debug('STOQ - no cart selling plan updates needed');
}
})
.catch(error => {
console.error('STOQ - error updating cart selling plans:', error);
});
}
});
}
// First try to get settings from metafields with expiry check
const cachedSettings = window._RestockRocketConfig.cachedSettings;
const validCachedSettings = cachedSettings ? checkSettingsExpiry(cachedSettings) : null;
if (validCachedSettings) {
console.debug('STOQ - using cached settings');
initializeScripts(validCachedSettings);
} else {
console.debug('STOQ - fetching fresh settings');
const headers = {
'X-Shopify-Shop-Domain': window._RestockRocketConfig.shop || window.Shopify.shop,
'ngrok-skip-browser-warning': 'skip'
};
if (window.Shopify?.theme?.role === 'main') {
headers['X-Shopify-Theme-Schema-Name'] = window.Shopify.theme.schema_name;
headers['X-Shopify-Theme-Schema-Version'] = window.Shopify.theme.schema_version;
headers['X-Shopify-Theme-Store-Id'] = window.Shopify.theme.theme_store_id;
}
fetch(
`${window._RestockRocketConfig.host}/api/v1/setting.json?translation_locale=${window._RestockRocketConfig.normalizedLocale}`,
{ headers }
)
.then(function(response) {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(function(settings) {
initializeScripts(settings);
})
.catch(function(error) {
// If request failed and we have cached settings (even if expired), use them as fallback
if (cachedSettings) {
console.debug('STOQ - using expired cached settings as fallback');
initializeScripts(cachedSettings);
} else {
console.error('STOQ - failed to load settings:', error);
}
})
.catch(function(e) {
console.error(e)
})
}
function fetchEmbedConfig(endpoint, apply) {
return fetch(
`${window._RestockRocketConfig.host}/api/v1/embed/${endpoint}.json`,
{
headers: {
'X-Shopify-Shop-Domain': window._RestockRocketConfig.shop || window.Shopify.shop,
'ngrok-skip-browser-warning': 'skip'
}
}
)
.then(function(response) {
if (!response.ok) throw new Error(`Failed to fetch ${endpoint}`);
return response.json();
})
.then(function(data) {
try {
apply(data);
} catch (applyError) {
// Apply failures are programming bugs (e.g. response shape changed
// server-side and the assignment threw). Surface them as console.error
// so they're visible in browser logs, then re-throw to fall through
// to the same Liquid-cached fallback as a fetch failure.
console.error('STOQ - apply failed for ' + endpoint + ':', applyError);
throw applyError;
}
})
.catch(function(error) {
console.debug(`STOQ - using cached ${endpoint}:`, error.message);
});
}
function initializeScripts(settings) {
settings = applyTranslations(settings);
window._RestockRocketConfig.settings = settings;
console.debug(`STOQ - settings configured for ${window._RestockRocketConfig.pageType}`);
// Stale-Liquid resilience (default-on, per-shop opt-out via the
// `disable_refresh_on_stale_liquid` Toggle, surfaced as the negative
// `disable_refresh_on_stale_liquid` flag in settings.json so that
// `undefined` -- in CDN-cached metafield payloads that predate this
// key -- reads as `!undefined === true` and gets default-on behavior
// immediately, no metafield rewrite required).
// When the Liquid CDN cache is older than LIQUID_CACHE_MAX_AGE the in-page
// selling_plans / integrations metafields can be wrong; refresh both from
// the API before launching scripts. Race against a 1000ms timeout so a slow
// API can't block init indefinitely. If the timeout wins, the in-flight
// fetches still complete and update window._RestockRocketConfig — the
// bundle re-reads sellingPlans/integrations on every interaction, so the
// late-arriving values benefit subsequent renders even though the first
// paint may use the Liquid-cached values. On any failure the existing
// Liquid-loaded values stay in place via fetchEmbedConfig's catch.
if (!window._RestockRocketConfig.isLiquidCacheFresh && !settings.disable_refresh_on_stale_liquid) {
console.debug('STOQ - Liquid cache stale, refreshing selling_plans + integrations');
Promise.race([
Promise.all([
fetchEmbedConfig('selling_plans', function(data) {
if (data && Array.isArray(data.plans)) {
window._RestockRocketConfig.sellingPlans = data.plans;
window._RestockRocketConfig.disabledSellingPlanIds = data.disabled_plan_ids || [];
}
}),
fetchEmbedConfig('integrations', function(data)
{
if (Array.isArray(data)) {
window._RestockRocketConfig.integrations = data;
}
})
]),
new Promise(function(resolve) { setTimeout(resolve, 1000); })
]).then(function() { loadScripts(settings); });
return;
}
loadScripts(settings);
}
function loadScripts(settings) {
// Setup cart selling plan updater BEFORE loading any scripts to avoid race conditions
setupCartSellingPlanUpdater(settings);
if(settings.enable_app) {
const hijackIntegration = window._RestockRocketConfig.integrations.find(function(integration) {
return integration.type === 'hijack' && integration.enabled && integration.page_types.includes(window._RestockRocketConfig.pageType);
})
if(window._RestockRocketConfig.pageType === 'collection' && (settings.show_button_on_collection || settings.preorder_collection_enabled)) {
createRestockRocketScript(window._RestockRocketConfig.scriptUrlCollection);
} else if(wind