diff --git a/gulp/tasks/build.js b/gulp/tasks/build.js index 0c5c2647c..bcc575fe4 100644 --- a/gulp/tasks/build.js +++ b/gulp/tasks/build.js @@ -1,28 +1,48 @@ import gulp from 'gulp'; import gutil from 'gulp-util'; import webpack from 'webpack'; +import path from 'path'; import devConfig from '../../webpack/dev.config'; import stagingConfig from '../../webpack/staging.config'; import extConfig from '../../webpack/production.config'; -const build = (config, callback) => { - let myConfig = Object.create(config); - webpack(myConfig, (err, stats) => { - if (err) { - throw new gutil.PluginError('webpack:build', err); - } - gutil.log('[webpack:build]', stats.toString({ colors: true })); +function compiler(config) { + return webpack(Object.create(config)); +} + +function staticCompiler(config) { + if (!staticCompiler.instance) { + staticCompiler.instance = compiler(config); + } + return staticCompiler.instance; +} + +function build(compiler, callback) { + compiler.run((err, stats) => { + if (err) throw new gutil.PluginError('webpack:build', err); + gutil.log('[webpack:build]', stats.toString({ + chunks: false, + colors: true, + })); callback(); }); -}; +} +gulp.task('webpack:watch', () => { + const globs = [ + path.join(__dirname, '../../src/') + '**/*', + path.join(__dirname, '../../test/') + '**/*', + ]; + return gulp.watch(globs, ['webpack:build:dev']); +}); gulp.task('webpack:build:dev', (callback) => { - build(devConfig, callback); + // Instance webpack compiler once over multiple times (watch) + build(staticCompiler(devConfig), callback); }); gulp.task('webpack:build:staging', (callback) => { - build(stagingConfig, callback); + build(compiler(stagingConfig), callback); }); gulp.task('webpack:build:production', (callback) => { - build(extConfig, callback); + build(compiler(extConfig), callback); }); \ No newline at end of file diff --git a/gulp/tasks/webpack.js b/gulp/tasks/webpack.js deleted file mode 100644 index f943f5273..000000000 --- a/gulp/tasks/webpack.js +++ /dev/null @@ -1,13 +0,0 @@ -import gulp from 'gulp'; -import fs from 'fs'; - -gulp.task('replace-webpack-code', () => { - const replaceTasks = [{ - from: './webpack/replace/JsonpMainTemplate.runtime.js', - to: './node_modules/webpack/lib/JsonpMainTemplate.runtime.js' - }, { - from: './webpack/replace/log-apply-result.js', - to: './node_modules/webpack/hot/log-apply-result.js' - }]; - replaceTasks.forEach(task => fs.writeFileSync(task.to, fs.readFileSync(task.from))); -}); diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 2aca0f6a9..345f5700a 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -4,7 +4,8 @@ requireDir('./gulp/tasks'); gulp.task('default',['build:dev']); gulp.task('build:dev', - ['webpack:build:dev', 'views:build:dev', 'copy:build:dev', 'copy:watch']); + ['webpack:build:dev', 'views:build:dev', 'copy:build:dev', + 'copy:watch', 'webpack:watch']); gulp.task('build:staging', ['webpack:build:staging', 'views:build:staging', 'copy:build:staging']); gulp.task('build:production', diff --git a/package.json b/package.json index 6a9604107..c9e980e4e 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "build:staging": "gulp build:staging", "build:dev": "gulp build:dev", "clean": "rm -rf build", - "test": "LMEM_BACKEND_ORIGIN='' NODE_ENV=test mocha --compilers js:babel-core/register test/app --recursive", - "lint": "git ls-files -om --exclude-standard | grep \\.js$ | xargs eslint --fix", + "test": "LMEM_BACKEND_ORIGIN='' LMEM_SCRIPTS_ORIGIN='.' NODE_ENV=test mocha --compilers js:babel-core/register test/app --recursive", + "lint": "git diff-index --name-only --cached HEAD | grep \\.js$ | xargs eslint --fix", "deploy:production": "gulp deploy:production", "deploy:staging": "gulp deploy:staging" }, diff --git a/src/app/actions/install.js b/src/app/actions/install.js new file mode 100644 index 000000000..6c30c0bb9 --- /dev/null +++ b/src/app/actions/install.js @@ -0,0 +1,23 @@ +import { INSTALLED } from '../constants/ActionTypes'; + +// Promise constructed when the module is first imported (very early) +// in order to not miss the "install" event. +const onInstalledPromise = new Promise(resolve => { + chrome.runtime.onInstalled.addListener(details => { + if (details.reason !== 'install') return; + + resolve(Object.assign({}, details, { + datetime: new Date(), + version: chrome.runtime.getManifest().version, + })); + }); +}); + +export default function () { + return dispatch => { + onInstalledPromise.then(onInstalledDetails => dispatch({ + type: INSTALLED, + onInstalledDetails + })); + }; +} diff --git a/src/app/components/AlternativeHeader.js b/src/app/components/AlternativeHeader.js index 43b1b6298..c2a46abcf 100644 --- a/src/app/components/AlternativeHeader.js +++ b/src/app/components/AlternativeHeader.js @@ -41,11 +41,12 @@ class AlternativeHeader extends Component {
) : [(
  • -
  • ), (
  • -
  • ), + (
  • +
  • )]; @@ -135,14 +159,13 @@ class AlternativeHeader extends Component { const extendReduceButton = preferenceScreenPanel ? undefined : (
    - - - ); } @@ -208,13 +216,13 @@ class AlternativeHeader extends Component { componentWillUnmount() { this.refs.deactivateMenu.ownerDocument - .removeEventListener('click', this._closeMenuDocumentClickHandler); + .removeEventListener('click', this.closeMenuDocumentClickHandler); } watchForMenuExit() { const menuElement = this.refs.deactivateMenu; - this._closeMenuDocumentClickHandler = event => { + this.closeMenuDocumentClickHandler = event => { if (!this.state.deactivateMenuOpen) return; if (!event.target.matches('.menu-deactivate, .menu-deactivate *')) { @@ -222,7 +230,7 @@ class AlternativeHeader extends Component { } }; - menuElement.ownerDocument.addEventListener('click', this._closeMenuDocumentClickHandler); + menuElement.ownerDocument.addEventListener('click', this.closeMenuDocumentClickHandler); } diff --git a/src/app/components/AlternativeMain.js b/src/app/components/AlternativeMain.js index 7fe753940..c0adf44c6 100644 --- a/src/app/components/AlternativeMain.js +++ b/src/app/components/AlternativeMain.js @@ -39,7 +39,9 @@ const AlternativeMain = ({ imagesUrl, contributorUrl, recommendation }) => {

    {recommendation.description}

    - Logo le même en mieux + Logo le même en mieux { recommendation.alternatives[0].url_to_redirect.replace(/^https?:\/\/(www.)?/, '') } @@ -58,7 +60,7 @@ const AlternativeMain = ({ imagesUrl, contributorUrl, recommendation }) => {
    - ) + ); }; AlternativeMain.propTypes = { diff --git a/src/app/components/Alternatives.js b/src/app/components/Alternatives.js index 436b9c9ce..0fc589e5f 100644 --- a/src/app/components/Alternatives.js +++ b/src/app/components/Alternatives.js @@ -17,15 +17,18 @@ class Alternatives extends Component { const { props, state } = this; const { recommendation, imagesUrl, reduced, contributorUrl, preferenceScreenPanel, deactivatedWebsites, - onExtend, onReduce, onDeactivate, togglePrefPanel, onReactivateWebsite, closePrefScreen, openPrefScreen + onExtend, onReduce, onDeactivate, togglePrefPanel, onReactivateWebsite, closePrefScreen, openPrefScreen, + onInstalledDetails } = props; - + const body = (preferenceScreenPanel ? : ); @@ -55,6 +58,7 @@ Alternatives.propTypes = { reduced: PropTypes.bool.isRequired, onExtend: PropTypes.func.isRequired, onReduce: PropTypes.func.isRequired, + onInstalledDetails: PropTypes.object.isRequired, }; export default Alternatives; diff --git a/src/app/components/PreferenceAboutPanel.js b/src/app/components/PreferenceAboutPanel.js index 32839f27f..06622e368 100644 --- a/src/app/components/PreferenceAboutPanel.js +++ b/src/app/components/PreferenceAboutPanel.js @@ -1,5 +1,54 @@ import React, { Component, PropTypes } from 'react'; +import { EXTENSION_VERSION } from '../constants/ui'; -export default function(){ - return Le Même En Mieux vous recommande des alternatives pertinentes, blablabla; -} \ No newline at end of file +function formatLocaleDate(strDate) { + const dateOfInstall = new Date(strDate); + + if (Number.isNaN(dateOfInstall.getTime())) + return undefined; + + return dateOfInstall.toLocaleDateString(navigator.language, + { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); +} + +export default function ({ onInstalledDetails }) { + const ISODateOfInstall = onInstalledDetails && onInstalledDetails.get('datetime'); + const localeDateOfInstall = ISODateOfInstall && formatLocaleDate(ISODateOfInstall); + + return ( +
    +
    +

    + Le Même en Mieux est un assistant d’achat + { localeDateOfInstall ? ( + que vous avez installé le + ) : ''}. +

    +

    + Quand vous consultez un produit sur Internet, il vous trouve des conseils d’achat, + des comparatifs et de meilleures alternatives, selon vos préférences. +

    + + {/*

    Localisation

    */} + {/*

    */} + {/* Si possible, l’extension filtre les recommandations pertinentes pour votre localité : */} + {/* .*/} + {/*

    */} +
    + +
    + ); +} diff --git a/src/app/components/PreferenceDeactivatedPanel.js b/src/app/components/PreferenceDeactivatedPanel.js index 9c09f7b9e..d55a97dc2 100644 --- a/src/app/components/PreferenceDeactivatedPanel.js +++ b/src/app/components/PreferenceDeactivatedPanel.js @@ -15,7 +15,7 @@ class PreferenceDeactivatedPanel extends Component { render() { const { props, state } = this; const { - deactivatedWebsites, onReactivateWebsite + deactivatedWebsites, onReactivateWebsite, imagesUrl } = props; const { reactivatedWebsites } = state; @@ -27,32 +27,59 @@ class PreferenceDeactivatedPanel extends Component { const reactivatedWebsitesArray = [...reactivatedWebsites] .map(w => ({ website: w, active: true })); - console.log('d, r', websitesDisplayedAsDeactivatedArray, reactivatedWebsitesArray); + // console.log('d, r', websitesDisplayedAsDeactivatedArray, reactivatedWebsitesArray); const displayedWebsites = websitesDisplayedAsDeactivatedArray.concat(reactivatedWebsitesArray); displayedWebsites.sort(({ website: w1 }, { website: w2 }) => w1.localeCompare(w2)); const lis = displayedWebsites - .map(({ website, active }) =>
  • - {website} - -
  • ); - - return (
    -
      - {lis} -
    -
    ); + .map(({ website, active }) =>
  • + + + {website.replace(/^www\./, '')} + + +
  • ); + + return lis.length > 0 ? + (
    +
      {lis}
    +
    + +
    ) : + (

    Aucun site n’est désactivé :
    + L’assistant vous accompagne partout pour vous trouver + des recommandations susceptibles de vous intéresser. +

    ); } } diff --git a/src/app/components/PreferenceScreen.js b/src/app/components/PreferenceScreen.js index 6d51dfeaf..b42731c33 100644 --- a/src/app/components/PreferenceScreen.js +++ b/src/app/components/PreferenceScreen.js @@ -8,44 +8,77 @@ import { import PreferenceAboutPanel from './PreferenceAboutPanel'; import PreferenceDeactivatedPanel from './PreferenceDeactivatedPanel'; -export default function(props) { +function mainClassName(screenPanel) { + switch (screenPanel) { + case PREFERENCE_SCREEN_PANEL_ABOUT: + return 'preference-about'; + case PREFERENCE_SCREEN_PANEL_DEACTIVATED_WEBSITES: + return 'preference-deactivated-websites'; + default: + return ''; + } +} + +export default function (props) { const { preferenceScreenPanel, deactivatedWebsites, - onReactivateWebsite, openPrefScreen + onReactivateWebsite, openPrefScreen, imagesUrl, + onInstalledDetails } = props; let mainContent; switch (preferenceScreenPanel){ case PREFERENCE_SCREEN_PANEL_ABOUT: - mainContent = ; + mainContent = (); break; case PREFERENCE_SCREEN_PANEL_DEACTIVATED_WEBSITES: - mainContent = + imagesUrl={imagesUrl} + />); break; default: - console.error('Unknown content value', content); + console.error('Unknown content value', preferenceScreenPanel); } const changePanel = e => { - const newContent = e.target.getAttribute('data-panel'); + const newContent = e.currentTarget.getAttribute('data-panel'); openPrefScreen(newContent); }; - return (
    - -
    - {mainContent} -
    + +
  • + +
  • + + +
    +
    + {mainContent} +
    ); } - diff --git a/src/app/constants/ActionTypes.js b/src/app/constants/ActionTypes.js index b6cec4849..de353a027 100644 --- a/src/app/constants/ActionTypes.js +++ b/src/app/constants/ActionTypes.js @@ -11,9 +11,13 @@ export const HEAP_EVENT_TRACKED = 'heap/EVENT_TRACKED'; export const UPDATE_DRAFT_RECOMMANDATIONS = 'UPDATE_DRAFT_RECOMMANDATIONS'; +export const INSTALLED = 'INSTALLED'; + // content actions export const ALTERNATIVE_FOUND = 'ALTERNATIVE_FOUND'; +export const INSTALLED_DETAILS = 'INSTALLED_DETAILS'; + export const REDUCE_ALTERNATIVE_IFRAME = 'REDUCE_ALTERNATIVE_IFRAME'; export const EXTEND_ALTERNATIVE_IFRAME = 'EXTEND_ALTERNATIVE_IFRAME'; @@ -24,3 +28,4 @@ export const DEACTIVATED_WEBSITES = 'DEACTIVATED_WEBSITES'; export const DEACTIVATE = 'DEACTIVATE'; export const REACTIVATE_WEBSITE = 'REACTIVATE_WEBSITE'; + diff --git a/src/app/constants/assetsUrls.js b/src/app/constants/assetsUrls.js index 948366b54..e554c90ca 100644 --- a/src/app/constants/assetsUrls.js +++ b/src/app/constants/assetsUrls.js @@ -1,4 +1,3 @@ export const IMAGES_URL = chrome.extension.getURL('img/'); export const CONTRIBUTOR_IMAGES_URL = 'https://lmem-craft-backend.cleverapps.io/uploads/avatars/'; -export const FONTS_URL = chrome.extension.getURL('fonts/'); -export const STYLES_URL = chrome.extension.getURL('styles/'); + diff --git a/src/app/constants/origins.js b/src/app/constants/origins.js index 74667d6aa..1414b69e0 100644 --- a/src/app/constants/origins.js +++ b/src/app/constants/origins.js @@ -1,7 +1,10 @@ -const _LMEM_BACKEND_ORIGIN = process.env.LMEM_BACKEND_ORIGIN; - -if(typeof _LMEM_BACKEND_ORIGIN !== 'string'){ - throw new TypeError('Missing LMEM backend origin ' + _LMEM_BACKEND_ORIGIN); +function originFromEnv(key) { + const origin = process.env[key]; + if (typeof origin !== 'string') { + throw new TypeError(`Missing LMEM env '${key}': ${origin}`); + } + return origin; } -export const LMEM_BACKEND_ORIGIN = _LMEM_BACKEND_ORIGIN; \ No newline at end of file +export const LMEM_BACKEND_ORIGIN = originFromEnv('LMEM_BACKEND_ORIGIN'); +export const LMEM_SCRIPTS_ORIGIN = originFromEnv('LMEM_SCRIPTS_ORIGIN'); \ No newline at end of file diff --git a/src/app/constants/ui.js b/src/app/constants/ui.js index 63d428562..a5d1b4464 100644 --- a/src/app/constants/ui.js +++ b/src/app/constants/ui.js @@ -3,24 +3,28 @@ import React, { Component } from 'react'; export const PREFERENCE_SCREEN_PANEL_ABOUT = 'PREFERENCE_SCREEN_PANEL_ABOUT'; export const PREFERENCE_SCREEN_PANEL_DEACTIVATED_WEBSITES = 'PREFERENCE_SCREEN_PANEL_DEACTIVATED_WEBSITES'; +export const EXTENSION_VERSION = chrome.runtime.getManifest().version; + export const HEADER_CONTENT = { [PREFERENCE_SCREEN_PANEL_ABOUT]: imagesUrl => ( - + - Préférences de l'extension - A propos + Préférences + À propos ), [PREFERENCE_SCREEN_PANEL_DEACTIVATED_WEBSITES]: imagesUrl => ( - + - Préférences de l'extension - Sites désactivés + Préférences + Sites désactivés ), diff --git a/src/app/containers/App.js b/src/app/containers/App.js index 04cb6c9ca..067567241 100644 --- a/src/app/containers/App.js +++ b/src/app/containers/App.js @@ -4,7 +4,7 @@ import Alternative from '../components/Alternatives'; import uiActions from '../content/actions/ui.js'; import { IMAGES_URL, CONTRIBUTOR_IMAGES_URL } from '../constants/assetsUrls'; -import portCommunication from 'app/content/portCommunication'; +import portCommunication from '../content/portCommunication'; const { reduce, extend, deactivate, closePrefScreen, openPrefScreen, reactivateWebsite @@ -17,7 +17,8 @@ function mapStateToProps(state) { contributorUrl: CONTRIBUTOR_IMAGES_URL, reduced: state.get('reduced'), preferenceScreenPanel: state.get('preferenceScreenPanel'), - deactivatedWebsites: state.get('deactivatedWebsites') + deactivatedWebsites: state.get('deactivatedWebsites'), + onInstalledDetails: state.get('onInstalledDetails') }; } function mapDispatchToProps(dispatch) { diff --git a/src/app/content/actions/preferences.js b/src/app/content/actions/preferences.js index 0d6d3dbe7..6076962e4 100644 --- a/src/app/content/actions/preferences.js +++ b/src/app/content/actions/preferences.js @@ -1,8 +1,15 @@ -import { DEACTIVATED_WEBSITES } from '../../constants/ActionTypes'; +import { DEACTIVATED_WEBSITES, INSTALLED_DETAILS } from '../../constants/ActionTypes'; -export default function (deactivatedWebsites) { +export function updateDeactivatedWebsites(deactivatedWebsites) { return { type: DEACTIVATED_WEBSITES, deactivatedWebsites }; } + +export function updateInstalledDetails(onInstalledDetails) { + return { + type: INSTALLED_DETAILS, + onInstalledDetails + }; +} diff --git a/src/app/content/reducers/index.js b/src/app/content/reducers/index.js index a8433bae1..056183749 100644 --- a/src/app/content/reducers/index.js +++ b/src/app/content/reducers/index.js @@ -6,7 +6,8 @@ import { OPEN_PREFERENCE_PANEL, CLOSE_PREFERENCE_PANEL, DEACTIVATED_WEBSITES, - REACTIVATE_WEBSITE + REACTIVATE_WEBSITE, + INSTALLED_DETAILS, } from '../../constants/ActionTypes'; export default function (state = {}, action) { @@ -15,7 +16,7 @@ export default function (state = {}, action) { switch (type) { case ALTERNATIVE_FOUND: { const { alternative } = action; - return state.set('alternative', alternative); + return state.set('alternative', alternative).set('reduced', false); } case REDUCE_ALTERNATIVE_IFRAME: @@ -39,6 +40,10 @@ export default function (state = {}, action) { const { deactivatedWebsites } = action; return state.set('deactivatedWebsites', deactivatedWebsites); + case INSTALLED_DETAILS: + const { onInstalledDetails } = action; + return state.set('onInstalledDetails', onInstalledDetails); + case REACTIVATE_WEBSITE: const { website } = action; return state.set('deactivatedWebsites', state.get('deactivatedWebsites').delete(website)); diff --git a/src/app/events/trackEvents.js b/src/app/events/trackEvents.js index 924ba1a64..977155d74 100644 --- a/src/app/events/trackEvents.js +++ b/src/app/events/trackEvents.js @@ -1,4 +1,4 @@ -import { trackHeapEvent } from '../actions/heap'; +// import { trackHeapEvent } from '../actions/heap'; // Arbitrary set max payload size // @TODO find a nicer way to handle the error @@ -24,7 +24,12 @@ const MAX_PAYLOAD_SIZE = 10000; * Could be implemented in a much nicer way though. */ const events = store => next => action => { - // Check payload size to avoid HTTP 414 Request-URI Too Large url + if (!window.heap) { + console.log(`Heap analytics disabled: ignore tracking of "${action.type}"`); + return next(action); + } + + // Check payload size to avoid HTTP 414 Request-URI Too Large url const payloadSize = JSON.stringify(action).length; if (payloadSize > MAX_PAYLOAD_SIZE) { console.log('Payload size too large', payloadSize); @@ -32,8 +37,7 @@ const events = store => next => action => { } else { window.heap.track(action.type, action.payload); } - const result = next(action); - return result; + return next(action); }; export default events; \ No newline at end of file diff --git a/src/app/reducers/index.js b/src/app/reducers/index.js index a3dd0e857..97520968d 100644 --- a/src/app/reducers/index.js +++ b/src/app/reducers/index.js @@ -3,7 +3,8 @@ import { RECEIVED_MATCHING_CONTEXTS, DEACTIVATE, REACTIVATE_WEBSITE, - UPDATE_DRAFT_RECOMMANDATIONS + UPDATE_DRAFT_RECOMMANDATIONS, + INSTALLED } from '../constants/ActionTypes'; import { DEACTIVATE_EVERYWHERE, DEACTIVATE_WEBSITE_ALWAYS } from '../constants/preferences'; @@ -77,6 +78,11 @@ export default function (state = {}, action) { return Object.assign({}, state, { draftRecommandations }); } + case INSTALLED: { + const { onInstalledDetails } = action; + return Object.assign({}, state, { onInstalledDetails }); + } + default: return state; } diff --git a/src/app/styles/_variables.scss b/src/app/styles/_variables.scss index a7d25756a..001f6ff88 100644 --- a/src/app/styles/_variables.scss +++ b/src/app/styles/_variables.scss @@ -54,7 +54,8 @@ $pythagoras-const: 1.41421; // Layout widths $global-width: rem-size(940); -$sideframe-width: $global-width * 1/3; +//$sideframe-width: $global-width * 1/3; +$sideframe-width: $block-size * 4; $controls-inner-width: $block-size * 1.5; // Media Query Thresholds diff --git a/src/app/styles/elements.scss b/src/app/styles/elements.scss index b56503c14..717884095 100644 --- a/src/app/styles/elements.scss +++ b/src/app/styles/elements.scss @@ -14,6 +14,7 @@ html, body{ margin: 0; padding: 0; + height: 100%; } // Content elements diff --git a/src/app/styles/main.scss b/src/app/styles/main.scss index cf61721ad..383bdac51 100644 --- a/src/app/styles/main.scss +++ b/src/app/styles/main.scss @@ -102,12 +102,12 @@ main { } .menu-right { - left: calc(100% + #{ rem-size(3) }); + left: calc(100% + #{rem-size(3)}); top: 0; } .menu-left { - right: calc(100% + #{ rem-size(3) }); + right: calc(100% + #{rem-size(3)}); top: 0; } @@ -362,7 +362,7 @@ ul.summary-entry-content { .pane-opened::after { @include iso-rect-triangle('to top', $simple-line-height, $heavy-border-color); bottom: -$midway-font-size; - left: calc(50% - #{ $simple-line-height * $pythagoras-const / 2 }); + left: calc(50% - #{$simple-line-height * $pythagoras-const / 2}); } } @@ -736,7 +736,7 @@ ul.summary-entry-content { } .fieldset-inner-wrapper { - margin: #{ $margin-size * 1/3 } 0 0; + margin: #{$margin-size * 1/3} 0 0; } } @@ -900,7 +900,7 @@ input[type=text] { max-height: 2 * $double-line-height; } - @media(max-width: #{ $media-query-threshold-wide - $block-size }) { + @media(max-width: #{$media-query-threshold-wide - $block-size}) { label small { @include visually-hidden; } @@ -1085,6 +1085,19 @@ input[type=text] { strong { color: $highlight-color; } + + .lmem-topbar-preferences { + color: inherit; + + > img { + margin-right: $half-adjusted-margin; + } + + > span:not(:last-child)::after { + content: '-'; + margin: 0 $half-adjusted-margin; + } + } } @@ -1127,9 +1140,12 @@ input[type=text] { } -.lmem-controls-list > li { - display: inline-block; - margin-right: $margin-size; +.lmem-controls-list { + display: flex; + + > li { + margin-left: $margin-size; + } } .lmem-controls-picto { diff --git a/src/app/styles/preference-screen.scss b/src/app/styles/preference-screen.scss index 2eafd82b8..7d238fb0f 100644 --- a/src/app/styles/preference-screen.scss +++ b/src/app/styles/preference-screen.scss @@ -2,34 +2,182 @@ display: flex; flex-direction: row; + margin-top: $half-adjusted-margin; + box-sizing: border-box; * { box-sizing: border-box; } nav { - width: 10%; + flex: 0 0 auto; - display: flex; - flex-direction: column; - align-items: stretch; - justify-content: flex-start; + ul { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + } + + li { + margin-bottom: $half-adjusted-margin; + } button { - background: transparent; - border: 0; - padding: 1em; + color: inherit; + font-size: $simple-line-height; + line-height: $double-line-height; - &:hover{ - background: hsla(0, 0%, 50%, 0.3); - } + align-items: center; + + padding: 0 $margin-size 0 $half-adjusted-margin; + width: 100%; + } + + img { + margin-right: 1ex; + margin-left: initial; + opacity: .9; } } main { - flex: 1; + flex: 1 1 auto; + } +} + +.preference-about { + + aside { + margin-top: $margin-size; + font-size: $tiny-font-size; + + ul { + display: flex; + } + li:not(:last-child)::after { + content: '-'; + margin: 0 1ex; + } + + h1, + h2 { + font-size: $simple-line-height; + margin: $half-adjusted-margin 0 #{$margin-size - $half-adjusted-margin}; + } + } + + time { + white-space: nowrap; + } +} + +.preference-deactivated-websites { + p { + margin-top: $half-adjusted-margin; + } + + > div { + display: flex; + } + aside { + flex: 1 1 50%; + color: $font-quiet-color; + + h1, + h2 { + font-weight: 600; + margin: $half-adjusted-margin 0 #{$margin-size - $half-adjusted-margin}; + } + + img { + vertical-align: bottom; + } + } + ul { + flex: 1 0 auto; + display: flex; + flex-direction: column; + align-items: flex-start; + overflow-y: auto; + max-height: $block-size * 3; + } + li { + flex: 0 0 auto; + display: flex; + line-height: $double-line-height; + margin-bottom: $half-adjusted-margin; + + &.reactivated { + > button, + > .deactivated-website-title { + opacity: .5; + } - padding: 1em; + > button { + background: none; + + width: $block-size / 2; + margin-left: $block-size / 2; + margin-right: $block-size / 2; + padding: 0; + + border-radius: 100%; + + > img { + width: $double-line-height; + opacity: .8; + } + } + + &:hover { + > button, + > span { opacity: initial } + } + } + + > button { + align-self: center; + height: $double-line-height; + width: $block-size * 3 / 2; + + font-size: $simple-line-height; + line-height: $simple-line-height; + font-weight: inherit; + + opacity: 0; + transition: opacity, border-radius .2s ease-out 50ms; + } + + &:hover > button, + > button:focus, + > button:active { + opacity: initial; + } + + transition: background-color .2s ease-out; + &:hover { + background-color: $background-light-color; + + img { + filter: initial; + -webkit-filter: initial; + opacity: initial; + } + } } + .deactivated-website-title { + font-size: $midway-font-size; + margin: 0 $margin-size 0 $half-adjusted-margin; + + img { + margin-right: $half-adjusted-margin; + vertical-align: middle; + + filter: grayscale(100%); + -webkit-filter: grayscale(100%); + opacity: .7; + } + } } \ No newline at end of file diff --git a/src/app/styles/reco-header.scss b/src/app/styles/reco-header.scss index 35fe154d2..52ef457a6 100644 --- a/src/app/styles/reco-header.scss +++ b/src/app/styles/reco-header.scss @@ -4,6 +4,10 @@ > header{ background: $background-em-color; + color: $font-em-color; + + flex: 0 0 100%; + display: flex; flex-direction: row; @@ -11,10 +15,13 @@ justify-content: flex-start; padding: $margin-size; + max-height: $block-size; + line-height: $double-line-height; > .logo{ position: relative; // so that the tooltip with position: absolute works + max-height: $block-size - $margin-size; background: inherit; box-shadow: none; border: 0; @@ -38,6 +45,7 @@ button.reduce{ align-items: center; line-height: $double-line-height; + display: flex; } .separation-bar{ @@ -46,14 +54,14 @@ // Win some space hiding secondary UI elements. - @media(max-width: #{ $media-query-threshold-small-edge - rem-size(1) }) { + @media(max-width: #{$media-query-threshold-small-edge - rem-size(1)}) { z-index: 20; .button-label { @include visually-hidden; } .button-compact.with-image img { - margin-left: .5ex; + margin-left: initial; } } @@ -72,8 +80,5 @@ min-width: $controls-inner-width; } } - } - - -} \ No newline at end of file +} diff --git a/src/app/styles/reco-main.scss b/src/app/styles/reco-main.scss index 322cb0812..688285ef3 100644 --- a/src/app/styles/reco-main.scss +++ b/src/app/styles/reco-main.scss @@ -5,13 +5,11 @@ & > main{ display: flex; justify-content: flex-end; - margin-top: 0.539em; + margin-top: $half-adjusted-margin; a.mainframe{ flex: 20 1 0; - - margin-left: $adjusted-margin; - padding-left: $margin-size; + align-self: flex-start; border: solid $border-width $background-light-color; display: block; @@ -27,6 +25,5 @@ } } } - } \ No newline at end of file diff --git a/src/app/styles/reco.scss b/src/app/styles/reco.scss index 3c2f9958c..2fe4fa317 100644 --- a/src/app/styles/reco.scss +++ b/src/app/styles/reco.scss @@ -10,7 +10,7 @@ .reco-summary-header { display: flex; justify-content: space-between; - align-items: baseline; + align-items: flex-start; } .reco-summary-title { @@ -79,7 +79,7 @@ } img { - height: rem-size(20); + height: rem-size(16); margin-right: 1ex; } } diff --git a/src/app/styles/top-level.scss b/src/app/styles/top-level.scss index ada5b8fdd..848ba7d73 100644 --- a/src/app/styles/top-level.scss +++ b/src/app/styles/top-level.scss @@ -15,12 +15,15 @@ body { } .lmem-top-level { + height: inherit; display: flex; flex-direction: column; - > *{ - padding: 0.539em $margin-size; - } + > * { + padding: $half-adjusted-margin $margin-size; + } } + + diff --git a/src/app/tabs/index.js b/src/app/tabs/index.js index 482f2db54..a23d12951 100644 --- a/src/app/tabs/index.js +++ b/src/app/tabs/index.js @@ -1,6 +1,6 @@ export default function ( tabs, - { findMatchingOffers, dispatch, contentCode, contentStyle, getDeactivatedWebsites } + { findMatchingOffers, dispatch, contentCode, contentStyle, getDeactivatedWebsites, getOnInstalledDetails } ) { const matchingTabIdToPortP = new Map(); @@ -27,7 +27,8 @@ export default function ( tabPort.postMessage({ type: 'init', style: contentStyle, - deactivatedWebsites: [...getDeactivatedWebsites()] + deactivatedWebsites: [...getDeactivatedWebsites()], + onInstalledDetails: getOnInstalledDetails() }); resolve(tabPort); diff --git a/src/assets/img/ball.svg b/src/assets/img/ball.svg index fd73a0cc3..fc5c82bb1 100644 --- a/src/assets/img/ball.svg +++ b/src/assets/img/ball.svg @@ -1,1046 +1,6 @@ - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ? - - - ? - - - - - - i - - - - - i - - - i - - - i - - - i - - - - - - - - - + + + + diff --git a/src/assets/img/close.svg b/src/assets/img/close.svg index 9bcac290f..9130abd24 100644 --- a/src/assets/img/close.svg +++ b/src/assets/img/close.svg @@ -1,145 +1,6 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ? - - - ? - - - - - - i - - - i - - - i - - - i - - - i - - - i - - - - - - - - - + + + diff --git a/src/assets/img/valid.svg b/src/assets/img/valid.svg new file mode 100644 index 000000000..ad954e964 --- /dev/null +++ b/src/assets/img/valid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/browser/extension/background/index.js b/src/browser/extension/background/index.js index b04dce897..e9862c92c 100644 --- a/src/browser/extension/background/index.js +++ b/src/browser/extension/background/index.js @@ -1,7 +1,10 @@ /* eslint global-require: "off" */ +// Early imports with high priority stuff involved, such as event listeners creation +import onInstalled from '../../../app/actions/install'; +import loadHeap from '../../../lib/heap'; + import configureStore from './../../../app/store/configureStore'; -import initBadge from './badge'; import findMatchingOffersAccordingToPreferences from '../../../app/lmem/findMatchingOffersAccordingToPreferences'; @@ -11,7 +14,8 @@ import prepareDraftPreview from '../../../app/lmem/draft-preview/main.js'; import { dispatchInitialStateFromBackend } from '../../../app/actions/kraftBackend'; import updateDraftRecommandations from '../../../app/actions/updateDraftRecommandations'; -import heap from './../../../lib/heap'; +import {LMEM_BACKEND_ORIGIN, LMEM_SCRIPTS_ORIGIN} from '../../../app/constants/origins'; + /** * FIXME import styles from components instead and let Webpack taking care of them... * @@ -28,12 +32,21 @@ import mainStyles from './../../../app/styles/main.scss'; if(process.env.NODE_ENV !== 'production'){ console.info('NODE_ENV', process.env.NODE_ENV); } -console.info('LMEM_BACKEND_ORIGIN', process.env.LMEM_BACKEND_ORIGIN); +console.info(`LMEM_BACKEND_ORIGIN "${LMEM_BACKEND_ORIGIN}"`); +console.info(`LMEM_SCRIPTS_ORIGIN "${LMEM_SCRIPTS_ORIGIN}"`); +const heapAppId = process.env.HEAP_APPID; +if (typeof heapAppId === 'string') { + console.info(`Heap loading with appId "${heapAppId}"`); + loadHeap(heapAppId); +} +else { + console.warn('Heap analytics disabled: assuming "process.env.HEAP_APPID" is deliberately not defined.'); +} // Load content code when the extension is loaded -const contentCodeP = fetch('./js/content.bundle.js').then(resp => resp.text()); -const draftRecoContentCodeP = fetch('./js/grabDraftRecommandations.js').then(resp => resp.text()); +const contentCodeP = fetch(LMEM_SCRIPTS_ORIGIN + '/js/content.bundle.js').then(resp => resp.text()); +const draftRecoContentCodeP = fetch(LMEM_SCRIPTS_ORIGIN + '/js/grabDraftRecommandations.js').then(resp => resp.text()); configureStore(store => { window.store = store; @@ -71,11 +84,14 @@ configureStore(store => { const deactivated = prefs.deactivated || {}; return deactivated.deactivatedWebsites || new Set(); }, + getOnInstalledDetails: () => { + const state = store.getState(); + return state.onInstalledDetails || {}; + }, dispatch: store.dispatch, contentCode, contentStyle: mainStyles - } - ); + }); }); draftRecoContentCodeP @@ -86,6 +102,10 @@ configureStore(store => { ) ); + if (!store.getState().onInstalledDetails) { + store.dispatch(onInstalled()); + } + store.dispatch(dispatchInitialStateFromBackend()); // store initialization from the kraft server if (process.env.NODE_ENV !== 'production') { diff --git a/src/browser/extension/content/index.js b/src/browser/extension/content/index.js index e003993b8..40e65902a 100644 --- a/src/browser/extension/content/index.js +++ b/src/browser/extension/content/index.js @@ -1,40 +1,128 @@ -import { Record, Set as ImmutableSet } from 'immutable'; +import { Record, Set as ImmutableSet, Map as ImmutableMap, fromJS as immutableFromJS } from 'immutable'; import React from 'react'; import { render } from 'react-dom'; import Root from '../../../app/containers/Root'; -import configureStore from '../../../app/store/configureStore'; - -import Alternative from '../../../app/components/Alternatives'; -import { STYLES_URL, IMAGES_URL } from '../../../app/constants/assetsUrls'; import { createStore } from 'redux'; import rootReducer from '../../../app/content/reducers'; import alternativeFound from '../../../app/content/actions/alternatives'; -import updateDeactivatedWebsites from '../../../app/content/actions/preferences'; +import { updateDeactivatedWebsites, updateInstalledDetails } from '../../../app/content/actions/preferences'; import portCommunication from '../../../app/content/portCommunication'; -import { - REDUCE_ALTERNATIVE_IFRAME, - EXTEND_ALTERNATIVE_IFRAME -} from '../../../app/constants/ActionTypes.js'; - const IFRAME_EXTENDED_HEIGHT = '255px'; const IFRAME_REDUCED_HEIGHT = '60px'; +const EXTENSION_STATE_SHOW_LOADING = 'EXTENSION_STATE_SHOW_LOADING'; +const EXTENSION_STATE_SHOW_ALTERNATIVE = 'EXTENSION_STATE_SHOW_ALTERNATIVE'; + +const AFTER_DOMCOMPLETE_DELAY = 5000; +const AFTER_LOADEND_DELAY = 1000; +const LOADING_SCREEN_DELAY = 4000; + +/* + LIB +*/ +function createExtensionIframe(reduced, style, onLoad){ + const iframe = document.createElement('iframe'); + iframe.id = 'lmemFrame'; + iframe.width = '100%'; + iframe.height = reduced ? IFRAME_REDUCED_HEIGHT : IFRAME_EXTENDED_HEIGHT; + iframe.style.position = 'fixed'; + iframe.style.bottom = 0; + iframe.style.left = 0; + iframe.style.right = 0; + iframe.style.zIndex = 2147483647; // Max z-index value (signed 32bits integer) + iframe.style.background = '#FDF6E3'; // UI bg color (avoid having a transparent iframe after injection) + iframe.style.border = 'none'; + iframe.style.transition = 'height .1s'; + iframe.style.boxShadow = '0 0 15px #888'; + iframe.srcdoc = ` + + + + + + + + `; + + iframe.onload = onLoad; + + return iframe; +} + + +/* + SETUP +*/ + +const DOMCompleteP = Promise.resolve(); // because the content script is loaded at "document_end" + +const DOMCompletePlusDelayP = DOMCompleteP.then(() => { + return new Promise(resolve => { + const {navigationStart, domContentLoadedEventStart} = performance.timing; + + const diff = domContentLoadedEventStart - navigationStart; + + if(diff >= AFTER_DOMCOMPLETE_DELAY) + resolve(); + else + setTimeout(resolve, AFTER_DOMCOMPLETE_DELAY - diff); + }); +}); + + +const LoadEndP = new Promise(resolve => { + document.addEventListener('load', resolve); +}); + +const LoadEndPlusDelayP = LoadEndP.then(() => { + return new Promise(resolve => { + const {navigationStart, loadEventStart} = performance.timing; + + const diff = loadEventStart - navigationStart; + + if(diff >= AFTER_LOADEND_DELAY) + resolve(); + else + setTimeout(resolve, AFTER_LOADEND_DELAY - diff); + }); +}); + +// Wait for some time before showing the extension to the user in loading mode +const CanShowIframeLoadingP = Promise.race([DOMCompletePlusDelayP, LoadEndPlusDelayP]); + +// User research showed that the LMEM loading screen is important so people don't +// think the LMEM iframe is an ad. +// Wait for some time loading before showing an alternative. +const CanShowAlternativeIfAvailableP = process.env.NODE_ENV === 'development' ? + Promise.resolve() : // otherwise the delay is annoying when developing + CanShowIframeLoadingP.then(() => { + return new Promise(resolve => { + setTimeout(resolve, LOADING_SCREEN_DELAY); + }); + }); + + + // create redux store const store = createStore( rootReducer, new Record({ open: true, - reduced: false, + reduced: true, preferenceScreenPanel: undefined, // preference screen close alternative: undefined, - deactivatedWebsites: new ImmutableSet() + deactivatedWebsites: new ImmutableSet(), + onInstalledDetails: new ImmutableMap(), })() ); + + + // reach back to background script chrome.runtime.onConnect.addListener(function listener(portToBackground) { portCommunication.port = portToBackground; @@ -46,60 +134,54 @@ chrome.runtime.onConnect.addListener(function listener(portToBackground) { switch (type) { case 'init': - const { style, deactivatedWebsites } = msg; - const reduced = store.getState().get('reduced'); - const lmemContentContainerP = new Promise(resolve => { - const iframe = document.createElement('iframe'); - iframe.id = 'lmemFrame'; - iframe.width = '100%'; - iframe.height = reduced ? IFRAME_REDUCED_HEIGHT : IFRAME_EXTENDED_HEIGHT; - iframe.style.position = 'fixed'; - iframe.style.bottom = '0px'; - iframe.style.left = '0px'; - iframe.style.right = '0px'; - iframe.style.zIndex = '999999999'; - iframe.srcdoc = ` - - - - - - - - `; - - iframe.onload = function () { - resolve(iframe.contentDocument.body); - }; - document.body.appendChild(iframe); - - store.subscribe(() => { - const state = store.getState(); - - if (!state.get('open')) { - iframe.remove(); - } - else { - iframe.height = state.get('reduced') ? IFRAME_REDUCED_HEIGHT : IFRAME_EXTENDED_HEIGHT; - } - - }); - }); + const { style, deactivatedWebsites, onInstalledDetails } = msg; store.dispatch(updateDeactivatedWebsites(new ImmutableSet(deactivatedWebsites))); + store.dispatch(updateInstalledDetails(immutableFromJS(onInstalledDetails))); + + // Let the page load a bit before showing the iframe in loading mode + CanShowIframeLoadingP + .then(() => { + + return new Promise(resolve => { + const iframe = createExtensionIframe( + store.getState().get('reduced'), + style, + () => { resolve(iframe.contentDocument.body); } + ); + + document.body.appendChild(iframe); + + store.subscribe(() => { + const state = store.getState(); + + if (!state.get('open')) { + iframe.remove(); + } + else { + iframe.height = state.get('reduced') ? IFRAME_REDUCED_HEIGHT : IFRAME_EXTENDED_HEIGHT; + } + + }); + }) + .then(lmemContainer => { + render(, lmemContainer); + }); - lmemContentContainerP.then(lmemContentContainer => { - render( - , - lmemContentContainer - ); }); + break; case 'alternative': const { alternative } = msg; - // console.log('alternative in content', alternative); - store.dispatch(alternativeFound(alternative)); + + // Even if the alternative arrived early, let the page load a bit before + // showing the iframe in loading mode + CanShowAlternativeIfAvailableP + .then(() => { + store.dispatch(alternativeFound(alternative)); + }); + break; default: console.error('Content script: unrecognized message type from background', type, msg); diff --git a/src/browser/extension/heap/index.js b/src/browser/extension/heap/index.js index bad70f298..c0f55c7a7 100644 --- a/src/browser/extension/heap/index.js +++ b/src/browser/extension/heap/index.js @@ -1,14 +1,15 @@ import React from 'react'; import { render } from 'react-dom'; -import Root from 'app/containers/Root'; -import configureStore from 'app/store/configureStore'; +import Root from '../../../app/containers/Root'; +import configureStore from '../../../app/store/configureStore'; +import { LMEM_SCRIPTS_ORIGIN } from '../../../app/constants/origins'; configureStore(store => { window.addEventListener('load', () => { console.log('Injecting heap analytics'); - let injectScript = document.createElement('script'); - injectScript.src('https://ui.lmem.net/js/heap.js'); + const injectScript = document.createElement('script'); + injectScript.src(LMEM_SCRIPTS_ORIGIN + '/js/heap.js'); document.getElementsByTagName('head')[0].appendChild(injectScript); render( diff --git a/src/browser/extension/manifest/dev.js b/src/browser/extension/manifest/dev.js index 166748c5d..43a8cfde9 100644 --- a/src/browser/extension/manifest/dev.js +++ b/src/browser/extension/manifest/dev.js @@ -1,5 +1,5 @@ import base from './base.js'; -import csp from "content-security-policy-builder"; +import csp from 'content-security-policy-builder'; export default Object.assign( {}, diff --git a/src/browser/extension/manifest/prod.js b/src/browser/extension/manifest/prod.js index 9e39d278d..58137e063 100644 --- a/src/browser/extension/manifest/prod.js +++ b/src/browser/extension/manifest/prod.js @@ -1,5 +1,5 @@ import base from './base.js'; -import csp from "content-security-policy-builder"; +import csp from 'content-security-policy-builder'; export default Object.assign( {}, @@ -9,7 +9,8 @@ export default Object.assign( 'content_security_policy': csp({ 'directives': { 'default-src': [ - 'https://lmem-craft-backend.cleverapps.io' + 'https://lmem-craft-backend.cleverapps.io', + 'https://ui.lmem.net', ], 'script-src': [ 'https://ui.lmem.net', diff --git a/src/browser/extension/manifest/staging.js b/src/browser/extension/manifest/staging.js index 5547cd254..cb602d0f6 100644 --- a/src/browser/extension/manifest/staging.js +++ b/src/browser/extension/manifest/staging.js @@ -1,5 +1,5 @@ import base from './base.js'; -import csp from "content-security-policy-builder"; +import csp from 'content-security-policy-builder'; export default Object.assign( {}, @@ -9,7 +9,8 @@ export default Object.assign( 'content_security_policy': csp({ 'directives': { 'default-src': [ - 'https://preprod-lmem-craft-backend.cleverapps.io' + 'https://preprod-lmem-craft-backend.cleverapps.io', + 'https://testing.ui.lmem.net' ], 'script-src': [ 'https://testing.ui.lmem.net', diff --git a/src/lib/heap.js b/src/lib/heap.js index 6b2bb0cfe..963b78cfd 100644 --- a/src/lib/heap.js +++ b/src/lib/heap.js @@ -1,2 +1,4 @@ -window.heap=window.heap||[],heap.load=function(e,t){window.heap.appid=e,window.heap.config=t=t||{};var r=t.forceSSL||"https:"===document.location.protocol,a=document.createElement("script");a.type="text/javascript",a.async=!0,a.src=(r?"https:":"http:")+"//cdn.heapanalytics.com/js/heap-"+e+".js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(a,n);for(var o=function(e){return function(){heap.push([e].concat(Array.prototype.slice.call(arguments,0)))}},p=["addEventProperties","addUserProperties","clearEventProperties","identify","removeEventProperty","setEventProperties","track","unsetEventProperty"],c=0;c 0) { - console.warn("[HMR] The following modules couldn't be hot updated: (They would need a full reload!)"); - unacceptedModules.forEach(function(moduleId) { - console.warn("[HMR] - " + moduleId); - }); - - if(chrome && chrome.runtime && chrome.runtime.reload) { - console.warn("[HMR] extension reload"); - chrome.runtime.reload(); - } else { - console.warn("[HMR] Can't extension reload. not found chrome.runtime.reload."); - } - } - - if(!renewedModules || renewedModules.length === 0) { - console.log("[HMR] Nothing hot updated."); - } else { - console.log("[HMR] Updated modules:"); - renewedModules.forEach(function(moduleId) { - console.log("[HMR] - " + moduleId); - }); - } -}; diff --git a/webpack/staging.config.js b/webpack/staging.config.js index 35c985e26..4f991a6f2 100644 --- a/webpack/staging.config.js +++ b/webpack/staging.config.js @@ -5,7 +5,7 @@ const srcPath = path.join(__dirname, '../src/browser/'); export default baseConfig({ input: { background: [`${srcPath}extension/background/`], - window: [`${srcPath}window/`], + // window: [`${srcPath}window/`], //popup: [`${srcPath}extension/popup/`], content: [`${srcPath}extension/content/`] }, @@ -22,7 +22,9 @@ export default baseConfig({ globals: { 'process.env': { NODE_ENV: '"staging"', - LMEM_BACKEND_ORIGIN: '"https://preprod-lmem-craft-backend.cleverapps.io"' + LMEM_BACKEND_ORIGIN: '"https://preprod-lmem-craft-backend.cleverapps.io"', + LMEM_SCRIPTS_ORIGIN: "'https://testing.ui.lmem.net'", + HEAP_APPID: '"234457910"', // testing } } });