From cedbe1481f542e08dc5e689e22ae7d16b34db711 Mon Sep 17 00:00:00 2001
From: Bastien DUMONT <bdumont@grandlyon.com>
Date: Mon, 22 Jan 2024 16:20:34 +0000
Subject: [PATCH] feat(mail): unsubscribe newsletter

---
 .gitignore                                    |   3 -
 .vscode/settings.json                         |   2 +
 manifest.webapp                               |   5 +
 .../Unsubscribe/UnSubscribeView.spec.tsx      |  52 --------
 .../Options/Unsubscribe/UnSubscribeView.tsx   |  56 ---------
 .../Options/Unsubscribe/Unsubscribe.spec.tsx  |  31 +++++
 .../Options/Unsubscribe/Unsubscribe.tsx       |  80 ++++++++++++
 .../UnSubscribeView.spec.tsx.snap             |  55 --------
 .../__snapshots__/Unsubscribe.spec.tsx.snap   |  52 ++++++++
 ...{unSubscribeView.scss => unsubscribe.scss} |   0
 src/components/Routes/Routes.tsx              |   2 -
 src/doctypes/io-cozy-permissions.ts           |   1 +
 src/locales/fr.json                           |  10 +-
 src/services/permissions.service.ts           |  55 ++++++++
 src/targets/browser/index.tsx                 |   4 +-
 src/targets/public/index.ejs                  |  49 ++++++++
 src/targets/public/index.tsx                  | 119 ++++++++++++++++++
 .../services/monthlyReportNotification.ts     |  61 +++++----
 src/targets/vendor/assets/offline.html        |  27 +---
 src/targets/vendor/assets/unsubscribe.html    |  64 ++++++++++
 20 files changed, 496 insertions(+), 232 deletions(-)
 delete mode 100644 src/components/Options/Unsubscribe/UnSubscribeView.spec.tsx
 delete mode 100644 src/components/Options/Unsubscribe/UnSubscribeView.tsx
 create mode 100644 src/components/Options/Unsubscribe/Unsubscribe.spec.tsx
 create mode 100644 src/components/Options/Unsubscribe/Unsubscribe.tsx
 delete mode 100644 src/components/Options/Unsubscribe/__snapshots__/UnSubscribeView.spec.tsx.snap
 create mode 100644 src/components/Options/Unsubscribe/__snapshots__/Unsubscribe.spec.tsx.snap
 rename src/components/Options/Unsubscribe/{unSubscribeView.scss => unsubscribe.scss} (100%)
 create mode 100644 src/doctypes/io-cozy-permissions.ts
 create mode 100644 src/services/permissions.service.ts
 create mode 100644 src/targets/public/index.ejs
 create mode 100644 src/targets/public/index.tsx
 create mode 100644 src/targets/vendor/assets/unsubscribe.html

diff --git a/.gitignore b/.gitignore
index 978c511aa..724a02d35 100644
--- a/.gitignore
+++ b/.gitignore
@@ -75,9 +75,6 @@ mobile/Preview.html
 # /!\ KEEP THIS SECTION THE LAST ONE
 !.gitkeep
 
-#Report bundle analyser
-public/
-
 # CSS
 src/styles/index.css
 src/styles/index.css.map
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9e93bd20a..dcb2207f4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -134,7 +134,9 @@
     "pseudonymisées",
     "reduxjs",
     "Reinit",
+    "sendmail",
     "SHARAPOWATT",
+    "shortcodes",
     "splashscreen",
     "swipeable",
     "Swipeable",
