[IMP] website_event_track_online: add PWA support
RATIONALE Events are sometimes held online, gathering a community. In this merge we improve Event application to better support full-online events with improved tracks, wishlists, chat rooms, ... PURPOSE Add PWA support. This will improve adoption and use of our Events application. LINKS Task ID-2252655 (Main Online Event task) Task ID-2283796 (Event B2Basics / Registration Flow) Task ID-1896623 (PWA Support)
This commit is contained in:
committed by
Thibault Delavallée
parent
0ba681f974
commit
bf2aead36f
@@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
from datetime import timedelta
|
||||
import pytz
|
||||
@@ -8,6 +10,9 @@ import pytz
|
||||
from odoo import exceptions, http, fields
|
||||
from odoo.addons.website_event_track.controllers.main import WebsiteEventTrackController
|
||||
from odoo.http import request
|
||||
from odoo.modules.module import get_module_resource
|
||||
from odoo.tools import ustr
|
||||
from odoo.tools.translate import _
|
||||
|
||||
|
||||
class EventTrackOnlineController(WebsiteEventTrackController):
|
||||
@@ -225,3 +230,46 @@ class EventTrackOnlineController(WebsiteEventTrackController):
|
||||
result['visitor_uuid'] = visitor_sudo.access_token
|
||||
|
||||
return result
|
||||
|
||||
@http.route('/event/manifest.webmanifest', type='http', auth='public', methods=['GET'], website=True, sitemap=False)
|
||||
def webmanifest(self):
|
||||
""" Returns a WebManifest describing the metadata associated with a web application.
|
||||
Using this metadata, user agents can provide developers with means to create user
|
||||
experiences that are more comparable to that of a native application.
|
||||
"""
|
||||
company = request.env.company
|
||||
website = request.website
|
||||
manifest = {
|
||||
'name': _('%s Online Events') % company.name,
|
||||
'short_name': company.name,
|
||||
'description': _('%s Online Events') % company.name,
|
||||
'scope': '/event',
|
||||
'start_url': '/event',
|
||||
'display': 'standalone',
|
||||
'background_color': '#ffffff',
|
||||
'theme_color': '#875A7B',
|
||||
}
|
||||
icon_sizes = ['192x192', '512x512']
|
||||
manifest['icons'] = [{
|
||||
'src': website.image_url(website, 'app_icon', size=size),
|
||||
'sizes': size,
|
||||
'type': 'image/png',
|
||||
} for size in icon_sizes]
|
||||
body = json.dumps(manifest, default=ustr)
|
||||
response = request.make_response(body, [
|
||||
('Content-Type', 'application/manifest+json'),
|
||||
])
|
||||
return response
|
||||
|
||||
@http.route('/event/service-worker.js', type='http', auth='public', methods=['GET'], website=True, sitemap=False)
|
||||
def service_worker(self):
|
||||
""" Returns a ServiceWorker javascript file scoped for website_event
|
||||
"""
|
||||
sw_file = get_module_resource('website_event_track_online', 'static/src/js/service_worker.js')
|
||||
with open(sw_file, 'rb') as fp:
|
||||
body = fp.read()
|
||||
response = request.make_response(body, [
|
||||
('Content-Type', 'text/javascript'),
|
||||
('Service-Worker-Allowed', '/event'),
|
||||
])
|
||||
return response
|
||||
|
||||
@@ -9,5 +9,6 @@ from . import event_track
|
||||
from . import event_track_stage
|
||||
from . import event_track_tag
|
||||
from . import event_type
|
||||
from . import website
|
||||
from . import website_event_menu
|
||||
from . import website_visitor
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.tools import ImageProcess
|
||||
|
||||
|
||||
class Website(models.Model):
|
||||
_inherit = "website"
|
||||
|
||||
app_icon = fields.Image(string='Website App Icon', compute='_compute_app_icon', stored=True, readonly=True, help='This field holds the image used as mobile app icon on the website (PNG format).')
|
||||
|
||||
@api.depends('favicon')
|
||||
def _compute_app_icon(self):
|
||||
""" Computes a squared image based on the favicon to be used as mobile webapp icon.
|
||||
App Icon should be in PNG format and size of at least 512x512.
|
||||
"""
|
||||
for website in self:
|
||||
image = ImageProcess(website.favicon)
|
||||
w, h = image.image.size
|
||||
square_size = w if w > h else h
|
||||
image.crop_resize(square_size, square_size)
|
||||
image.image = image.image.resize((512, 512))
|
||||
image.operationsCount += 1
|
||||
website.app_icon = image.image_base64(output_format='PNG')
|
||||
@@ -0,0 +1,81 @@
|
||||
// idb-keyval.js 3.2.0
|
||||
// https://github.com/jakearchibald/idb-keyval
|
||||
// Copyright 2016, Jake Archibald
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
var idbKeyval = (function (exports) {
|
||||
'use strict';
|
||||
|
||||
class Store {
|
||||
constructor(dbName = 'keyval-store', storeName = 'keyval') {
|
||||
this.storeName = storeName;
|
||||
this._dbp = new Promise((resolve, reject) => {
|
||||
const openreq = indexedDB.open(dbName, 1);
|
||||
openreq.onerror = () => reject(openreq.error);
|
||||
openreq.onsuccess = () => resolve(openreq.result);
|
||||
// First time setup: create an empty object store
|
||||
openreq.onupgradeneeded = () => {
|
||||
openreq.result.createObjectStore(storeName);
|
||||
};
|
||||
});
|
||||
}
|
||||
_withIDBStore(type, callback) {
|
||||
return this._dbp.then(db => new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(this.storeName, type);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onabort = transaction.onerror = () => reject(transaction.error);
|
||||
callback(transaction.objectStore(this.storeName));
|
||||
}));
|
||||
}
|
||||
}
|
||||
let store;
|
||||
function getDefaultStore() {
|
||||
if (!store)
|
||||
store = new Store();
|
||||
return store;
|
||||
}
|
||||
function get(key, store = getDefaultStore()) {
|
||||
let req;
|
||||
return store._withIDBStore('readonly', store => {
|
||||
req = store.get(key);
|
||||
}).then(() => req.result);
|
||||
}
|
||||
function set(key, value, store = getDefaultStore()) {
|
||||
return store._withIDBStore('readwrite', store => {
|
||||
store.put(value, key);
|
||||
});
|
||||
}
|
||||
function del(key, store = getDefaultStore()) {
|
||||
return store._withIDBStore('readwrite', store => {
|
||||
store.delete(key);
|
||||
});
|
||||
}
|
||||
function clear(store = getDefaultStore()) {
|
||||
return store._withIDBStore('readwrite', store => {
|
||||
store.clear();
|
||||
});
|
||||
}
|
||||
function keys(store = getDefaultStore()) {
|
||||
const keys = [];
|
||||
return store._withIDBStore('readonly', store => {
|
||||
// This would be store.getAllKeys(), but it isn't supported by Edge or Safari.
|
||||
// And openKeyCursor isn't supported by Safari.
|
||||
(store.openKeyCursor || store.openCursor).call(store).onsuccess = function () {
|
||||
if (!this.result)
|
||||
return;
|
||||
keys.push(this.result.key);
|
||||
this.result.continue();
|
||||
};
|
||||
}).then(() => keys);
|
||||
}
|
||||
|
||||
exports.Store = Store;
|
||||
exports.get = get;
|
||||
exports.set = set;
|
||||
exports.del = del;
|
||||
exports.clear = clear;
|
||||
exports.keys = keys;
|
||||
|
||||
return exports;
|
||||
|
||||
}({}));
|
||||
@@ -38,11 +38,13 @@ publicWidget.registry.websiteEventTrackReminder = publicWidget.Widget.extend({
|
||||
this.reminderOn = $trackLink.data('reminderOn');
|
||||
}
|
||||
|
||||
var reminderOnValue = !this.reminderOn;
|
||||
|
||||
this._rpc({
|
||||
route: '/event/track/toggle_reminder',
|
||||
params: {
|
||||
track_id: $trackLink.data('trackId'),
|
||||
set_reminder_on: !this.reminderOn
|
||||
set_reminder_on: reminderOnValue,
|
||||
},
|
||||
}).then(function (result) {
|
||||
if (result.error && result.error === 'ignored') {
|
||||
@@ -52,7 +54,7 @@ publicWidget.registry.websiteEventTrackReminder = publicWidget.Widget.extend({
|
||||
message: _.str.sprintf(_t('Talk already in your Favorites')),
|
||||
});
|
||||
} else {
|
||||
self.reminderOn = result.reminderOn;
|
||||
self.reminderOn = reminderOnValue;
|
||||
var reminderText = self.reminderOn ? _t('Reminder On') : _t('Set Reminder');
|
||||
self.$('.o_wetrack_js_reminder_text').text(reminderText);
|
||||
self._updateDisplay();
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
importScripts("/website_event_track_online/static/lib/idb-keyval/idb-keyval.js");
|
||||
|
||||
const PREFIX = "odoo-event";
|
||||
const SYNCABLE_ROUTES = ["/event/track/toggle_reminder"];
|
||||
const CACHABLE_ROUTES = ["/web/webclient/version_info"];
|
||||
|
||||
const { Store, set, get, del } = idbKeyval;
|
||||
const pendingRequestsQueueName = `${PREFIX}-pending-requests`;
|
||||
const cacheName = `${PREFIX}-cache`;
|
||||
const syncStore = new Store(`${PREFIX}-sync-db`, `${PREFIX}-sync-store`);
|
||||
const cacheStore = new Store(`${PREFIX}-cache-db`, `${PREFIX}-cache-store`);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
const urlPathname = (url) => new URL(url).pathname;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array} whitelist
|
||||
* @returns {Function}
|
||||
*/
|
||||
const canHandleRoutes = (whitelist) => (url) => whitelist.includes(urlPathname(url));
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Request} request
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isGET = (request) => request.method === "GET";
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
const isSyncableURL = canHandleRoutes(SYNCABLE_ROUTES);
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
const isCachableURL = canHandleRoutes(CACHABLE_ROUTES);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Request} req
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
const serializeRequest = async (req) => ({
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
body: await req.text(),
|
||||
mode: req.mode,
|
||||
credentials: req.credentials,
|
||||
cache: req.cache,
|
||||
redirect: req.redirect,
|
||||
referrer: req.referrer,
|
||||
integrity: req.integrity,
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} requestData
|
||||
* @returns {Request}
|
||||
*/
|
||||
const deserializeRequest = (requestData) => {
|
||||
const { url } = requestData;
|
||||
delete requestData.url;
|
||||
return new Request(url, requestData);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Response} res
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
const serializeResponse = async (res) => ({
|
||||
body: await res.text(),
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: Object.fromEntries(res.headers.entries()),
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} responseData
|
||||
* @returns {Response}
|
||||
*/
|
||||
const deserializeResponse = (responseData) => {
|
||||
const { body } = responseData;
|
||||
delete responseData.body;
|
||||
return new Response(body, responseData);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} serializedRequest
|
||||
* @returns {string}
|
||||
*/
|
||||
const buildCacheKey = ({ url, body: { method, params } }) =>
|
||||
JSON.stringify({
|
||||
url,
|
||||
method,
|
||||
params,
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {int}
|
||||
*/
|
||||
const uniqueRequestId = () => Math.floor(Math.random() * 1000 * 1000 * 1000);
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Response}
|
||||
*/
|
||||
const buildEmptyResponse = () => new Response(JSON.stringify({ jsonrpc: "2.0", id: uniqueRequestId(), result: {} }));
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Request} request
|
||||
* @param {Response} response
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const cacheRequest = async (request, response) => {
|
||||
if (isGET(request)) {
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.put(request, response.clone());
|
||||
} else if (isCachableURL(request.url)) {
|
||||
const serializedResponse = await serializeResponse(response.clone());
|
||||
await set(buildCacheKey(serializedRequest), serializedResponse, cacheStore);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Request} request
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isCachableRequest = (request) => isGET(request) || isCachableURL(request.url);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Request} request
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
const matchCache = async (request) => {
|
||||
if (isGET(request)) {
|
||||
const cache = await caches.open(cacheName);
|
||||
return await cache.match(request);
|
||||
}
|
||||
if (isCachableURL(request.url)) {
|
||||
const serializedRequest = await serializeRequest(requestCopy);
|
||||
const cachedResponse = await get(buildCacheKey(serializedRequest), cacheStore);
|
||||
if (cachedResponse) {
|
||||
return deserializeResponse(cachedResponse);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FetchEvent} param0
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
const processFetchEvent = async ({ request }) => {
|
||||
const requestCopy = request.clone();
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(request);
|
||||
await cacheRequest(request, response);
|
||||
} catch (requestError) {
|
||||
if (isCachableRequest(requestCopy)) {
|
||||
response = await matchCache(requestCopy);
|
||||
} else if (isSyncableURL(requestCopy.url)) {
|
||||
const pendingRequests = (await get(pendingRequestsQueueName, syncStore)) || [];
|
||||
const serializedRequest = await serializeRequest(requestCopy);
|
||||
await set(pendingRequestsQueueName, [...pendingRequests, serializedRequest], syncStore);
|
||||
if (self.registration.sync) {
|
||||
await self.registration.sync.register(pendingRequestsQueueName).catch((err) => {
|
||||
console.warn("Cannot use BackgroundSync", err);
|
||||
throw requestError;
|
||||
});
|
||||
}
|
||||
return buildEmptyResponse();
|
||||
} else {
|
||||
console.warn(`Offline ${requestCopy.method} request currently not supported`, requestCopy);
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw requestError;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const processPendingRequests = async () => {
|
||||
const pendingRequests = (await get(pendingRequestsQueueName, syncStore)) || [];
|
||||
if (!pendingRequests.length) {
|
||||
console.info("Nothing to sync!");
|
||||
return;
|
||||
}
|
||||
let pendingRequest;
|
||||
while ((pendingRequest = pendingRequests.shift())) {
|
||||
const request = deserializeRequest(pendingRequest);
|
||||
await fetch(request);
|
||||
await set(pendingRequestsQueueName, pendingRequests, syncStore);
|
||||
}
|
||||
};
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
event.respondWith(processFetchEvent(event));
|
||||
});
|
||||
|
||||
self.addEventListener("sync", (event) => {
|
||||
console.info(`Syncing pending requests...`);
|
||||
if (event.tag === pendingRequestsQueueName) {
|
||||
event.waitUntil(processPendingRequests());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
odoo.define("website_event_track_online.website_event_pwa_widget", function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require("web.config");
|
||||
var publicWidget = require("web.public.widget");
|
||||
|
||||
var PWAInstallBanner = publicWidget.Widget.extend({
|
||||
xmlDependencies: ["/website_event_track_online/static/src/xml/website_event_pwa.xml"],
|
||||
template: "pwa_install_banner",
|
||||
events: {
|
||||
"click .o_btn_install": "_onClickInstall",
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickInstall: function () {
|
||||
this.trigger_up("prompt_install");
|
||||
},
|
||||
});
|
||||
|
||||
publicWidget.registry.WebsiteEventPWAWidget = publicWidget.Widget.extend({
|
||||
selector: "#wrapwrap.event",
|
||||
custom_events: {
|
||||
prompt_install: "_onPromptInstall",
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.beforeInstallPromptHandler = this._onBeforeInstallPrompt.bind(this);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var superProm = this._super.apply(this, arguments);
|
||||
window.addEventListener("beforeinstallprompt", this.beforeInstallPromptHandler);
|
||||
return superProm.then(this._registerServiceWorker);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
destroy: function () {
|
||||
window.removeEventListener("beforeinstallprompt", this.beforeInstallPromptHandler);
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_hideInstallBanner: function () {
|
||||
this.installBanner ? this.installBanner.destroy() : undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_registerServiceWorker: function () {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker
|
||||
.register("/event/service-worker.js", { scope: "/event" })
|
||||
.then((registration) => {
|
||||
console.info("Registration successful, scope is:", registration.scope);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Service worker registration failed, error:", error);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_showInstallBanner: function () {
|
||||
this.installBanner = new PWAInstallBanner(this);
|
||||
this.installBanner.appendTo(this.$el);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param ev {Event}
|
||||
*/
|
||||
_onBeforeInstallPrompt: function (ev) {
|
||||
if (!config.device.isMobile) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this.deferredPrompt = ev;
|
||||
this._showInstallBanner();
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param ev {Event}
|
||||
*/
|
||||
_onPromptInstall: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.deferredPrompt.prompt();
|
||||
this._hideInstallBanner();
|
||||
this.deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === "accepted") {
|
||||
console.log("User accepted the install prompt");
|
||||
} else {
|
||||
console.log("User dismissed the install prompt");
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
PWAInstallBanner: PWAInstallBanner,
|
||||
WebsiteEventPWAWidget: publicWidget.registry.WebsiteEventPWAWidget,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
@media all and (display-mode: standalone) {
|
||||
#wrapwrap.event {
|
||||
#top,
|
||||
#bottom {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#wrapwrap.event {
|
||||
.o_pwa_install_banner {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
border-top: 1px solid $gray-200;
|
||||
box-shadow: 0 -0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
font-size: 1rem;
|
||||
|
||||
.o_btn_install {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<templates>
|
||||
|
||||
<t t-name="pwa_install_banner">
|
||||
<div class="alert alert-default o_pwa_install_banner">
|
||||
<strong>Install Application</strong>
|
||||
<button class="btn btn-primary o_btn_install">Install</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -4,10 +4,13 @@
|
||||
<template id="assets_frontend" inherit_id="website.assets_frontend" name="Event Track Online Assets Frontend">
|
||||
<xpath expr="//link[last()]" position="after">
|
||||
<link rel="stylesheet" type="text/scss" href="/website_event_track_online/static/src/scss/event_track_templates.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/website_event_track_online/static/src/scss/pwa_frontend.scss"/>
|
||||
</xpath>
|
||||
<xpath expr="//script[last()]" position="after">
|
||||
<script type="text/javascript" src="/website_event_track_online/static/src/js/event_track_reminder.js"></script>
|
||||
<script type="text/javascript" src="/website_event_track_online/static/src/js/website_event_customize_options.js"></script>
|
||||
<script type="text/javascript" src="/website_event_track_online/static/lib/idb-keyval/idb-keyval.js"/>
|
||||
<script type="text/javascript" src="/website_event_track_online/static/src/js/website_event_pwa_widget.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -15,4 +15,30 @@
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="layout" inherit_id="website_event.layout">
|
||||
<xpath expr='//t[@t-call="website.layout"]' position="inside">
|
||||
<t t-set="pageName" t-value="'event'"/>
|
||||
<t t-set="head">
|
||||
<t t-raw="head"/>
|
||||
<t t-call="website_event_track_online.pwa_manifest"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="index" inherit_id="website_event.index">
|
||||
<xpath expr='//t[@t-call="website.layout"]' position="inside">
|
||||
<t t-set="pageName" t-value="'event'"/>
|
||||
<t t-set="head">
|
||||
<t t-raw="head"/>
|
||||
<t t-call="website_event_track_online.pwa_manifest"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="pwa_manifest">
|
||||
<link rel="manifest" href="/event/manifest.webmanifest"/>
|
||||
<link rel="apple-touch-icon" t-att-href="website.image_url(website, 'app_icon', size='192x192')"/>
|
||||
<meta name="theme-color" content="#875A7B"/>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user