feat: неблокирующий сбор User Flow и гостевых ошибок (api#376 api#377)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
This commit is contained in:
1 parent
791761a782
commit
ba33a76a34
5 files changed
+87
-12
No files matched your search
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
/** Inline клиент: bounded delivery переживает SSR-переход, не задерживая пользовательский путь. */
|
||||||
|
return <<<'JS'
|
||||||
|
(() => {
|
||||||
|
if(window.ZoomzillaAnalytics)return;
|
||||||
|
const config=window.ZoomzillaAnalyticsConfig||{},endpoint=config.endpoint||location.origin;
|
||||||
|
const uuid=()=>{if(crypto.randomUUID)return crypto.randomUUID();const b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;return Array.from(b,(v,i)=>([4,6,8,10].includes(i)?'-':'')+v.toString(16).padStart(2,'0')).join('')};
|
||||||
|
const random=()=>btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||||
|
const read=(storage,key,fallback)=>{try{const raw=storage().getItem(key);return raw&&raw.length<=65536?JSON.parse(raw):fallback}catch(_){return fallback}};
|
||||||
|
const write=(storage,key,value)=>{try{storage().setItem(key,JSON.stringify(value))}catch(_){}};
|
||||||
|
const local=()=>localStorage,session=()=>sessionStorage,browserKey='zoomzilla.analytics.browser.v1',queueKey='zoomzilla.analytics.delivery.v1';
|
||||||
|
let browser=read(local,browserKey,null)||read(session,browserKey,null);
|
||||||
|
if(!/^[0-9a-f-]{36}$/.test(browser||''))browser=uuid();
|
||||||
|
write(local,browserKey,browser);write(session,browserKey,browser);
|
||||||
|
const page=uuid(),queue=[];let token=null,contextTry=0,timer=null,sending=false,current=null,departureKey=null;
|
||||||
|
const captureCandidate=config.capture||new URL(location.href).searchParams.get('azv');
|
||||||
|
let capture=/^[A-Za-z0-9_-]{43}$/.test(captureCandidate||'')?captureCandidate:null;
|
||||||
|
const pageCapture=capture;
|
||||||
|
function prune(){for(let i=queue.length-1;i>=0;i--)if(queue[i].tries>=5||Date.now()-queue[i].created>86400000)queue.splice(i,1);while(queue.length>64||JSON.stringify(queue).length>62000)queue.shift()}
|
||||||
|
function persist(){prune();write(session,queueKey,{browser,endpoint,pending:queue})}
|
||||||
|
function restoreQueue(){const saved=read(session,queueKey,null);if(saved?.browser!==browser||saved?.endpoint!==endpoint||!Array.isArray(saved.pending))return;for(const item of saved.pending.slice(-64)){if(!item||!item.event||typeof item.event.id!=='string'||JSON.stringify(item.event).length>1024||!Number.isFinite(item.created)||!Number.isInteger(item.tries)||item.tries<0)continue;const existing=queue.find(x=>x.event.id===item.event.id);if(existing)existing.tries=Math.max(existing.tries,item.tries);else queue.push(item)}prune()}
|
||||||
|
restoreQueue();
|
||||||
|
const post=async(path,data,unload=false)=>{const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),5000);try{const response=await fetch(endpoint+'/api/analytics/public/'+path,{method:'POST',credentials:'omit',cache:'no-store',headers:{'Content-Type':unload?'text/plain;charset=UTF-8':'application/json'},body:JSON.stringify(data),signal:controller.signal,keepalive:true});if(!response.ok)throw new Error('analytics');return await response.json()}finally{clearTimeout(timeout)}};
|
||||||
|
function batch(){prune();const items=queue.slice(0,20);for(const item of items)item.tries++;persist();return items.map(item=>item.event)}
|
||||||
|
function acknowledge(result){const accepted=new Set(result.accepted||[]);for(let i=queue.length-1;i>=0;i--)if(accepted.has(queue[i].event.id))queue.splice(i,1);persist()}
|
||||||
|
const schedule=()=>{if(timer)return;const tries=queue.length?Math.max(...queue.map(item=>item.tries)):0;timer=setTimeout(()=>{timer=null;flush()},Math.min(30000,500*2**tries)+Math.random()*500)};
|
||||||
|
function contextBody(events,extra={}){const oldCapture=events.find(event=>event.capture)?.capture;return {browser,...((capture||oldCapture)?{capture:capture||oldCapture}:{}),...(current?{handoff:current.handoff,journey:current.journey}:{}),...extra,events}}
|
||||||
|
async function connect(extra={}){
|
||||||
|
const requestedBrowser=browser,events=batch();
|
||||||
|
try{const result=await post('context',contextBody(events,extra));if(browser!==requestedBrowser)return;token=result.token;contextTry=0;capture=null;acknowledge(result);flush()}catch(_){if(contextTry++<5)setTimeout(()=>connect(extra),Math.min(30000,500*2**contextTry)+Math.random()*500)}
|
||||||
|
}
|
||||||
|
async function flush(){
|
||||||
|
if(sending||!token||!queue.length)return;sending=true;
|
||||||
|
try{acknowledge(await post('events',{token,events:batch()}))}catch(_){}
|
||||||
|
sending=false;persist();if(queue.length)schedule();
|
||||||
|
}
|
||||||
|
function depart(){
|
||||||
|
persist();if(!queue.length)return;
|
||||||
|
const key=queue.map(item=>item.event.id).join(':');if(key===departureKey)return;departureKey=key;
|
||||||
|
const events=batch();
|
||||||
|
// fetch keepalive отправляется синхронно; ни pagehide, ни навигация его не ждут.
|
||||||
|
const delivery=token?post('events',{token,events},true):post('context',contextBody(events),true);
|
||||||
|
delivery.then(acknowledge).catch(()=>{});
|
||||||
|
}
|
||||||
|
function emit(kind,details={},operation=current){
|
||||||
|
const event={id:uuid(),page_id:page,kind,at:new Date().toISOString(),...(operation?.journey?{journey_id:operation.journey}:(config.operationProof?{operation_proof:config.operationProof}:{})),...(pageCapture?{capture:pageCapture}:{}),...details};
|
||||||
|
if(JSON.stringify(event).length>1024)return;queue.push({event,tries:0,created:Date.now()});persist();schedule();
|
||||||
|
}
|
||||||
|
window.ZoomzillaAnalytics={emit,uuid,
|
||||||
|
begin(saved){if(saved?.browser&&saved.browser!==browser){browser=saved.browser;token=null;write(local,browserKey,browser);write(session,browserKey,browser)}current=saved||{journey:uuid(),handoff:random(),browser};if(current.browser===browser)connect({handoff:current.handoff,journey:current.journey});return {...current}},
|
||||||
|
restore(saved){if(saved)this.begin(saved);return current},
|
||||||
|
metadata(){return current?{...current}:null},
|
||||||
|
append(data,operation=current){if(operation?.browser===browser){if(token)data.append('analytics_context',token);data.append('analytics_journey',operation.journey);data.append('analytics_handoff',operation.handoff)}},
|
||||||
|
headers(operation=current){return token&&operation?.browser===browser?{'X-Analytics-Context':token,'X-Analytics-Journey':operation.journey}:{}},
|
||||||
|
handoff(operation=current){return operation?.handoff||null}
|
||||||
|
};
|
||||||
|
let visited=false;
|
||||||
|
function visible(){if(config.observePage===false||visited||document.visibilityState!=='visible'||!document.body)return;visited=true;emit('visit',config.entryProof?{entry_proof:config.entryProof}:{},null);if(config.join)emit('join_visit',{},null);if(config.accountProof)emit('account_visit',{account_proof:config.accountProof},null)}
|
||||||
|
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden')depart();else{departureKey=null;visible()}});
|
||||||
|
document.addEventListener('DOMContentLoaded',visible);window.addEventListener('pagehide',depart);
|
||||||
|
window.addEventListener('pageshow',()=>{restoreQueue();visible();flush()});visible();connect();
|
||||||
|
})();
|
||||||
|
JS;
|
||||||
@@ -91,6 +91,7 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (form.querySelector('.dropzone-form-button')?.disabled) return;
|
if (form.querySelector('.dropzone-form-button')?.disabled) return;
|
||||||
|
if(event.isTrusted&&window.ZoomzillaAnalyticsConfig?.guestUpload!==false)window.ZoomzillaAnalytics?.emit('upload_click');
|
||||||
other.value = '';
|
other.value = '';
|
||||||
other.click();
|
other.click();
|
||||||
});
|
});
|
||||||
@@ -117,6 +118,7 @@
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
const timer = window.setTimeout(() => reader.abort(), 30000);
|
const timer = window.setTimeout(() => reader.abort(), 30000);
|
||||||
const failed = error => {
|
const failed = error => {
|
||||||
|
if(window.ZoomzillaAnalyticsConfig?.guestUpload!==false)window.ZoomzillaAnalytics?.emit('photo_error',{stage:'read',reason_code:'file_read_failed'});
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
window.UploadDiagnostics?.record('buffer_error', { error_name: error.name, message: error.message });
|
window.UploadDiagnostics?.record('buffer_error', { error_name: error.name, message: error.message });
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|||||||
+20
-11
@@ -108,7 +108,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
let leavingUploadPage = false;
|
let leavingUploadPage = false;
|
||||||
let uploadAttempt = 0;
|
let uploadAttempt = 0;
|
||||||
const recovery = window.ZoomzillaUploadRecovery.create({scope: 'preland', target: createApiUrl});
|
const recovery = window.ZoomzillaUploadRecovery.create({scope: 'preland', target: createApiUrl});
|
||||||
const metadata = () => ({actionUrl: createApiUrl, redirectBase: redirectUrlBase, redirectUtm: redirectUrlUtm, utmDigits: {...utmDigits}});
|
const metadata = () => ({actionUrl: createApiUrl, redirectBase: redirectUrlBase, redirectUtm: redirectUrlUtm, utmDigits: {...utmDigits}, analytics: window.ZoomzillaAnalytics?.metadata()});
|
||||||
const clearRecovery = () => recovery.clear().catch(() => {});
|
const clearRecovery = () => recovery.clear().catch(() => {});
|
||||||
let uploadDots = 1;
|
let uploadDots = 1;
|
||||||
let uploadDotsTimer = null;
|
let uploadDotsTimer = null;
|
||||||
@@ -157,6 +157,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.target !== fileInput) {
|
if (e.target !== fileInput) {
|
||||||
|
if(e.isTrusted)window.ZoomzillaAnalytics?.emit('upload_click');
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -193,6 +194,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleSelectedFile(file) {
|
function handleSelectedFile(file) {
|
||||||
|
window.ZoomzillaAnalytics?.begin();
|
||||||
if (maxUploadBytes > 0 && file.size > maxUploadBytes) {
|
if (maxUploadBytes > 0 && file.size > maxUploadBytes) {
|
||||||
rejectOversizedFile(file);
|
rejectOversizedFile(file);
|
||||||
return;
|
return;
|
||||||
@@ -202,9 +204,11 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
}
|
}
|
||||||
|
|
||||||
function redirectToTour(record) {
|
function redirectToTour(record) {
|
||||||
|
const azv=window.ZoomzillaAnalytics?.handoff(record.meta.analytics);
|
||||||
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
|
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
|
||||||
const redirectQuery = window.ZoomzillaUtmHandoff.query(record.meta, utmDigits);
|
const redirectQuery = window.ZoomzillaUtmHandoff.query(record.meta, utmDigits);
|
||||||
if (redirectQuery) targetUrl += '&' + redirectQuery;
|
if (redirectQuery) targetUrl += '&' + redirectQuery;
|
||||||
|
if(azv)targetUrl+='&azv='+encodeURIComponent(azv);
|
||||||
const shownTheme = window.ZoomzillaTheme.current();
|
const shownTheme = window.ZoomzillaTheme.current();
|
||||||
targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme);
|
targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme);
|
||||||
window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
|
window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
|
||||||
@@ -222,6 +226,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
formData.append('original_name', file.name || 'upload');
|
formData.append('original_name', file.name || 'upload');
|
||||||
formData.append('original_size', String(file.size || 0));
|
formData.append('original_size', String(file.size || 0));
|
||||||
formData.append('client_key', clientKey);
|
formData.append('client_key', clientKey);
|
||||||
|
window.ZoomzillaAnalytics?.append(formData,record.meta.analytics);
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', record.meta.actionUrl, true);
|
xhr.open('POST', record.meta.actionUrl, true);
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
@@ -230,12 +235,12 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
try { const result = JSON.parse(xhr.responseText); if (!result?.id) throw new Error(); await recovery.accepted({id: result.id}); redirectToTour(record); }
|
try { const result = JSON.parse(xhr.responseText); if (!result?.id) throw new Error(); await recovery.accepted({id: result.id}); redirectToTour(record); }
|
||||||
catch (_) { showUploadError(genericErrorMsg); }
|
catch (_) { showUploadError(genericErrorMsg, false, false, true, xhr); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500);
|
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500, true, xhr);
|
||||||
};
|
};
|
||||||
xhr.onerror = () => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true); };
|
xhr.onerror = () => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true, true, xhr); };
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
} catch (_) { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); }
|
} catch (_) { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); }
|
||||||
}
|
}
|
||||||
@@ -249,7 +254,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
const record = saved || await recovery.remember(new File([bytes], file.name, {type: file.type, lastModified: file.lastModified}), metadata());
|
const record = saved || await recovery.remember(new File([bytes], file.name, {type: file.type, lastModified: file.lastModified}), metadata());
|
||||||
if (attempt === uploadAttempt && !leavingUploadPage) uploadPreparedFile(file, bytes, record);
|
if (attempt === uploadAttempt && !leavingUploadPage) uploadPreparedFile(file, bytes, record);
|
||||||
})
|
})
|
||||||
.catch(() => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); });
|
.catch(() => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, false, false, false); });
|
||||||
}
|
}
|
||||||
function uploadPreparedFile(file, bytes, record) {
|
function uploadPreparedFile(file, bytes, record) {
|
||||||
const attempt = uploadAttempt;
|
const attempt = uploadAttempt;
|
||||||
@@ -260,6 +265,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
const clientKey = record.key;
|
const clientKey = record.key;
|
||||||
formData.append('file', bytes, file.name || 'upload');
|
formData.append('file', bytes, file.name || 'upload');
|
||||||
formData.append('client_key', clientKey);
|
formData.append('client_key', clientKey);
|
||||||
|
window.ZoomzillaAnalytics?.append(formData,record.meta.analytics);
|
||||||
|
|
||||||
const actionUrl = record.meta.actionUrl;
|
const actionUrl = record.meta.actionUrl;
|
||||||
xhr.open('POST', actionUrl, true);
|
xhr.open('POST', actionUrl, true);
|
||||||
@@ -280,13 +286,13 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
try {
|
try {
|
||||||
response = JSON.parse(xhr.responseText);
|
response = JSON.parse(xhr.responseText);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showUploadError(genericErrorMsg);
|
showUploadError(genericErrorMsg, false, false, true, xhr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageId = response?.id || response?.data?.id || response?.image?.id || response?.image_id;
|
const imageId = response?.id || response?.data?.id || response?.image?.id || response?.image_id;
|
||||||
if (!imageId) {
|
if (!imageId) {
|
||||||
showUploadError(genericErrorMsg);
|
showUploadError(genericErrorMsg, false, false, true, xhr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,21 +303,23 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
|
|
||||||
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
|
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
|
||||||
try { await recovery.accepted({id: imageId}); redirectToTour(record); }
|
try { await recovery.accepted({id: imageId}); redirectToTour(record); }
|
||||||
catch (_) { showUploadError(genericErrorMsg); }
|
catch (_) { showUploadError(genericErrorMsg, false, false, true, xhr); }
|
||||||
} else {
|
} else {
|
||||||
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500);
|
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500, true, xhr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true);
|
if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true, true, xhr);
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUploadError(msg, keepRecovery = false, connection = false) {
|
function showUploadError(msg, keepRecovery = false, connection = false, recordAnalytics = true, analyticsRequest = null) {
|
||||||
if (leavingUploadPage) return;
|
if (leavingUploadPage) return;
|
||||||
|
const failureProof=analyticsRequest?.getResponseHeader?.('X-Analytics-Failure');
|
||||||
|
if(recordAnalytics)window.ZoomzillaAnalytics?.emit('photo_error',{stage:'upload',reason_code:'upload_failed',...(analyticsRequest?.status>=100?{http_status:analyticsRequest.status}:{}),...(failureProof?{failure_proof:failureProof}:{})});
|
||||||
uploadAttempt++;
|
uploadAttempt++;
|
||||||
if (!keepRecovery) clearRecovery();
|
if (!keepRecovery) clearRecovery();
|
||||||
resetUploadPending();
|
resetUploadPending();
|
||||||
@@ -333,6 +341,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const saved = await recovery.load();
|
const saved = await recovery.load();
|
||||||
|
window.ZoomzillaAnalytics?.restore(saved?.meta?.analytics);
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload' || saved.sourceUrl !== location.href || saved.meta.actionUrl !== createApiUrl) { clearRecovery(); return; }
|
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload' || saved.sourceUrl !== location.href || saved.meta.actionUrl !== createApiUrl) { clearRecovery(); return; }
|
||||||
if (saved.result?.id) { beginUploadPending(); redirectToTour(saved); }
|
if (saved.result?.id) { beginUploadPending(); redirectToTour(saved); }
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Yii::init($config['default_language'] ?? 'ru');
|
|||||||
$config = prelandNumericUtmConfig($config);
|
$config = prelandNumericUtmConfig($config);
|
||||||
$resolvedUtm = prelandResolveUtm($_GET, $config);
|
$resolvedUtm = prelandResolveUtm($_GET, $config);
|
||||||
$UTM = $resolvedUtm['context'];
|
$UTM = $resolvedUtm['context'];
|
||||||
$get_params = array_diff_key($_GET, array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus','part','registration','bchoice','chob','chb','key'], true));
|
$get_params = array_diff_key($_GET, array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus','part','registration','bchoice','chob','chb','key','azv'], true));
|
||||||
// Числа — внутренний snapshot для recovery. Публичная оболочка создаётся при навигации.
|
// Числа — внутренний snapshot для recovery. Публичная оболочка создаётся при навигации.
|
||||||
$redirect_digits = $resolvedUtm['digits'];
|
$redirect_digits = $resolvedUtm['digits'];
|
||||||
$themeInitial = prelandThemeResolve(($UTM['theme'] ?? 'light') === 'dark' ? 'dark' : 'light');
|
$themeInitial = prelandThemeResolve(($UTM['theme'] ?? 'light') === 'dark' ? 'dark' : 'light');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<?php require __DIR__ . '/components/theme-style.php'; ?>
|
<?php require __DIR__ . '/components/theme-style.php'; ?>
|
||||||
<?php require __DIR__ . '/components/theme-client.php'; ?>
|
<?php require __DIR__ . '/components/theme-client.php'; ?>
|
||||||
|
<script>window.ZoomzillaAnalyticsConfig={endpoint:<?= json_encode($apiHost) ?>};<?= require __DIR__.'/components/analytics-client.php' ?></script>
|
||||||
|
|
||||||
<title><?= $page_title ?? $default_title ?></title>
|
<title><?= $page_title ?? $default_title ?></title>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user