diff --git a/manifest.webapp b/manifest.webapp
index bab84280f..d9ba90669 100644
--- a/manifest.webapp
+++ b/manifest.webapp
@@ -162,6 +162,11 @@
     "url": "https://www.grandlyon.com/"
   },
   "routes": {
+    "/public": {
+      "folder": "/public",
+      "index": "index.html",
+      "public": true
+    },
     "/": {
       "folder": "/",
       "index": "index.html",
diff --git a/src/components/Options/Unsubscribe/UnSubscribeView.spec.tsx b/src/components/Options/Unsubscribe/UnSubscribeView.spec.tsx
deleted file mode 100644
index 790c171bb..000000000
--- a/src/components/Options/Unsubscribe/UnSubscribeView.spec.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import React from 'react'
-import { Provider } from 'react-redux'
-import * as profileActions from 'store/profile/profile.slice'
-import { createMockEcolyoStore } from 'tests/__mocks__/store'
-import UnSubscribeView from './UnSubscribeView'
-
-jest.mock('components/Header/CozyBar', () => 'mock-cozybar')
-jest.mock('components/Header/Header', () => 'mock-header')
-jest.mock('components/Content/Content', () => 'mock-content')
-
-const mockUpdateProfile = jest.fn()
-jest.mock('services/profile.service', () => {
-  return jest.fn(() => ({
-    updateProfile: mockUpdateProfile,
-  }))
-})
-const updateProfileSpy = jest.spyOn(profileActions, 'updateProfile')
-const mockedNavigate = jest.fn()
-jest.mock('react-router-dom', () => ({
-  ...jest.requireActual('react-router-dom'),
-  useNavigate: () => mockedNavigate,
-}))
-
-describe('UnSubscribe component', () => {
-  const store = createMockEcolyoStore()
-  beforeEach(() => {
-    jest.clearAllMocks()
-  })
-
-  it('should be rendered correctly', () => {
-    const { container } = render(
-      <Provider store={store}>
-        <UnSubscribeView />
-      </Provider>
-    )
-    expect(container).toMatchSnapshot()
-  })
-
-  it('should click on button and deactivate report', async () => {
-    render(
-      <Provider store={store}>
-        <UnSubscribeView />
-      </Provider>
-    )
-    await userEvent.click(screen.getByRole('button'))
-    expect(updateProfileSpy).toHaveBeenCalledWith({
-      sendAnalysisNotification: false,
-    })
-  })
-})
diff --git a/src/components/Options/Unsubscribe/UnSubscribeView.tsx b/src/components/Options/Unsubscribe/UnSubscribeView.tsx
deleted file mode 100644
index f67dbdc7d..000000000
--- a/src/components/Options/Unsubscribe/UnSubscribeView.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Button } from '@material-ui/core'
-import BearIcon from 'assets/icons/visu/duelResult/CHALLENGE0001-0.svg'
-import StyledIcon from 'components/CommonKit/Icon/StyledIcon'
-import Content from 'components/Content/Content'
-import CozyBar from 'components/Header/CozyBar'
-import Header from 'components/Header/Header'
-import { useI18n } from 'cozy-ui/transpiled/react/I18n'
-import React, { useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { useAppDispatch } from 'store/hooks'
-import { updateProfile } from 'store/profile/profile.slice'
-import './unSubscribeView.scss'
-
-const UnSubscribeView = () => {
-  const { t } = useI18n()
-  const navigate = useNavigate()
-  const dispatch = useAppDispatch()
-  const [headerHeight, setHeaderHeight] = useState<number>(0)
-  const unSubscribe = async () => {
-    dispatch(updateProfile({ sendAnalysisNotification: false }))
-    navigate('/consumption')
-  }
-
-  return (
-    <>
-      <CozyBar titleKey="common.title_analysis" />
-      <Header
-        setHeaderHeight={setHeaderHeight}
-        desktopTitleKey="common.title_analysis"
-      />
-      <Content heightOffset={headerHeight}>
-        <div className="unsubscribe-container">
-          <StyledIcon className="profile-icon" icon={BearIcon} size={250} />
-
-          <div className="text-20-bold head">{t('unsubscribe.title')}</div>
-          <div className="text-16-normal question">
-            {t('unsubscribe.content')}
-          </div>
-          <Button
-            aria-label={t('unsubscribe.button_accessibility')}
-            onClick={() => unSubscribe()}
-            variant="contained"
-            classes={{
-              root: 'btnPrimary',
-              label: 'text-18-bold',
-            }}
-          >
-            {t('unsubscribe.button_text')}
-          </Button>
-        </div>
-      </Content>
-    </>
-  )
-}
-
-export default UnSubscribeView
diff --git a/src/components/Options/Unsubscribe/Unsubscribe.spec.tsx b/src/components/Options/Unsubscribe/Unsubscribe.spec.tsx
new file mode 100644
index 000000000..a014ff8cb
--- /dev/null
+++ b/src/components/Options/Unsubscribe/Unsubscribe.spec.tsx
@@ -0,0 +1,31 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import { userEvent } from '@testing-library/user-event'
+import React from 'react'
+import Unsubscribe from './Unsubscribe'
+
+const mockUpdateProfile = jest.fn().mockResolvedValue('')
+jest.mock('services/profile.service', () => {
+  return jest.fn(() => ({
+    updateProfile: mockUpdateProfile,
+  }))
+})
+
+describe('Unsubscribe component', () => {
+  it('should be rendered correctly', async () => {
+    const { container } = render(<Unsubscribe />)
+    await waitFor(() => null, { container })
+    expect(container).toMatchSnapshot()
+  })
+
+  it('should click the subscribe button', async () => {
+    const { container } = render(<Unsubscribe />)
+    await waitFor(() => null, { container })
+    const [subscribeBtn] = screen.getAllByRole('button')
+    await userEvent.click(subscribeBtn)
+    expect(mockUpdateProfile).toHaveBeenCalled()
+
+    // then should only display one button
+    const buttons = screen.getAllByRole('button')
+    expect(buttons.length).toBe(1)
+  })
+})
diff --git a/src/components/Options/Unsubscribe/Unsubscribe.tsx b/src/components/Options/Unsubscribe/Unsubscribe.tsx
new file mode 100644
index 000000000..c5032fbdc
--- /dev/null
+++ b/src/components/Options/Unsubscribe/Unsubscribe.tsx
@@ -0,0 +1,80 @@
+import { Button } from '@material-ui/core'
+import * as Sentry from '@sentry/react'
+import BearIcon from 'assets/icons/visu/duelResult/CHALLENGE0001-0.svg'
+import StyledIcon from 'components/CommonKit/Icon/StyledIcon'
+import Loader from 'components/Loader/Loader'
+import { useClient } from 'cozy-client'
+import { useI18n } from 'cozy-ui/transpiled/react/I18n'
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import ProfileService from 'services/profile.service'
+import './unsubscribe.scss'
+
+const Unsubscribe = () => {
+  const { t } = useI18n()
+  const client = useClient()
+
+  const [isLoading, setIsLoading] = useState(true)
+  const [status, setStatus] = useState<
+    'subscribed' | 'error' | 'unsubscribed'
+  >()
+
+  const profileService = useMemo(() => new ProfileService(client), [client])
+
+  const updateSubscription = useCallback(
+    (value: boolean) => {
+      setIsLoading(true)
+
+      profileService
+        .updateProfile({ sendAnalysisNotification: value })
+        .then(() => {
+          setStatus(value ? 'subscribed' : 'unsubscribed')
+        })
+        .catch(err => {
+          setStatus('error')
+          console.error(err)
+          Sentry.captureException('Failed to unsubscribe')
+        })
+        .finally(() => {
+          setIsLoading(false)
+        })
+    },
+    [profileService]
+  )
+
+  useEffect(() => {
+    updateSubscription(false)
+  }, [updateSubscription])
+
+  return (
+    <div className="unsubscribe-container">
+      {isLoading && <Loader />}
+      {!isLoading && (
+        <>
+          <StyledIcon className="profile-icon" icon={BearIcon} size={250} />
+          <div className="text-20-bold head">{t(`unsubscribe.${status}`)}</div>
+          {status === 'unsubscribed' && (
+            <div className="text-16-normal question">
+              {t('unsubscribe.content')}
+            </div>
+          )}
+          {status !== 'subscribed' && status !== 'error' && (
+            <Button
+              className="btnPrimary"
+              onClick={() => updateSubscription(true)}
+            >
+              {t('unsubscribe.button_subscribe')}
+            </Button>
+          )}
+          <Button
+            className="btnSecondary"
+            onClick={() => window.location.replace('/')}
+          >
+            {t('unsubscribe.button_home')}
+          </Button>
+        </>
+      )}
+    </div>
+  )
+}
+
+export default Unsubscribe
diff --git a/src/components/Options/Unsubscribe/__snapshots__/UnSubscribeView.spec.tsx.snap b/src/components/Options/Unsubscribe/__snapshots__/UnSubscribeView.spec.tsx.snap
deleted file mode 100644
index 85efc0cad..000000000
--- a/src/components/Options/Unsubscribe/__snapshots__/UnSubscribeView.spec.tsx.snap
+++ /dev/null
@@ -1,55 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`UnSubscribe component should be rendered correctly 1`] = `
-<div>
-  <mock-cozybar
-    titlekey="common.title_analysis"
-  />
-  <mock-header
-    desktoptitlekey="common.title_analysis"
-  />
-  <mock-content
-    heightoffset="0"
-  >
-    <div
-      class="unsubscribe-container"
-    >
-      <svg
-        aria-hidden="true"
-        class="profile-icon styles__icon___23x3R"
-        height="250"
-        width="250"
-      >
-        <use
-          xlink:href="#test-file-stub"
-        />
-      </svg>
-      <div
-        class="text-20-bold head"
-      >
-        unsubscribe.title
-      </div>
-      <div
-        class="text-16-normal question"
-      >
-        unsubscribe.content
-      </div>
-      <button
-        aria-label="unsubscribe.button_accessibility"
-        class="MuiButtonBase-root MuiButton-root btnPrimary MuiButton-contained"
-        tabindex="0"
-        type="button"
-      >
-        <span
-          class="MuiButton-label text-18-bold"
-        >
-          unsubscribe.button_text
-        </span>
-        <span
-          class="MuiTouchRipple-root"
-        />
-      </button>
-    </div>
-  </mock-content>
-</div>
-`;
diff --git a/src/components/Options/Unsubscribe/__snapshots__/Unsubscribe.spec.tsx.snap b/src/components/Options/Unsubscribe/__snapshots__/Unsubscribe.spec.tsx.snap
new file mode 100644
index 000000000..181c853d2
--- /dev/null
+++ b/src/components/Options/Unsubscribe/__snapshots__/Unsubscribe.spec.tsx.snap
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Unsubscribe component should be rendered correctly 1`] = `
+<div>
+  <div
+    class="unsubscribe-container"
+  >
+    <svg
+      aria-hidden="true"
+      class="profile-icon styles__icon___23x3R"
+      height="250"
+      width="250"
+    >
+      <use
+        xlink:href="#test-file-stub"
+      />
+    </svg>
+    <div
+      class="text-20-bold head"
+    >
+      unsubscribe.unsubscribed
+    </div>
+    <div
+      class="text-16-normal question"
+    >
+      unsubscribe.content
+    </div>
+    <button
+      class="MuiButtonBase-root MuiButton-root MuiButton-text btnPrimary"
+      tabindex="0"
+      type="button"
+    >
+      <span
+        class="MuiButton-label"
+      >
+        unsubscribe.button_subscribe
+      </span>
+    </button>
+    <button
+      class="MuiButtonBase-root MuiButton-root MuiButton-text btnSecondary"
+      tabindex="0"
+      type="button"
+    >
+      <span
+        class="MuiButton-label"
+      >
+        unsubscribe.button_home
+      </span>
+    </button>
+  </div>
+</div>
+`;
diff --git a/src/components/Options/Unsubscribe/unSubscribeView.scss b/src/components/Options/Unsubscribe/unsubscribe.scss
similarity index 100%
rename from src/components/Options/Unsubscribe/unSubscribeView.scss
rename to src/components/Options/Unsubscribe/unsubscribe.scss
diff --git a/src/components/Routes/Routes.tsx b/src/components/Routes/Routes.tsx
index 259830e52..89d3a0590 100644
--- a/src/components/Routes/Routes.tsx
+++ b/src/components/Routes/Routes.tsx
@@ -6,7 +6,6 @@ import EcogestureFormView from 'components/EcogestureForm/EcogestureFormView'
 import EcogestureSelectionView from 'components/EcogestureSelection/EcogestureSelectionView'
 import ExplorationView from 'components/Exploration/ExplorationView'
 import Loader from 'components/Loader/Loader'
-import UnSubscribeView from 'components/Options/Unsubscribe/UnSubscribeView'
 import QuizView from 'components/Quiz/QuizView'
 import TermsView from 'components/Terms/TermsView'
 import { FluidType } from 'enums'
@@ -92,7 +91,6 @@ const AppRoutes = ({ termsStatus }: { termsStatus: TermsStatus }) => {
             <Route path="/options" element={<OptionsView />} />
             <Route path="/analysis" element={<AnalysisView />} />
             <Route path="/profiletype" element={<ProfileTypeView />} />
-            <Route path="/unsubscribe" element={<UnSubscribeView />} />
             <Route path="*" element={<Navigate replace to="/consumption" />} />
           </>
         )}
diff --git a/src/doctypes/io-cozy-permissions.ts b/src/doctypes/io-cozy-permissions.ts
new file mode 100644
index 000000000..0dfde431f
--- /dev/null
+++ b/src/doctypes/io-cozy-permissions.ts
@@ -0,0 +1 @@
+export const PERMISSIONS_DOCTYPE = 'io.cozy.permissions'
diff --git a/src/locales/fr.json b/src/locales/fr.json
index ea7cc0a49..3dad86508 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -1287,10 +1287,12 @@
     }
   },
   "unsubscribe": {
-    "title": "Êtes-vous sûr de ne plus vouloir recevoir notre email mensuel\u00a0?",
-    "content": "Dans ce cas, vous ne recevrez plus la notification de votre bilan ainsi que les conseils associés au mois en cours.",
-    "button_text": "Oui, je me désabonne",
-    "button_accessibility": "Bouton de désinscription"
+    "error": "Une erreur est survenue lors de votre désinscription, merci de ré-essayer plus tard.",
+    "unsubscribed": "Vous êtes désormais désabonné",
+    "subscribed": "Vous allez recevoir les prochaines newsletters",
+    "content": "Vous ne recevrez plus la notification de votre bilan ainsi que les conseils associés au mois en cours.",
+    "button_subscribe": "Je souhaite me ré-abonner à la newsletter",
+    "button_home": "Me connecter"
   },
   "welcome_modal": {
     "title": "Félicitations",
diff --git a/src/services/permissions.service.ts b/src/services/permissions.service.ts
new file mode 100644
index 000000000..a16f3f371
--- /dev/null
+++ b/src/services/permissions.service.ts
@@ -0,0 +1,55 @@
+import * as Sentry from '@sentry/react'
+import { Client, QueryResult } from 'cozy-client'
+import logger from 'cozy-logger'
+import { PERMISSIONS_DOCTYPE } from 'doctypes/io-cozy-permissions'
+
+const logStack = logger.namespace('challengeService')
+
+type PermissionsResponse = {
+  attributes: {
+    type: string
+    source_id: string
+    codes: {
+      code: string
+    }
+    shortcodes: {
+      code: string
+    }
+    cozyMetadata: Record<string, unknown>
+  }
+}
+
+export class PermissionsService {
+  private readonly client: Client
+
+  constructor(client: Client) {
+    this.client = client
+  }
+
+  public async getShareCode(): Promise<unknown> {
+    try {
+      const TTL = '15D'
+      const { data }: QueryResult<PermissionsResponse> = await this.client.save(
+        {
+          _type: PERMISSIONS_DOCTYPE,
+          permissions: {
+            images: {
+              type: 'com.grandlyon.ecolyo.profile',
+              verbs: ['PUT', 'GET'],
+            },
+          },
+          ttl: TTL,
+        }
+      )
+
+      return data.attributes.codes.code
+    } catch (error) {
+      const errorMessage = `Failed to create shareCode: ${JSON.stringify(
+        error
+      )}`
+      logStack('error', errorMessage)
+      Sentry.captureException(error)
+      throw error
+    }
+  }
+}
diff --git a/src/targets/browser/index.tsx b/src/targets/browser/index.tsx
index ebe12ae6d..9e9cde536 100644
--- a/src/targets/browser/index.tsx
+++ b/src/targets/browser/index.tsx
@@ -35,9 +35,7 @@ const setupApp = memoize(() => {
   const cozyUrl = `${protocol}//${data.domain}`
 
   const locale = 'fr'
-  const polyglot: any = initTranslation(locale, lang =>
-    require(`locales/${lang}`)
-  )
+  const polyglot = initTranslation(locale, lang => require(`locales/${lang}`))
 
   const client: Client = new CozyClient({
     uri: cozyUrl,
diff --git a/src/targets/public/index.ejs b/src/targets/public/index.ejs
new file mode 100644
index 000000000..ff3f16edf
--- /dev/null
+++ b/src/targets/public/index.ejs
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html lang="{{.Locale}}">
+  <head>
+    <meta charset="utf-8">
+    <title><%= htmlWebpackPlugin.options.title %> | Me désabonner</title>
+    <link rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32">
+    <link rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16">
+    <!-- PWA Manifest -->
+    <link rel="manifest" href="/manifest.json" crossOrigin="use-credentials">
+    <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#297EF2">
+    <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, viewport-fit=cover">
+    <!-- PWA Chrome -->
+    <link rel="icon" sizes="192x192" href="/android-chrome-192x192.png">
+    <link rel="icon" sizes="512x512" href="/android-chrome-512x512.png">
+    <!-- PWA iOS -->
+    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+    <link rel="apple-touch-startup-image" href="/apple-touch-icon.png">
+    <meta name="apple-mobile-web-app-title" content="Ecolyo">
+    <meta name="apple-mobile-web-app-capable" content="yes">
+    <meta name="apple-mobile-web-app-status-bar-style" content="black">
+    <!-- PWA Colors -->
+    <meta name="theme-color" content="#343641" />
+    <meta name="background-color" content="#121212" />
+
+    <% _.forEach(htmlWebpackPlugin.files.css, function(file) { %>
+        <link rel="stylesheet" href="<%- file %>">
+    <% }); %>
+    {{.ThemeCSS}}
+
+    <% if (__TARGET__ === 'mobile') { %>
+    <meta name="format-detection" content="telephone=no">
+    <script src="cordova.js" defer></script>
+    <% } else if (__STACK_ASSETS__) { %>
+    {{.CozyBar}}
+    <% } %>
+    <script src="//{{.Domain}}/assets/js/piwik.js"></script>
+
+  </head>
+  <body>
+  <div
+    role="application"
+    class="application"
+    data-cozy="{{.CozyData}}"
+  >
+  <% _.forEach(htmlWebpackPlugin.files.js, function(file) { %>
+      <script src="<%- file %>"></script>
+  <% }); %>
+  </body>
+</html>
\ No newline at end of file
diff --git a/src/targets/public/index.tsx b/src/targets/public/index.tsx
new file mode 100644
index 000000000..294a37fb1
--- /dev/null
+++ b/src/targets/public/index.tsx
@@ -0,0 +1,119 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-var-requires */
+declare let __SENTRY_DSN__: string
+
+import * as Sentry from '@sentry/react'
+import { BrowserTracing } from '@sentry/tracing'
+import Unsubscribe from 'components/Options/Unsubscribe/Unsubscribe'
+import CozyClient, { Client, CozyProvider } from 'cozy-client'
+import { isFlagshipApp } from 'cozy-device-helper'
+import { WebviewIntentProvider } from 'cozy-intent'
+import { I18n, initTranslation } from 'cozy-ui/transpiled/react/I18n'
+import schema from 'doctypes'
+import { memoize } from 'lodash'
+import React from 'react'
+import { createRoot } from 'react-dom/client'
+import EnvironmentService from 'services/environment.service'
+import cozyBar from 'utils/cozyBar'
+import logApp from 'utils/logger'
+import manifest from '../../../manifest.webapp'
+import '../../styles/index.scss'
+
+const getToken = (): string => {
+  const search = new URLSearchParams(window.location.search)
+  return search.get('token') || ''
+}
+
+const setupApp = memoize(() => {
+  const publicToken = getToken()
+  const container: any = document.querySelector('[role=application]')
+  const data = JSON.parse(container.dataset.cozy)
+  const protocol = window.location.protocol
+  const cozyUrl = `${protocol}//${data.domain}`
+
+  const locale = 'fr'
+  const polyglot = initTranslation(locale, lang => require(`locales/${lang}`))
+
+  const client: Client = new CozyClient({
+    uri: cozyUrl,
+    token: publicToken,
+    appMetadata: {
+      slug: manifest.name,
+      version: manifest.version,
+    },
+    schema,
+  })
+
+  const envService = new EnvironmentService()
+  const isLocal = envService.isLocal()
+  const development = envService.isDev()
+
+  cozyBar.init({
+    appName: data.app.name,
+    appEditor: data.app.editor,
+    cozyClient: client,
+    iconPath: data.app.icon,
+    lang: data.locale,
+    replaceTitleOnMobile: false,
+    appSlug: data.app.slug,
+    appNamePrefix: data.app.prefix,
+    isInvertedTheme: isFlagshipApp(),
+  })
+
+  !isLocal &&
+    Sentry.init({
+      dsn: __SENTRY_DSN__,
+      integrations: [new BrowserTracing()],
+      // Set tracesSampleRate to 1.0 to capture 100%
+      // of transactions for performance monitoring.
+      // We recommend adjusting this value in production
+      // Set to 0 for local development
+      tracesSampleRate: 1.0,
+
+      // Custom settings below
+      release: client.appMetadata.version,
+      environment: development ? 'development' : 'production',
+      // cast because init is somehow missing dsn property
+    } as Sentry.BrowserOptions)
+
+  return { container, client, locale, polyglot }
+})
+
+const init = () => {
+  const { container, client, locale, polyglot } = setupApp()
+  const root = createRoot(container)
+
+  root.render(
+    <WebviewIntentProvider setBarContext={cozyBar.setWebviewContext}>
+      <CozyProvider client={client}>
+        <I18n lang={locale} polyglot={polyglot}>
+          <Unsubscribe />
+        </I18n>
+      </CozyProvider>
+    </WebviewIntentProvider>
+  )
+}
+
+// initial rendering of the application
+document.addEventListener('DOMContentLoaded', () => init())
+// excludes Chrome, Edge, and all Android browsers that include the Safari name in their user agent
+const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
+if (!isSafari && 'serviceWorker' in navigator) {
+  window.addEventListener('load', function () {
+    navigator.serviceWorker
+      .register('/serviceWorker.js')
+      .then(reg => console.log('service worker registered', reg.scope))
+      .catch(error => {
+        const errorMessage = `service worker not registered: ${JSON.stringify(
+          error
+        )}`
+        logApp.error(errorMessage)
+        Sentry.captureException(errorMessage)
+      })
+  })
+}
+
+if (module.hot) {
+  init()
+  module.hot.accept()
+}
diff --git a/src/targets/services/monthlyReportNotification.ts b/src/targets/services/monthlyReportNotification.ts
index 9d4dc2fd2..276c47cac 100644
--- a/src/targets/services/monthlyReportNotification.ts
+++ b/src/targets/services/monthlyReportNotification.ts
@@ -10,6 +10,7 @@ import { MonthlyReport, PerformanceIndicator } from 'models'
 import ConsumptionService from 'services/consumption.service'
 import EnvironmentService from 'services/environment.service'
 import MailService from 'services/mail.service'
+import { PermissionsService } from 'services/permissions.service'
 import ProfileService from 'services/profile.service'
 import { getMonthNameWithPrep } from 'utils/utils'
 import { runService } from './service'
@@ -155,9 +156,10 @@ const monthlyReportNotification = async ({
   client,
 }: MonthlyReportNotificationProps) => {
   logStack('info', 'Fetching user profile...')
-  const upm = new ProfileService(client)
-  let userProfil = await upm.getProfile()
-  if (!userProfil?.sendAnalysisNotification) {
+  const profileService = new ProfileService(client)
+  const permissionsService = new PermissionsService(client)
+  const userProfile = await profileService.getProfile()
+  if (!userProfile?.sendAnalysisNotification) {
     logStack(
       'info',
       'End of process - Report Notification disabled in user profile'
@@ -166,11 +168,11 @@ const monthlyReportNotification = async ({
   }
 
   // Init mail token for user in case he don't have one
-  if (!userProfil.mailToken || userProfil.mailToken === '') {
+  if (!userProfile.mailToken || userProfile.mailToken === '') {
     const token: string = require('crypto').randomBytes(48).toString('hex')
 
     try {
-      await upm.updateProfile({
+      await profileService.updateProfile({
         mailToken: token,
       })
     } catch (error) {
@@ -181,7 +183,7 @@ const monthlyReportNotification = async ({
   }
 
   let username = ''
-  let url = ''
+  let analysisLink = ''
 
   logStack('info', 'Fetching data for mail...')
   // Retrieve public name from the stack
@@ -197,7 +199,7 @@ const monthlyReportNotification = async ({
   const apps = await client.getStackClient().fetchJSON('GET', '/apps/ecolyo')
   const appLink = get(apps, 'data.links.related')
   if (appLink) {
-    url = appLink
+    analysisLink = appLink
   }
 
   logStack('info', 'Creation of mail...')
@@ -208,39 +210,34 @@ const monthlyReportNotification = async ({
   })
   const month = today.toFormat('MM')
   const year = today.toFormat('yyyy')
-  const monthlyReport: MonthlyReport = await getMonthlyReport(
-    year,
-    month,
-    client
-  )
+  const monthlyReport = await getMonthlyReport(year, month, client)
 
-  let unsubscribeUrl
-  userProfil = await upm.getProfile()
-  let token = undefined
-  if (userProfil?.mailToken) {
-    token = userProfil.mailToken
+  /** bounce token, will be used to calculate bounce clicks from monthly report */
+  let bounceToken = undefined
+  if (userProfile?.mailToken) {
+    bounceToken = userProfile.mailToken
   }
 
-  if (!url.includes('analysis')) {
-    unsubscribeUrl = url + '/#/unsubscribe'
-    url = url + '/#/analysis'
-    if (token) {
-      url += '?token=' + token
-    }
-  } else {
-    unsubscribeUrl = url.replace('analysis', 'unsubscribe')
+  if (!analysisLink.includes('analysis')) {
+    analysisLink = analysisLink + '/#/analysis'
+  }
+  if (bounceToken) {
+    analysisLink += '?token=' + bounceToken
+  }
+
+  let unsubscribeUrl = appLink + 'public'
+  const unsubscribeToken = await permissionsService.getShareCode()
+  if (unsubscribeToken) {
+    unsubscribeUrl += `?token=${unsubscribeToken}`
   }
 
   const monthComparisonText = await buildComparisonText(client, 'month')
   const yearComparisonText = await buildComparisonText(client, 'year')
 
-  const isInfo: boolean = monthlyReport.info !== ''
-
-  const isServiceNews: boolean =
+  const isInfo = monthlyReport.info !== ''
+  const isServiceNews =
     monthlyReport.newsTitle !== '' && monthlyReport.newsContent !== ''
-
-  const isPoll: boolean =
-    monthlyReport.question !== '' && monthlyReport.link !== ''
+  const isPoll = monthlyReport.question !== '' && monthlyReport.link !== ''
 
   const date = DateTime.local()
     .setZone('utc', { keepLocalTime: true })
@@ -253,7 +250,7 @@ const monthlyReportNotification = async ({
     title: 'Infos & bilan consos',
     baseUrl: baseUrl,
     username: username,
-    clientUrl: url,
+    clientUrl: analysisLink,
     unsubscribeUrl: unsubscribeUrl,
     comparisonExist:
       monthComparisonText.length > 0 || yearComparisonText.length > 0,
diff --git a/src/targets/vendor/assets/offline.html b/src/targets/vendor/assets/offline.html
index f310b42c3..eff28e28a 100644
--- a/src/targets/vendor/assets/offline.html
+++ b/src/targets/vendor/assets/offline.html
@@ -1,4 +1,4 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="en">
   <head>
     <meta charset="UTF-8" />
@@ -52,30 +52,7 @@
     <nav></nav>
     <div class="container">
       <section>
-        <svg
-          width="80"
-          height="80"
-          viewBox="0 0 16 16"
-          fill="none"
-          xmlns="http://www.w3.org/2000/svg"
-        >
-          <path
-            d="M8.01323 15.059C14.8544 12.3888 14.3227 8.59276 13.9682 2.93081C11.7705 2.8343 9.82099 2.15873 8.01323 1.03278C6.20546 2.15873 4.25592 2.8343 2.05825 2.93081C1.70378 8.59276 1.17209 12.3888 8.01323 15.059Z"
-            fill="#1B1C22"
-          />
-          <path
-            d="M8.01325 0L7.54979 0.288665C5.85495 1.34429 4.05006 1.96586 2.01981 2.05502L1.23259 2.08959L1.18335 2.87604C1.16873 3.10958 1.15353 3.34193 1.13842 3.57294C0.97583 6.05878 0.823332 8.39029 1.50758 10.3994C2.29645 12.7158 4.12651 14.483 7.69452 15.8756L8.01325 16V15.059C1.44034 12.4935 1.67345 8.88869 2.01622 3.58822C2.03021 3.37192 2.04438 3.1528 2.05827 2.93081C4.25595 2.8343 6.20549 2.15873 8.01325 1.03277V0Z"
-            fill="#FFC600"
-          />
-          <path
-            d="M8.01323 0L8.4767 0.288665C10.1715 1.34429 11.9764 1.96586 14.0067 2.05502L14.7939 2.08959L14.8431 2.87604C14.8578 3.10958 14.873 3.34193 14.8881 3.57294C15.0507 6.05878 15.2032 8.39029 14.5189 10.3994C13.73 12.7158 11.9 14.483 8.33197 15.8756L8.01323 16V15.059C14.5862 12.4935 14.353 8.88869 14.0103 3.58822C13.9963 3.37192 13.9821 3.1528 13.9682 2.93081C11.7705 2.8343 9.821 2.15873 8.01323 1.03277V0Z"
-            fill="#DB8300"
-          />
-          <path
-            d="M5.85148 5.51416H6.16248C6.28668 5.51416 6.4058 5.56292 6.49363 5.64973C6.58146 5.73653 6.6308 5.85427 6.6308 5.97703V10.6758H5.38316V5.97703C5.38316 5.85427 5.4325 5.73653 5.52033 5.64973C5.60815 5.56292 5.72727 5.51416 5.85148 5.51416ZM8.01233 7.80214H8.32333C8.44754 7.80214 8.56666 7.85091 8.65448 7.93771C8.74231 8.02452 8.79165 8.14225 8.79165 8.26502V10.6758H7.54401V8.26502C7.54401 8.14225 7.59335 8.02452 7.68118 7.93771C7.76901 7.85091 7.88813 7.80214 8.01233 7.80214ZM10.302 6.91708H10.613C10.7372 6.91708 10.8563 6.96584 10.9441 7.05265C11.032 7.13945 11.0813 7.25719 11.0813 7.37995V10.6758H9.83366V7.37995C9.83366 7.25719 9.883 7.13945 9.97083 7.05265C10.0587 6.96584 10.1778 6.91708 10.302 6.91708Z"
-            fill="#FFC600"
-          />
-        </svg>
+        <img src="./icon.svg" alt="logo Ecolyo" height="80px" width="80px" />
         <p class="text-16-white">Hors ligne</p>
         <p class="text-16-white">
           Vérifiez votre connexion pour lancer Ecolyo.
diff --git a/src/targets/vendor/assets/unsubscribe.html b/src/targets/vendor/assets/unsubscribe.html
new file mode 100644
index 000000000..eff28e28a
--- /dev/null
+++ b/src/targets/vendor/assets/unsubscribe.html
@@ -0,0 +1,64 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta
+      name="description"
+      content="Ecolyo est le service proposé par la Métropole de Lyon pour suivre et comprendre la consommation énergétique globale de votre foyer."
+    />
+    <meta
+      http-equiv="Content-Security-Policy"
+      content="style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;"
+    />
+    <link rel="stylesheet" href="./style.css" />
+    <link
+      href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;0,900;1,400;1,700;1,900&display=swap"
+      rel="stylesheet"
+    />
+    <link rel="icon" href="assets/icon.svg" />
+    <title>Ecolyo</title>
+    <style type="text/css">
+      * {
+        margin: 0;
+        line-height: 1;
+        font-family: 'Lato', sans-serif;
+        color: white;
+      }
+      html,
+      body {
+        height: 100%;
+        margin: auto;
+        background: #121212;
+      }
+      p {
+        margin-top: 1rem;
+      }
+      .container {
+        height: 100%;
+        display: flex;
+        align-items: center;
+        text-align: center;
+        justify-content: center;
+      }
+      .text-16-white {
+        font-weight: 900;
+        font-size: 1rem;
+        color: white;
+      }
+    </style>
+  </head>
+  <body>
+    <nav></nav>
+    <div class="container">
+      <section>
+        <img src="./icon.svg" alt="logo Ecolyo" height="80px" width="80px" />
+        <p class="text-16-white">Hors ligne</p>
+        <p class="text-16-white">
+          Vérifiez votre connexion pour lancer Ecolyo.
+        </p>
+      </section>
+    </div>
+    <footer></footer>
+  </body>
+</html>
-- 
GitLab