diff --git a/.env b/.env
index f8f280fbc..2488f7cd6 100644
--- a/.env
+++ b/.env
@@ -110,3 +110,5 @@ SENTRY_DSN=
# postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock
###< symfony/lock ###
+
+MAIL_SENDER='info@netgen.io'
diff --git a/Makefile b/Makefile
index be3417ae8..41bcbfd86 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
APP_ENV = dev
-PHP_VERSION = php8.1
+PHP_VERSION = php8.2
PHP_RUN = /usr/bin/env $(PHP_VERSION)
COMPOSER_PATH = /usr/local/bin/composer2
ifeq ("$(wildcard $(COMPOSER_PATH))","")
diff --git a/assets/js/backoffice/components.js b/assets/js/backoffice/components.js
new file mode 100644
index 000000000..7fee568cb
--- /dev/null
+++ b/assets/js/backoffice/components.js
@@ -0,0 +1,58 @@
+// This file contains only import, configuration and initialization of components
+import addDocumentEventListeners from '../utilities/components/add-document-event-listeners';
+
+import ModalConfirmControl from '../components/backoffice/ModalConfirmControl.component';
+import NgConversationsMarkThreadAsRead from '../components/backoffice/NgConversationsMarkThreadAsRead.component';
+import Sidebar from '../components/backoffice/Sidebar.component';
+import EnableFilterButtons from '../components/backoffice/EnableFilterButtons.component';
+import HideOverflowingFilters from '../components/backoffice/HideOverflowingFilters.component';
+import DateRangeInput from '../components/backoffice/DateRangeInput.component';
+
+const componentConfiguration = [
+ {
+ Component: NgConversationsMarkThreadAsRead,
+ selector: '#ngconversations-app-root',
+ options: {
+ menuItemSelector: '#menu_item_conversations',
+ menuItemCountClass: 'menu-ntf-count',
+ menuItemIconSelector: '.icon-conversations',
+ markAsReadEvent: 'ngconversations-change-thread-read-state',
+ },
+ },
+ {
+ Component: Sidebar,
+ selector: '.app-container',
+ options: {
+ hasSubmenu: '.has-submenu',
+ submenu: '.menu_level_1',
+ child: 'div:first-child',
+ currentItems:
+ '.app-sidebar .sidebar-wrapper .current, .app-sidebar .sidebar-wrapper .current_ancestor',
+ },
+ },
+ {
+ Component: ModalConfirmControl,
+ selector: '.js-modal-confirm',
+ },
+ {
+ Component: EnableFilterButtons,
+ selector: '.js-filter-form',
+ options: {
+ hasActiveFilterClass: 'has-active-filter',
+ hasNewInputClass: 'has-new-input',
+ },
+ },
+ {
+ Component: HideOverflowingFilters,
+ selector: '.js-filter-wrapper',
+ options: {
+ filterWrapSelector: '.js-filters',
+ },
+ },
+ {
+ Component: DateRangeInput,
+ selector: '.js-date-range-input',
+ },
+];
+
+addDocumentEventListeners(componentConfiguration);
diff --git a/assets/js/backoffice/date.js b/assets/js/backoffice/date.js
new file mode 100644
index 000000000..45aebec09
--- /dev/null
+++ b/assets/js/backoffice/date.js
@@ -0,0 +1,34 @@
+import flatpickr from 'flatpickr';
+
+const date = document.querySelector('.js-date-filter');
+
+if (date) {
+ flatpickr(date, {
+ mode: 'range',
+ minDate: '2022-01-01',
+ dateFormat: 'Y-m-d',
+ wrap: true,
+
+ onChange(selectedDates) {
+ // eslint-disable-next-line no-empty
+ if (!selectedDates || selectedDates.length < 1) return;
+
+ const dateFrom = new Date(selectedDates[0]);
+ dateFrom.setHours(0, 0, 0, 0);
+
+ let dateTo = selectedDates[0];
+ if (selectedDates.length > 1) {
+ // eslint-disable-next-line prefer-destructuring
+ dateTo = selectedDates[1];
+ }
+ dateTo = new Date(dateTo);
+ dateTo.setHours(23, 59, 59, 0);
+
+ const formattedDateFrom = dateFrom.toISOString().split('.')[0];
+ const formattedDateTo = dateTo.toISOString().split('.')[0];
+
+ document.getElementById('dateFromHidden').value = formattedDateFrom;
+ document.getElementById('dateToHidden').value = formattedDateTo;
+ },
+ });
+}
diff --git a/assets/js/backoffice/index.js b/assets/js/backoffice/index.js
new file mode 100644
index 000000000..e664d107a
--- /dev/null
+++ b/assets/js/backoffice/index.js
@@ -0,0 +1,4 @@
+import '../../sass/backoffice/style.scss';
+import './components';
+import 'bootstrap';
+import './date';
diff --git a/assets/js/components-noncritical.js b/assets/js/components-noncritical.js
index 821a6093d..36406064b 100644
--- a/assets/js/components-noncritical.js
+++ b/assets/js/components-noncritical.js
@@ -13,9 +13,11 @@ import PageHeader from './components/PageHeader.component';
import LoginFormFragment from './components/LoginFormFragment.component';
import ResponsiveVideo from './components/ResponsiveVideo.component';
import SwiperBase from './components/SwiperBase.component';
+import SubscriptionWidget from './components/SubscriptionWidget.component';
import SwiperThumb from './components/SwiperThumb.component';
import VideoPoster from './components/VideoPoster.component';
import SkipToMainContent from './components/SkipToMainContent.component';
+import BookmarkWidget from './components/BookmarkWidget.component';
// Configuration
const componentConfiguration = [
@@ -214,6 +216,27 @@ const componentConfiguration = [
Component: SkipToMainContent,
selector: '#skip-to-main-content',
},
+ {
+ Component: BookmarkWidget,
+ selector: '.js-bookmark-widget',
+ options: {
+ bookmarkIcon: '.fa-bookmark',
+ bookmarkTooltip: '.bookmark-tooltip',
+ },
+ },
+ {
+ Component: SubscriptionWidget,
+ selector: '#subscription-modal-body',
+ options: {
+ modalBody: '.modal-body',
+ subscriptionForm: '.ntf-subscribe-widget',
+ subscriptionTypeWrapper: '#subscription_widget_type',
+ subscriptionTypeRadios: 'input[type=radio]',
+ digestType: '#subscription_widget_type_1',
+ digestOptions: '.digest-options',
+ buttonSubscribe: 'button[type=submit]',
+ },
+ },
];
addDocumentEventListeners(componentConfiguration);
diff --git a/assets/js/components/BookmarkWidget.component.js b/assets/js/components/BookmarkWidget.component.js
new file mode 100644
index 000000000..8ee0ac345
--- /dev/null
+++ b/assets/js/components/BookmarkWidget.component.js
@@ -0,0 +1,40 @@
+export default class BookmarkWidget {
+ constructor(element, options) {
+ this.element = element;
+ this.bookmarkIcon = this.element.querySelector(options.bookmarkIcon);
+ this.bookmarkTooltip = this.element.querySelector(options.bookmarkTooltip);
+ this.createBookmarkEndpoint = element.dataset.createBookmarkEndpoint;
+ this.deleteBookmarkEndpoint = element.dataset.deleteBookmarkEndpoint;
+ this.isBookmarked = element.dataset.isBookmarked === 'true';
+
+ this.onInit();
+ }
+
+ onInit() {
+ console.log('bookmark component');
+
+ this.element.addEventListener('click', this.updateBookmarkData.bind(this));
+ }
+
+ updateBookmarkData() {
+ this.bookmarkTooltip.classList.remove('tooltip-animated');
+
+ if (this.isBookmarked) {
+ this.bookmarkTooltip.innerHTML = 'Bookmark removed';
+ } else {
+ this.bookmarkTooltip.innerHTML = 'Bookmarked';
+ }
+
+ return fetch(this.isBookmarked ? this.deleteBookmarkEndpoint : this.createBookmarkEndpoint, {
+ method: 'POST',
+ }).then((response) => {
+ ['far', 'fas'].forEach((faTypeClass) => this.bookmarkIcon.classList.toggle(faTypeClass));
+
+ this.bookmarkTooltip.classList.add('tooltip-animated');
+
+ this.isBookmarked = !this.isBookmarked;
+
+ return response;
+ });
+ }
+}
diff --git a/assets/js/components/PageHeader.component.js b/assets/js/components/PageHeader.component.js
index f274f5e4e..e541fff1f 100644
--- a/assets/js/components/PageHeader.component.js
+++ b/assets/js/components/PageHeader.component.js
@@ -100,7 +100,6 @@ export default class PageHeader {
if (this.mainNav === null) {
return;
}
-
this.level1Menus = this.mainNav.querySelectorAll(this.options.menuLevel1);
if (this.level1Menus.length === 0) {
return;
@@ -116,9 +115,19 @@ export default class PageHeader {
this.submenuTriggerElements.push(submenuTriggerContent);
});
- this.submenuTriggerElements.forEach((submenuTrigger) => {
- submenuTrigger.addEventListener('click', () => {
- this.toggleMobileSubmenu(submenuTrigger);
+ window.addEventListener('click', (e) => {
+ this.submenuTriggerElements.forEach((submenuTrigger) => {
+ const submenuTriggered = submenuTrigger.contains(e.target);
+
+ if (e.target.closest('.prevent-user-menu-cta')) {
+ e.preventDefault();
+ }
+
+ if (!submenuTriggered || submenuTrigger.classList.contains('submenu-active')) {
+ submenuTrigger.classList.remove('submenu-active');
+ } else {
+ submenuTrigger.classList.add('submenu-active');
+ }
});
});
}
diff --git a/assets/js/components/SubscriptionWidget.component.js b/assets/js/components/SubscriptionWidget.component.js
new file mode 100644
index 000000000..94dd66462
--- /dev/null
+++ b/assets/js/components/SubscriptionWidget.component.js
@@ -0,0 +1,74 @@
+export default class SubscriptionWidget {
+ constructor(element, options) {
+ this.el = element;
+ this.options = options;
+ this.modalBody = this.el.querySelector(options.modalBody);
+ this.subscriptionForm = this.el.querySelector(options.subscriptionForm);
+ this.digestType = this.el.querySelector(options.digestType);
+ this.digestOptions = this.el.querySelector(options.digestOptions);
+ this.buttonSubscribe = this.el.querySelector(options.buttonSubscribe);
+
+ this.onInit();
+ }
+
+ onInit() {
+ if (this.digestType && this.digestType.checked) {
+ this.digestOptions.classList.remove('d-none');
+ SubscriptionWidget.activateSubscribeButton(this.buttonSubscribe);
+ }
+
+ this.el.addEventListener('click', (e) => {
+ const buttonSubscribe = e.currentTarget.querySelector(this.options.buttonSubscribe);
+
+ if (e.target.matches(this.options.subscriptionTypeRadios)) {
+ this.changeType(e);
+ SubscriptionWidget.activateSubscribeButton(buttonSubscribe);
+ }
+ });
+
+ this.el.addEventListener('submit', (e) => {
+ if (e.target.matches(this.options.subscriptionForm)) {
+ const modalBody = e.currentTarget.querySelector(this.options.modalBody);
+
+ e.preventDefault();
+ this.formSubmit(modalBody, e.target);
+ }
+ });
+ }
+
+ changeType(e) {
+ const digestOptions = e.currentTarget.querySelector(this.options.digestOptions);
+ const digestType = e.currentTarget.querySelector(this.options.digestType);
+
+ if (e.target === digestType || digestType.checked) {
+ digestOptions.classList.remove('d-none');
+ } else {
+ digestOptions.classList.add('d-none');
+ }
+ }
+
+ static activateSubscribeButton(button) {
+ if (button) {
+ button.removeAttribute('disabled');
+ button.classList.replace('btn-tertiary', 'btn-primary');
+ }
+ }
+
+ formSubmit(modalBody, subscriptionForm) {
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', this.subscriptionForm.action);
+ xhr.onload = function (event) {
+ modalBody.innerHTML = event.target.response;
+ const errorMessage = modalBody.querySelector('.errors');
+ const subscribeButton = modalBody.querySelector('button[type=submit]');
+
+ if (errorMessage && errorMessage.closest('.digest-options')) {
+ errorMessage.closest('.digest-options').classList.remove('d-none');
+ }
+
+ SubscriptionWidget.activateSubscribeButton(subscribeButton);
+ };
+ const formData = new FormData(subscriptionForm);
+ xhr.send(formData);
+ }
+}
diff --git a/assets/js/components/backoffice/DateRangeInput.component.js b/assets/js/components/backoffice/DateRangeInput.component.js
new file mode 100644
index 000000000..1150ba422
--- /dev/null
+++ b/assets/js/components/backoffice/DateRangeInput.component.js
@@ -0,0 +1,41 @@
+import flatpickr from 'flatpickr';
+
+export default class Date {
+ constructor(element) {
+ this.element = element;
+
+ this.init();
+ }
+
+ init() {
+ flatpickr(this.element, {
+ mode: 'range',
+ minDate: '2022-01-01',
+ dateFormat: 'Y-m-d',
+ wrap: true,
+
+ onChange(selectedDates) {
+ if (!selectedDates || selectedDates.length < 1) {
+ return;
+ }
+
+ const dateFrom = selectedDates[0];
+ dateFrom.setHours(0, 0, 0, 0);
+
+ let dateTo = selectedDates[0];
+ if (selectedDates.length > 1) {
+ // eslint-disable-next-line prefer-destructuring
+ dateTo = selectedDates[1];
+ }
+
+ dateTo.setHours(23, 59, 59, 0);
+
+ const formattedDateFrom = dateFrom.toISOString().split('.')[0];
+ const formattedDateTo = dateTo.toISOString().split('.')[0];
+
+ document.getElementById('dateFromHidden').value = formattedDateFrom;
+ document.getElementById('dateToHidden').value = formattedDateTo;
+ },
+ });
+ }
+}
diff --git a/assets/js/components/backoffice/EnableFilterButtons.component.js b/assets/js/components/backoffice/EnableFilterButtons.component.js
new file mode 100644
index 000000000..dc8afd846
--- /dev/null
+++ b/assets/js/components/backoffice/EnableFilterButtons.component.js
@@ -0,0 +1,30 @@
+export default class EnableFilterButtons {
+ constructor(element, options) {
+ this.form = element;
+ this.options = options;
+ this.onInit();
+ }
+
+ onInit() {
+ this.searchParams = new URLSearchParams(window.location.search);
+ this.inputs = this.form.querySelectorAll('input, select');
+
+ if ([...this.searchParams.keys()].some((query) => query !== 'page')) {
+ this.form.classList.add(this.options.hasActiveFilterClass);
+ }
+
+ this.form.addEventListener('input', this.checkInputs.bind(this));
+
+ this.checkInputs();
+ }
+
+ checkInputs() {
+ const action = [...this.inputs].some(
+ ({ value, name }) => value && value !== this.searchParams.get(name)
+ )
+ ? 'add'
+ : 'remove';
+
+ this.form.classList[action](this.options.hasNewInputClass);
+ }
+}
diff --git a/assets/js/components/backoffice/HideOverflowingFilters.component.js b/assets/js/components/backoffice/HideOverflowingFilters.component.js
new file mode 100644
index 000000000..daa602c02
--- /dev/null
+++ b/assets/js/components/backoffice/HideOverflowingFilters.component.js
@@ -0,0 +1,47 @@
+const SEARCH_ELEMENT_MARGIN_RIGHT = 16;
+
+export default class HideOverflowingFilters {
+ constructor(element, options) {
+ this.filterWrapper = element;
+ this.options = options;
+ this.onInit();
+ }
+
+ onInit() {
+ this.filtersModal = this.filterWrapper.parentElement.querySelector('.dropdown-menu');
+ this.filters = this.filterWrapper.querySelector(this.options.filterWrapSelector);
+ this.movedElements = [];
+ this.mostRightElement = [...this.filterWrapper.querySelector(':scope > div').children]
+ .reverse()
+ .find((element) => window.getComputedStyle(element).display !== 'none');
+ this.handleWindowResize();
+
+ window.addEventListener('resize', this.handleWindowResize.bind(this));
+ }
+
+ handleWindowResize() {
+ const wrapperRect = this.filterWrapper.getBoundingClientRect();
+ const mostRightElementRect = this.mostRightElement.getBoundingClientRect();
+
+ if (mostRightElementRect.right > wrapperRect.right || window.innerWidth < 576) {
+ // Move the last child of filter-wrapper to filters-modal and store window width at which it should be restored
+ const lastChild = this.filters.lastElementChild;
+ if (lastChild && this.filtersModal) {
+ this.movedElements.unshift({
+ element: lastChild,
+ width: wrapperRect.right + SEARCH_ELEMENT_MARGIN_RIGHT,
+ });
+ this.filtersModal.prepend(lastChild);
+ this.filterWrapper.classList.add('more-filters');
+ }
+
+ requestAnimationFrame(this.handleWindowResize.bind(this));
+ } else if (this.movedElements.length && window.innerWidth > this.movedElements[0].width) {
+ this.filters.append(this.movedElements[0].element);
+ this.movedElements.shift();
+ if (!this.movedElements.length) {
+ this.filterWrapper.classList.remove('more-filters');
+ }
+ }
+ }
+}
diff --git a/assets/js/components/backoffice/ModalConfirmControl.component.js b/assets/js/components/backoffice/ModalConfirmControl.component.js
new file mode 100644
index 000000000..59169c0e8
--- /dev/null
+++ b/assets/js/components/backoffice/ModalConfirmControl.component.js
@@ -0,0 +1,26 @@
+export default class ModalConfirmControl {
+ constructor(element) {
+ this.element = element;
+ this.modalElement = document.querySelector(element.dataset.modalElementSelector);
+ this.getFormUrl = element.dataset.getFormUrl;
+
+ this.init();
+ }
+
+ init() {
+ const that = this;
+
+ this.element.addEventListener('click', () => {
+ that.modalElement.innerHTML =
+ '
';
+
+ that.fetchFormHTML().then((data) => {
+ that.modalElement.innerHTML = data;
+ });
+ });
+ }
+
+ fetchFormHTML() {
+ return fetch(this.getFormUrl, { method: 'GET' }).then((response) => response.text());
+ }
+}
diff --git a/assets/js/components/backoffice/NgConversationsMarkThreadAsRead.component.js b/assets/js/components/backoffice/NgConversationsMarkThreadAsRead.component.js
new file mode 100644
index 000000000..9d9d71f49
--- /dev/null
+++ b/assets/js/components/backoffice/NgConversationsMarkThreadAsRead.component.js
@@ -0,0 +1,32 @@
+export default class NgConversationsMarkThreadAsRead {
+ constructor(element, options) {
+ this.element = element;
+ this.options = options;
+
+ this.init();
+ }
+
+ init() {
+ const menuItem = document.querySelector(this.options.menuItemSelector);
+
+ this.element.addEventListener(this.options.markAsReadEvent, ({ detail }) => {
+ let countElement = menuItem.querySelector(`.${this.options.menuItemCountClass}`);
+
+ let count = Number(countElement?.innerText) || 0;
+ count += detail.isRead ? -1 : 1;
+
+ if (count > 0) {
+ if (countElement === null) {
+ countElement = document.createElement('span');
+ countElement.classList.add(this.options.menuItemCountClass);
+ const menuItemIcon = menuItem.querySelector(this.options.menuItemIconSelector);
+ menuItemIcon.parentNode.insertBefore(countElement, menuItemIcon);
+ }
+
+ countElement.innerText = count;
+ } else {
+ countElement?.remove();
+ }
+ });
+ }
+}
diff --git a/assets/js/components/backoffice/Sidebar.component.js b/assets/js/components/backoffice/Sidebar.component.js
new file mode 100644
index 000000000..4363aa250
--- /dev/null
+++ b/assets/js/components/backoffice/Sidebar.component.js
@@ -0,0 +1,71 @@
+export default class Sidebar {
+ constructor(element, options) {
+ this.element = element;
+ this.options = options;
+
+ this.init();
+ }
+
+ init() {
+ const sidebarToggle = document.getElementById('toggleSidebar');
+ if (sidebarToggle) {
+ sidebarToggle.onclick = () => {
+ this.element.classList.toggle('closed-sidebar');
+ };
+ }
+
+ // Sidebar menu dropdowns
+ const menuItems = document.querySelectorAll(this.options.hasSubmenu);
+ menuItems.forEach((menuItem) => {
+ const submenu = menuItem.querySelector(this.options.submenu);
+ const child = menuItem.querySelector(this.options.child);
+
+ child.addEventListener('click', (event) => {
+ event.preventDefault();
+ submenu.classList.toggle('active');
+ menuItem.classList.toggle('active');
+ });
+ });
+
+ const mobileNavToggle = document.getElementById('mobileNavToggle');
+
+ // Mobile menu toggle
+ mobileNavToggle.onclick = () => {
+ this.element.classList.toggle('closed-sidebar');
+ };
+
+ // Sidebar account mini menu toggle and closing on outside click
+ const myAccountToggle = document.getElementById('myAccountToggle');
+ const myAccountMenu = document.getElementById('myAccountMenu');
+
+ if (myAccountToggle && myAccountMenu) {
+ myAccountToggle.addEventListener('click', (e) => {
+ e.stopPropagation();
+ myAccountMenu.classList.toggle('user-settings-menu--visible');
+ });
+
+ document.addEventListener('click', (event) => {
+ const isClickInsideMenu = myAccountMenu.contains(event.target);
+ const isClickOnToggle = event.target === myAccountToggle;
+
+ if (!isClickInsideMenu && !isClickOnToggle) {
+ myAccountMenu.classList.remove('user-settings-menu--visible');
+ }
+ });
+ }
+
+ // Scroll sidebar to the selected item after the reload
+ const currentItems = document.querySelectorAll(this.options.currentItems);
+
+ if (currentItems.length > 0) {
+ const currentItem = currentItems[0];
+ currentItem.scrollIntoView();
+ }
+
+ // Detect Safari for specific CSS
+ if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {
+ document.querySelector('body').classList.add('sf-helper');
+ }
+
+ }
+}
diff --git a/assets/sass/_page_floating_widgets.scss b/assets/sass/_page_floating_widgets.scss
new file mode 100644
index 000000000..e841795e1
--- /dev/null
+++ b/assets/sass/_page_floating_widgets.scss
@@ -0,0 +1,110 @@
+.page-floating-widgets {
+ position: fixed;
+ bottom: 40%;
+ right: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ color: $secondary_3;
+ z-index: 10;
+
+ .subscription-widget {
+ background-color: $secondary_0;
+ writing-mode: vertical-rl;
+ transform: rotate(180deg);
+ width: 100%;
+ display: flex;
+ align-items: center;
+ }
+
+ .subscription-icon {
+ transform: rotate(180deg);
+
+ &::before {
+ content: url(../sass/assets/icons/icon_notification.svg);
+ }
+ }
+
+ .subscriptions-widget-toggle {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ padding: 1rem 0;
+ }
+
+ .bookmark-widget {
+ position: relative;
+
+ .bookmark-tooltip {
+ position: absolute;
+ right: calc(100% + 0.5625rem);
+ background-color: $secondary_1;
+ border-radius: 0.25rem;
+ padding: 0.125rem 0.75rem;
+ opacity: 0;
+ pointer-events: none;
+ visibility: hidden;
+ font-size: 0.875rem;
+
+ &.tooltip-animated {
+ animation: tooltipAppear 3s ease forwards;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 100%;
+ transform: translateY(-50%);
+ border: 6px solid transparent;
+ border-left: 6px solid $secondary_1;
+ }
+ }
+ }
+}
+
+@keyframes tooltipAppear {
+ 0% {
+ opacity: 0;
+ pointer-events: none;
+ visibility: hidden;
+ }
+ 25% {
+ opacity: 1;
+ pointer-events: all;
+ visibility: visible;
+ }
+ 75% {
+ opacity: 1;
+ pointer-events: all;
+ visibility: visible;
+ }
+ 100% {
+ opacity: 0;
+ pointer-events: none;
+ visibility: hidden;
+ }
+}
+
+#subscriptionModal {
+ .modal-dialog {
+ max-width: 30rem;
+ }
+
+ .modal-content {
+ border-radius: 0.25rem;
+ }
+
+ .modal-header {
+ padding: 1.5rem 1.5rem 1rem;
+ }
+
+ .modal-body {
+ padding: 1rem 1.5rem 1.5rem;
+ }
+
+ .modal-footer {
+ border-top: none;
+ padding: 0 1.5rem 1.5rem;
+ }
+}
diff --git a/assets/sass/_subscription_widget.scss b/assets/sass/_subscription_widget.scss
new file mode 100644
index 000000000..01e686286
--- /dev/null
+++ b/assets/sass/_subscription_widget.scss
@@ -0,0 +1,114 @@
+#subscription-modal-body {
+
+ .modal-header {
+ align-items: flex-start;
+
+ .modal-title {
+ margin-right: 1rem;
+ @include text-1_125;
+ font-weight: 700;
+ }
+
+ .btn-close {
+ margin: 0;
+ }
+ }
+
+ .modal-body {
+ p {
+ margin: 0;
+ }
+
+ a {
+ font-weight: 600;
+ color: $secondary_4;
+ border-bottom: 1px solid $secondary_4;
+ }
+ }
+
+ .ntf-subscribe-widget {
+ #subscription_widget_type {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-bottom: 1rem;
+
+ & > div {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .type-options {
+ label {
+ display: none;
+ }
+
+ input {
+ height: 1rem;
+ width: 1rem;
+ accent-color: $secondary_4;
+
+ & + label {
+ padding-left: 0.5rem;
+ display: inline-block;
+ }
+ }
+ }
+
+ .digest-options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin: 0 0 1rem 1.5rem;
+
+ label {
+ display: none;
+ }
+
+ .form-select {
+ @include text-1_125;
+ font-weight: 600;
+ }
+ }
+
+ button[type=submit] {
+ width: 100%;
+ }
+
+ small {
+ display: inline-block;
+ text-align: center;
+ color: rgba(37, 45, 64, 0.66);
+ padding: 0 3rem;
+ margin-top: 0.5rem;
+ @include text-0_875;
+
+ @include media-breakpoint-down(sm) {
+ padding: 0;
+ }
+ }
+ }
+
+ .subscription-success {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+
+ .success-icon {
+ width: 3rem;
+ height: 3rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: $success-green;
+ border-radius: 50%;
+ margin-bottom: 1.5rem;
+
+ &::before {
+ content: url(../sass/assets/icons/icon_check.svg);
+ }
+ }
+ }
+}
diff --git a/assets/sass/_user_impersonation.scss b/assets/sass/_user_impersonation.scss
new file mode 100644
index 000000000..c52d3cee4
--- /dev/null
+++ b/assets/sass/_user_impersonation.scss
@@ -0,0 +1,44 @@
+.user-impersonation {
+ background-color: $primary_0;
+ padding: 0.5rem 1rem;
+ height: $impersonation-bar-height;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ .user-impersonation-label {
+ display: flex;
+ align-items: center;
+ margin-bottom: 0;
+ gap: 0.25rem;
+ @include text-0_875;
+
+ &::before {
+ content: url(../../assets/sass/assets/icons/icon_info.svg);
+ line-height: 0;
+ }
+
+ .impersonating-label {
+ @include media-breakpoint-down(sm) {
+ text-transform: capitalize;
+ }
+ }
+
+ .user-name {
+ font-weight: 600;
+ }
+ }
+
+ .btn-end-process {
+ @include text-0_75;
+ font-weight: 600;
+ gap: 0.25rem;
+
+ &.btn:not(.btn.btn-link) {
+ @include media-breakpoint-down(sm) {
+ width: fit-content;
+ }
+ }
+ }
+}
+
diff --git a/assets/sass/_variables.scss b/assets/sass/_variables.scss
index 62d02eb5c..64a53d7a5 100644
--- a/assets/sass/_variables.scss
+++ b/assets/sass/_variables.scss
@@ -3,10 +3,19 @@ $header-height: 5.9375rem;
$header-height-sm: 4rem;
$gutter: .9375rem;
$gap: 2 * $gutter;
+$impersonation-bar-height: 3rem;
// colours
$primary: #FED82F;
+$primary_0: #FFCD00;
+$primary_1: #346094;
+$primary_2: #BBC6D6;
$secondary: #F8F9FC;
+$secondary_0: #00396F;
+$secondary_1: #252D40;
+$secondary_2: #F3F3F3;
+$secondary_3: #F9F1DE;
+$secondary_4: #A31F34;
$white: hsl(0, 0, 100);
$black: hsl(0, 0, 0);
$gray-87: hsl(0, 0, 13);
@@ -17,6 +26,9 @@ $gray-19: hsl(0, 0, 81);
$gray-12: hsl(0, 0, 88);
$gray-7: hsl(0, 0, 93);
$outline-color: #4D90FE;
+$success-green: #4BC17C;
+$darkgray: rgba(37, 45, 64, 0.66);
+
$body-bg: $white;
$footer-bg: $black;
@@ -27,4 +39,5 @@ $filtered-colours: (
secondary: brightness(0) saturate(100%) invert(100%) sepia(26%) saturate(567%) hue-rotate(178deg) brightness(100%) contrast(98%),
white: brightness(0) saturate(100%) invert(92%) sepia(93%) saturate(32%) hue-rotate(251deg) brightness(107%) contrast(100%),
black: brightness(0) saturate(100%) invert(0%) sepia(9%) saturate(7500%) hue-rotate(154deg) brightness(91%) contrast(108%),
+ success-green: brightness(0) saturate(100%) invert(31%) sepia(10%) saturate(2823%) hue-rotate(92deg) brightness(95%) contrast(83%),
);
diff --git a/assets/sass/assets/icons/icon_alert.svg b/assets/sass/assets/icons/icon_alert.svg
new file mode 100644
index 000000000..4cbf2ab54
--- /dev/null
+++ b/assets/sass/assets/icons/icon_alert.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_arrow_down.svg b/assets/sass/assets/icons/icon_arrow_down.svg
new file mode 100644
index 000000000..2410d9579
--- /dev/null
+++ b/assets/sass/assets/icons/icon_arrow_down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_arrow_left.svg b/assets/sass/assets/icons/icon_arrow_left.svg
new file mode 100644
index 000000000..3a8739fe7
--- /dev/null
+++ b/assets/sass/assets/icons/icon_arrow_left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_arrow_right.svg b/assets/sass/assets/icons/icon_arrow_right.svg
new file mode 100644
index 000000000..6e11db2fc
--- /dev/null
+++ b/assets/sass/assets/icons/icon_arrow_right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_arrow_right_small.svg b/assets/sass/assets/icons/icon_arrow_right_small.svg
new file mode 100644
index 000000000..59a559b72
--- /dev/null
+++ b/assets/sass/assets/icons/icon_arrow_right_small.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_arrow_white.svg b/assets/sass/assets/icons/icon_arrow_white.svg
new file mode 100644
index 000000000..e437100a0
--- /dev/null
+++ b/assets/sass/assets/icons/icon_arrow_white.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_bookmarks.svg b/assets/sass/assets/icons/icon_bookmarks.svg
new file mode 100644
index 000000000..eee27c72b
--- /dev/null
+++ b/assets/sass/assets/icons/icon_bookmarks.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_caret_right.svg b/assets/sass/assets/icons/icon_caret_right.svg
new file mode 100644
index 000000000..52ce92c88
--- /dev/null
+++ b/assets/sass/assets/icons/icon_caret_right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_chat.svg b/assets/sass/assets/icons/icon_chat.svg
new file mode 100644
index 000000000..a12760825
--- /dev/null
+++ b/assets/sass/assets/icons/icon_chat.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_check.svg b/assets/sass/assets/icons/icon_check.svg
new file mode 100644
index 000000000..cc4de9b02
--- /dev/null
+++ b/assets/sass/assets/icons/icon_check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_check_white.svg b/assets/sass/assets/icons/icon_check_white.svg
new file mode 100644
index 000000000..7fb8d94f8
--- /dev/null
+++ b/assets/sass/assets/icons/icon_check_white.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_chevron.svg b/assets/sass/assets/icons/icon_chevron.svg
new file mode 100644
index 000000000..a478f2b1f
--- /dev/null
+++ b/assets/sass/assets/icons/icon_chevron.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_close.svg b/assets/sass/assets/icons/icon_close.svg
new file mode 100644
index 000000000..548a0db66
--- /dev/null
+++ b/assets/sass/assets/icons/icon_close.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_close_small.svg b/assets/sass/assets/icons/icon_close_small.svg
new file mode 100644
index 000000000..97c0cc0b8
--- /dev/null
+++ b/assets/sass/assets/icons/icon_close_small.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_conversations.svg b/assets/sass/assets/icons/icon_conversations.svg
new file mode 100644
index 000000000..f62977051
--- /dev/null
+++ b/assets/sass/assets/icons/icon_conversations.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_copy.svg b/assets/sass/assets/icons/icon_copy.svg
new file mode 100644
index 000000000..b18e32252
--- /dev/null
+++ b/assets/sass/assets/icons/icon_copy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/sass/assets/icons/icon_dashboard.svg b/assets/sass/assets/icons/icon_dashboard.svg
new file mode 100644
index 000000000..0bd80331f
--- /dev/null
+++ b/assets/sass/assets/icons/icon_dashboard.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_date.svg b/assets/sass/assets/icons/icon_date.svg
new file mode 100644
index 000000000..ebaf2b3a6
--- /dev/null
+++ b/assets/sass/assets/icons/icon_date.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_delete.svg b/assets/sass/assets/icons/icon_delete.svg
new file mode 100644
index 000000000..13dbc20e1
--- /dev/null
+++ b/assets/sass/assets/icons/icon_delete.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_docs.svg b/assets/sass/assets/icons/icon_docs.svg
new file mode 100644
index 000000000..d224169e4
--- /dev/null
+++ b/assets/sass/assets/icons/icon_docs.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_docx.svg b/assets/sass/assets/icons/icon_docx.svg
new file mode 100644
index 000000000..222260bb0
--- /dev/null
+++ b/assets/sass/assets/icons/icon_docx.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_dots.svg b/assets/sass/assets/icons/icon_dots.svg
new file mode 100644
index 000000000..16a11bf08
--- /dev/null
+++ b/assets/sass/assets/icons/icon_dots.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_download.svg b/assets/sass/assets/icons/icon_download.svg
new file mode 100644
index 000000000..8cb9434a9
--- /dev/null
+++ b/assets/sass/assets/icons/icon_download.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_edit.svg b/assets/sass/assets/icons/icon_edit.svg
new file mode 100644
index 000000000..735b20865
--- /dev/null
+++ b/assets/sass/assets/icons/icon_edit.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_exteral_dark.svg b/assets/sass/assets/icons/icon_exteral_dark.svg
new file mode 100644
index 000000000..9a84b51d4
--- /dev/null
+++ b/assets/sass/assets/icons/icon_exteral_dark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_external.svg b/assets/sass/assets/icons/icon_external.svg
new file mode 100644
index 000000000..8bbb3b65b
--- /dev/null
+++ b/assets/sass/assets/icons/icon_external.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_filter.svg b/assets/sass/assets/icons/icon_filter.svg
new file mode 100644
index 000000000..f50f53560
--- /dev/null
+++ b/assets/sass/assets/icons/icon_filter.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_filter_red.svg b/assets/sass/assets/icons/icon_filter_red.svg
new file mode 100644
index 000000000..15b4e6170
--- /dev/null
+++ b/assets/sass/assets/icons/icon_filter_red.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_filter_small.svg b/assets/sass/assets/icons/icon_filter_small.svg
new file mode 100644
index 000000000..648f2b9ab
--- /dev/null
+++ b/assets/sass/assets/icons/icon_filter_small.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_info.svg b/assets/sass/assets/icons/icon_info.svg
new file mode 100644
index 000000000..efe1785b9
--- /dev/null
+++ b/assets/sass/assets/icons/icon_info.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_list.svg b/assets/sass/assets/icons/icon_list.svg
new file mode 100644
index 000000000..51f9da9c0
--- /dev/null
+++ b/assets/sass/assets/icons/icon_list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_lock.svg b/assets/sass/assets/icons/icon_lock.svg
new file mode 100644
index 000000000..8e5783a11
--- /dev/null
+++ b/assets/sass/assets/icons/icon_lock.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_logout.svg b/assets/sass/assets/icons/icon_logout.svg
new file mode 100644
index 000000000..7afbd2206
--- /dev/null
+++ b/assets/sass/assets/icons/icon_logout.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_next.svg b/assets/sass/assets/icons/icon_next.svg
new file mode 100644
index 000000000..6e11db2fc
--- /dev/null
+++ b/assets/sass/assets/icons/icon_next.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_note_info.svg b/assets/sass/assets/icons/icon_note_info.svg
new file mode 100644
index 000000000..396ba39ad
--- /dev/null
+++ b/assets/sass/assets/icons/icon_note_info.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_notification.svg b/assets/sass/assets/icons/icon_notification.svg
new file mode 100644
index 000000000..0aed5c81e
--- /dev/null
+++ b/assets/sass/assets/icons/icon_notification.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_notifications_dark.svg b/assets/sass/assets/icons/icon_notifications_dark.svg
new file mode 100644
index 000000000..843d134c3
--- /dev/null
+++ b/assets/sass/assets/icons/icon_notifications_dark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_overview.svg b/assets/sass/assets/icons/icon_overview.svg
new file mode 100644
index 000000000..2dea5a0bb
--- /dev/null
+++ b/assets/sass/assets/icons/icon_overview.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_pdf.svg b/assets/sass/assets/icons/icon_pdf.svg
new file mode 100644
index 000000000..cd9d9b65e
--- /dev/null
+++ b/assets/sass/assets/icons/icon_pdf.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_plus.svg b/assets/sass/assets/icons/icon_plus.svg
new file mode 100644
index 000000000..1a86f5ec4
--- /dev/null
+++ b/assets/sass/assets/icons/icon_plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_print.svg b/assets/sass/assets/icons/icon_print.svg
new file mode 100644
index 000000000..8805c5b2d
--- /dev/null
+++ b/assets/sass/assets/icons/icon_print.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_save.svg b/assets/sass/assets/icons/icon_save.svg
new file mode 100644
index 000000000..9dc33fa71
--- /dev/null
+++ b/assets/sass/assets/icons/icon_save.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_search.svg b/assets/sass/assets/icons/icon_search.svg
new file mode 100644
index 000000000..b8d18d421
--- /dev/null
+++ b/assets/sass/assets/icons/icon_search.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_star.svg b/assets/sass/assets/icons/icon_star.svg
new file mode 100644
index 000000000..c211b223a
--- /dev/null
+++ b/assets/sass/assets/icons/icon_star.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_star_filled.svg b/assets/sass/assets/icons/icon_star_filled.svg
new file mode 100644
index 000000000..5ba2d330d
--- /dev/null
+++ b/assets/sass/assets/icons/icon_star_filled.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_trash.svg b/assets/sass/assets/icons/icon_trash.svg
new file mode 100644
index 000000000..5028b4b57
--- /dev/null
+++ b/assets/sass/assets/icons/icon_trash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/sass/assets/icons/icon_user.svg b/assets/sass/assets/icons/icon_user.svg
new file mode 100644
index 000000000..54aba15d1
--- /dev/null
+++ b/assets/sass/assets/icons/icon_user.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/sass/assets/icons/icon_user_cancel.svg b/assets/sass/assets/icons/icon_user_cancel.svg
new file mode 100644
index 000000000..f28f02723
--- /dev/null
+++ b/assets/sass/assets/icons/icon_user_cancel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/sass/assets/icons/icon_user_check.svg b/assets/sass/assets/icons/icon_user_check.svg
new file mode 100644
index 000000000..adca2547f
--- /dev/null
+++ b/assets/sass/assets/icons/icon_user_check.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/sass/assets/icons/icon_user_group.svg b/assets/sass/assets/icons/icon_user_group.svg
new file mode 100644
index 000000000..db0ccd01d
--- /dev/null
+++ b/assets/sass/assets/icons/icon_user_group.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_view.svg b/assets/sass/assets/icons/icon_view.svg
new file mode 100644
index 000000000..65b03b469
--- /dev/null
+++ b/assets/sass/assets/icons/icon_view.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/icon_warning.svg b/assets/sass/assets/icons/icon_warning.svg
new file mode 100644
index 000000000..a7f9a2014
--- /dev/null
+++ b/assets/sass/assets/icons/icon_warning.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/assets/sass/assets/icons/x-twitter.svg b/assets/sass/assets/icons/x-twitter.svg
new file mode 100644
index 000000000..f5feed78f
--- /dev/null
+++ b/assets/sass/assets/icons/x-twitter.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/sass/backoffice/_buttons.scss b/assets/sass/backoffice/_buttons.scss
new file mode 100644
index 000000000..885be0529
--- /dev/null
+++ b/assets/sass/backoffice/_buttons.scss
@@ -0,0 +1,280 @@
+%btn-icon {
+ content: '';
+ width: 1rem;
+ height: 1rem;
+ display: inline-block;
+ margin: 0;
+
+ &.btn-icon-plus {
+ @extend %icon-plus;
+ }
+
+ &.btn-icon-arrow-left {
+ width: 1.5rem;
+ height: 1.5rem;
+ @extend %icon-arrow-white;
+ transform: rotate(180deg);
+ }
+
+ &.btn-icon-arrow-right {
+ width: 1.5rem;
+ height: 1.5rem;
+ @extend %icon-arrow-white;
+ }
+
+ &.btn-icon-download {
+ @extend %icon-download;
+ }
+
+ &.btn-icon-edit {
+ width: 1.5rem;
+ height: 1.5rem;
+ @extend %icon-edit;
+ }
+
+ &.btn-icon-check {
+ @extend %icon-check;
+ }
+
+ &.btn-icon-close {
+ @extend %icon-close;
+ }
+
+ &.btn-icon-save {
+ width: 1.5rem;
+ height: 1.5rem;
+ @extend %icon-save;
+ }
+
+ &.btn-icon-conversations {
+ width: 1.5rem;
+ height: 1.5rem;
+ @extend %icon-conversations;
+ }
+
+ &.btn-icon-user-group {
+ width: 1.5rem;
+ height: 1.5rem;
+ @extend %icon-user-group;
+ }
+
+}
+
+.btn {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ height: 40px;
+ padding: 0.35rem 1.75rem 0.35rem;
+ display: inline-flex;
+ width: fit-content;
+ justify-content: center;
+ align-items: center;
+
+ &.btn-primary {
+ background-color: $secondary_4;
+ border: 2px solid $secondary_4;
+ color: $secondary_3;
+
+ &:hover {
+ background-color: darken($secondary_4, 6%);
+ border-color: darken($secondary_4, 6%);
+ color: $secondary_3;
+ }
+
+ &.hidden {
+ visibility: hidden;
+ }
+ }
+
+ &.btn-secondary {
+ background: $white;
+ border: 2px solid $border;
+ color: $secondary_1;
+
+ &:hover {
+ background-color: $light-bg;
+ border-color: $border;
+ color: $secondary_1;
+ }
+ }
+
+ &.btn-ghost {
+ background: transparent;
+ border: 2px solid transparent;
+ color: $secondary_4;
+
+ &:hover {
+ background-color: rgba($secondary_1, 0.04);
+ border-color: rgba($secondary_1, 0.04);
+ color: $secondary_4;
+ }
+ }
+
+ &.btn-accepted {
+ color: $olive;
+ background-color: $accept;
+
+ &.hidden {
+ visibility: hidden;
+ }
+ }
+
+ &.btn-link {
+ border-radius: 0;
+ padding: 0;
+ text-transform: unset;
+ font-size: 0.875rem;
+ border-bottom: 2px solid $border;
+ color: $secondary_4;
+ white-space: nowrap;
+ height: auto;
+
+ &.btn-icon-left,
+ &.btn-icon-right {
+ padding: 0;
+ }
+
+ &:hover {
+ color: $secondary_4;
+ border-color: $secondary_4;
+ background: transparent;
+ }
+
+ &.btn-link-plain {
+ border-bottom: none;
+
+ &.icon-view {
+ &::after {
+ content: '';
+ width: 1.5rem;
+ height: 1.5rem;
+ display: inline-block;
+ @extend %icon-view;
+ }
+ }
+ }
+
+ &.btn-link-list {
+ border-bottom: none;
+ font-weight: 400;
+ text-decoration: underline;
+ }
+
+ &.btn-margin {
+ margin: 0 0.5rem;
+ }
+ }
+
+ &.btn-dropdown {
+ text-transform: unset;
+ font-size: 0.875rem;
+ color: $secondary_4;
+ white-space: nowrap;
+ padding: 0.35rem 1rem 0.35rem 1.5rem;
+
+ &:hover {
+ background-color: rgba($secondary_1, 0.04);
+ }
+ }
+
+ &.btn-confirm {
+ color: $olive;
+ background-color: $accept;
+ height: 2rem;
+
+ &.btn-icon-left,
+ &.btn-icon-right {
+ padding: 0.5rem 0.75rem;
+ }
+ }
+
+ &.btn-decline {
+ padding: 0.5rem 0.75rem;
+ color: $olive-dark;
+ background-color: $decline;
+
+ &::before {
+ content: '';
+ width: 1rem;
+ height: 1rem;
+ @extend %icon-x;
+ margin-right: 0.25rem;
+ }
+ }
+
+ &.btn-icon-left,
+ &.btn-icon-right {
+ display: inline-flex;
+ gap: 8px;
+
+ &.btn-primary {
+ &::after,
+ &::before {
+ @include icon-color(secondary_3);
+ }
+ }
+
+ &.btn-secondary {
+ &::after,
+ &::before {
+ @include icon-color(secondary_1);
+ }
+ }
+
+ &.btn-ghost {
+ &::after,
+ &::before {
+ @include icon-color(secondary_4);
+ }
+ }
+
+ &.btn-confirm {
+ &::after,
+ &::before {
+ @include icon-color(success-green);
+ }
+ }
+
+ &.btn-decline {
+ &::after,
+ &::before {
+ @include icon-color(secondary_4);
+ }
+ }
+ }
+
+ &.btn-icon-left {
+ padding-left: 1.5rem;
+ padding-right: 2rem;
+
+ &::before {
+ @extend %btn-icon;
+ }
+
+ &::after {
+ content: unset;
+ }
+ }
+
+ &.btn-icon-right {
+ padding-left: 2rem;
+ padding-right: 1.5rem;
+
+ &::before {
+ content: unset;
+ }
+
+ &::after {
+ @extend %btn-icon;
+ }
+ }
+
+ &.btn-icon {
+ border: none;
+ background: none;
+
+ &::after {
+ @extend %btn-icon;
+ }
+ }
+}
diff --git a/assets/sass/backoffice/_dashboard.scss b/assets/sass/backoffice/_dashboard.scss
new file mode 100644
index 000000000..3ae77615c
--- /dev/null
+++ b/assets/sass/backoffice/_dashboard.scss
@@ -0,0 +1,145 @@
+.startpage-wrapper {
+ padding: 1rem;
+
+ h5 {
+ font-size: 1.125rem;
+ margin: 1.5rem 0 1rem;
+ font-weight: 600;
+ }
+}
+.startpage-shortcuts {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 8px;
+
+ @include media-breakpoint-down(lg) {
+ display: block;
+ }
+
+ &-item {
+ padding: 1.5rem;
+ background-color: $light-bg;
+ border-radius: 8px;
+
+ @include media-breakpoint-down(lg) {
+ margin-bottom: 8px;
+ }
+
+ h6 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ margin-bottom: 8px;
+ }
+
+ p {
+ font-size: 0.875rem;
+ margin-bottom: 2.5rem;
+ color: rgba($secondary_1, .66);
+ }
+
+ ul {
+ list-style: none;
+ padding: 0;
+
+ li {
+ a {
+ color: $secondary_4;
+ text-decoration: underline;
+ font-size: 0.875rem;
+ }
+ }
+ }
+
+ &.startpage-shortcuts-item-full-width {
+ grid-column: 1 / 4;
+ }
+
+ .startpage-shortcuts-lists {
+ display: flex;
+ gap: 3.5rem;
+ > ul {
+ flex: 0 0 calc(33.333333% - 2.375rem);
+ }
+ }
+ }
+}
+
+.welcome-message,
+.user-info-message {
+ background-color: rgba($message, .2);
+ padding: 1rem 1rem 1.5rem 1rem;
+ border-radius: 8px;
+ margin-bottom: 1.5rem;
+ position: relative;
+
+ @include media-breakpoint-down(md) {
+ min-width: calc(100vw - 110px);
+ }
+
+ i::before {
+ content: "";
+ width: 1rem;
+ height: 16px;
+ background-repeat: no-repeat;
+ position: absolute;
+ top: 1.125rem;
+ left: 1.125rem;
+ border-radius: 50%;
+ }
+
+ i.icon-info::before {
+ @extend %icon-info;
+ }
+
+ a.btn-close {
+ position: absolute;
+ right: 0;
+ top: 0.125rem;
+ outline: none;
+ border: none;
+ font-size: 0.625rem;
+ color: $black;
+ opacity: 1;
+
+ @include media-breakpoint-down(xs) {
+ &.btn:not(.btn.btn-link) {
+ width: auto;
+ }
+ }
+ }
+
+ &-content {
+ padding: 0 2rem;
+
+ p {
+ font-size: 0.875rem;
+ margin: 0.625rem 0 0;
+ }
+ }
+}
+
+.user-info-message {
+ &-content {
+ a, span {
+ font-weight: 600;
+ }
+
+ a {
+ color: $secondary_4;
+ }
+ }
+}
+
+.startpage-set-interests {
+ display: block;
+ color: $secondary-4;
+ text-decoration: underline;
+ font-size: 1rem;
+ margin-bottom: 1rem;
+
+ &:hover {
+ color: $secondary-4;
+ opacity: .9;
+ text-decoration: underline;
+ }
+}
\ No newline at end of file
diff --git a/assets/sass/backoffice/_filter_form.scss b/assets/sass/backoffice/_filter_form.scss
new file mode 100644
index 000000000..e68debf10
--- /dev/null
+++ b/assets/sass/backoffice/_filter_form.scss
@@ -0,0 +1,320 @@
+.filter-form {
+ .ntf-filter-search {
+ padding: 1rem;
+ border-bottom: 4px solid $secondary_2;
+
+ @include media-breakpoint-down(sm) {
+ display: block;
+ }
+
+ .ntf-filter-search-wrap {
+ display: flex;
+ gap: 16px;
+
+ @include media-breakpoint-down(sm) {
+ flex-wrap: wrap;
+ column-gap: 8px;
+ }
+
+ div .input-group [type='search'] {
+ @include media-breakpoint-up(md) {
+ max-width: 224px;
+ }
+ }
+ }
+
+ &.more-filters .dropdown-menu {
+ select.form-select,
+ .date-wrapper,
+ .filter-by-date,
+ input {
+ width: 100%;
+ }
+ }
+
+ &.ntf-filter-search-wrap-grid {
+ .ss-main.form-select {
+ background-image: none;
+
+ .ss-single {
+ color: $placeholder;
+ }
+ }
+
+ div > select.form-select {
+ width: 235px;
+ }
+ }
+
+ .search-wrap {
+ align-self: flex-end;
+
+ @include media-breakpoint-down(sm) {
+ flex: 1;
+ }
+ }
+
+ .dropdown {
+ align-self: flex-end;
+
+ .dropdown-menu {
+ border: 2px solid $secondary_2;
+ border-radius: 4px;
+ padding: 1rem;
+ box-shadow: 0px 4px 12px 0px rgba(37, 45, 64, 0.1);
+ display: none;
+ flex-direction: column;
+ gap: 16px;
+ top: 0.25rem;
+
+ &.show {
+ display: flex;
+ }
+ }
+ }
+
+ .ntf-filter-wrap {
+ display: flex;
+ align-items: flex-end;
+ gap: 16px;
+ margin-right: -1rem;
+
+ @include media-breakpoint-down(md) {
+ > div {
+ width: 100%;
+ }
+ }
+
+ select {
+ color: $placeholder;
+ @extend %icon-chevron;
+ background-size: 1.5rem;
+ background-position: 98%;
+ position: relative;
+ background-color: $light-bg;
+ }
+ }
+
+ .date-wrapper {
+ margin-right: auto;
+ }
+
+ label {
+ font-size: 0.875rem;
+ display: block;
+ }
+
+ .form-hidden {
+ display: none;
+ }
+
+ .form-select {
+ height: 40px;
+ font-size: 0.875rem;
+ width: 220px;
+ border-radius: 4px;
+ padding: 0 0 0 0.75rem;
+ }
+
+ input {
+ height: 40px;
+ border-radius: 4px;
+ background-color: $light-bg;
+ border: 2px solid $border;
+ font-size: 0.875rem;
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ }
+ }
+
+ select {
+ background-color: $light-bg;
+ }
+
+ input[type='search'] {
+ border-radius: 100px;
+ padding-left: 3rem;
+ min-width: 200px;
+
+ @include media-breakpoint-up(md) {
+ max-width: 204px;
+ }
+ }
+
+ .input-group {
+ @include media-breakpoint-up(md) {
+ flex-wrap: nowrap;
+ }
+ }
+
+ .btn-apply-filters {
+ white-space: nowrap;
+ align-self: flex-end;
+ width: auto;
+ color: $secondary_4;
+ border-color: $secondary_4;
+
+ @include media-breakpoint-down(md) {
+ padding: 0.5rem 1.5rem 0.5rem 1.5rem;
+ }
+
+ &:active {
+ background: $red;
+ border-color: $red;
+ color: $white;
+ }
+ }
+
+ .btn-reset-filters {
+ height: fit-content;
+ display: none;
+ align-self: flex-end;
+ margin: 0.5938rem 0;
+ }
+
+ .more-filters-btn {
+ align-items: center;
+ border-color: transparent;
+ display: none;
+ align-self: flex-end;
+
+ @include media-breakpoint-down(md) {
+ padding: 0.125rem 0.5rem 0.125rem 1rem;
+ }
+
+ &::after {
+ content: unset;
+ }
+
+ span {
+ display: inline-block;
+ line-height: 1.4;
+ }
+
+ i {
+ display: inline-flex;
+
+ &::after {
+ content: '';
+ width: 1rem;
+ height: 1rem;
+ }
+
+ &.icon-filter::after {
+ @extend %icon-filter;
+ }
+ }
+
+ &.show {
+ background-color: rgba($secondary_1, 0.04);
+ }
+ }
+
+ &.more-filters .more-filters-btn {
+ display: flex;
+ }
+
+ .input-group.input-search {
+ position: relative;
+ &::before {
+ content: '';
+ @extend %icon-search;
+ width: 1.5rem;
+ height: 1.5rem;
+ position: absolute;
+ margin: 0;
+ left: 1rem;
+ top: 0.5rem;
+ z-index: 10;
+ }
+
+ .btn {
+ border: 2px solid $border;
+ }
+ }
+
+ .input-group:not(.has-validation)
+ > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) {
+ @include media-breakpoint-down(md) {
+ border-radius: 100px;
+ margin-bottom: 0.5rem;
+ }
+ }
+
+ .input-group
+ > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(
+ .invalid-tooltip
+ ):not(.invalid-feedback) {
+ &.btn-apply-filters {
+ border-radius: 100px;
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ }
+ }
+ }
+ }
+
+ &.has-active-filter .ntf-filter-search {
+ .btn-reset-filters {
+ display: block;
+ }
+ }
+
+ &:not(.has-new-input) .ntf-filter-search {
+ .btn-apply-filters {
+ color: $gray;
+ border-color: $gray;
+ pointer-events: none;
+ }
+ }
+
+ .filter-by-date {
+ position: relative;
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ margin-right: 0;
+ }
+
+ .input-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.5rem;
+ height: 2.5rem;
+ position: absolute;
+ bottom: 0;
+ border-radius: 4px;
+ cursor: pointer;
+
+ @include media-breakpoint-down(md) {
+ top: 2rem;
+ }
+
+ &-calendar {
+ right: 0;
+
+ &::before {
+ content: '';
+ display: block;
+ width: 1.5rem;
+ height: 1.5rem;
+ @extend %icon-date;
+ }
+ }
+ }
+
+ input {
+ min-width: 220px;
+ padding-left: 1rem;
+ }
+ }
+}
+
+.modal-dialog .modal-content {
+ .ntf-filter-search .btn-apply-filters {
+ pointer-events: initial!important;
+ }
+}
\ No newline at end of file
diff --git a/assets/sass/backoffice/_global.scss b/assets/sass/backoffice/_global.scss
new file mode 100644
index 000000000..ed497c11d
--- /dev/null
+++ b/assets/sass/backoffice/_global.scss
@@ -0,0 +1,720 @@
+.hide-sm {
+ @media (max-width: 767px) {
+ display: none !important;
+ }
+}
+
+.hide-lg {
+ @media (min-width: 768px) {
+ display: none !important;
+ }
+}
+
+.cell-input-wrap {
+ display: flex;
+ align-items: center;
+
+ input[type='checkbox'] {
+ margin-right: 0.5rem;
+ }
+
+ a {
+ font-weight: 600;
+ }
+}
+
+.flash-notices {
+ position: fixed;
+ right: 0;
+ top: 4rem;
+ font-size: 0.875rem;
+ z-index: 9999;
+ animation: 3s hideMessage 5s forwards;
+
+ > div {
+ display: flex;
+ align-items: center;
+ padding: 0.5rem 1rem;
+ margin-bottom: 0.25rem;
+ border-top-left-radius: 8px;
+ border-bottom-left-radius: 8px;
+ }
+}
+
+.flash-success,
+.flash-notice,
+.flash-error {
+ padding: 1rem;
+ color: $secondary_4;
+ font-size: 1rem;
+ padding-bottom: 0;
+ font-weight: 600;
+
+ &::before {
+ content: '';
+ margin-right: 0.5rem;
+ border-radius: 50%;
+ padding: 0.25rem;
+ width: 1.5rem;
+ height: 1.5rem;
+ }
+}
+
+.flash-success {
+ background: $flash-ntf;
+ color: $flash-ntf-text;
+
+ &::before {
+ @extend %icon-check-white;
+ background-color: $success-green;
+ }
+}
+
+.flash-notice {
+ background: $orange;
+
+ &::before {
+ content: '!';
+ line-height: 1;
+ }
+}
+
+.flash-error {
+ background: $red;
+ color: $white;
+
+ &::before {
+ content: '!';
+ line-height: 1;
+ font-weight: 600;
+ margin-right: 0;
+ }
+}
+
+@keyframes hideMessage {
+ from {
+ opacity: 1;
+ z-index: 9999;
+ }
+
+ to {
+ opacity: 0;
+ z-index: -1;
+ }
+}
+
+form {
+ label {
+ font-size: 0.875rem;
+ margin-right: 1rem;
+ }
+
+ .row-input {
+ &.error-input {
+ ul {
+ list-style: none;
+ margin-top: 0.5rem;
+ margin-bottom: 0;
+ padding-left: 0;
+
+ li {
+ color: $red;
+ font-size: 0.75rem;
+ display: flex;
+ align-items: center;
+
+ &::before {
+ content: '';
+ @extend %icon-alert;
+ margin-right: 0.25rem;
+ width: 1rem;
+ height: 1rem;
+ display: inline-block;
+ }
+ }
+ }
+ }
+ }
+
+ .ss-main .ss-values .ss-value {
+ background-color: rgba($secondary_1, 0.12);
+ font-weight: 900;
+ padding: 0.0625rem;
+
+ .ss-value-text {
+ color: $secondary_1;
+ }
+
+ .ss-value-delete {
+ border-left: none;
+ svg {
+ transform: scale(1.25);
+ margin-top: 0.0625rem;
+
+ path {
+ stroke: $secondary_1;
+ }
+ }
+ }
+ }
+}
+
+div.ss-content {
+ font-size: 0.875rem;
+ border: 2px solid $border;
+ border-radius: 4px;
+ background-color: $light-bg;
+
+ &.ss-open-below {
+ border-top: none;
+ border-color: $secondary_1;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ &.ss-open-above {
+ border-bottom: none;
+ border-color: $secondary_1;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ .ss-list {
+ .ss-option:not(.ss-hide) {
+ line-height: 1.4;
+ margin: 0 0.5rem 0.25rem;
+ padding: 0.375rem 0.25rem;
+ display: block;
+ &:hover {
+ background-color: $secondary_2;
+ color: inherit;
+ }
+
+ .ss-search-highlight {
+ padding: 0;
+ background-color: $secondary_4;
+ color: $secondary_3;
+ }
+ }
+
+ .ss-search,
+ .ss-searching {
+ padding: 0.25rem 0.875rem 0.5rem;
+ }
+ }
+
+ .ss-search {
+ input {
+ background-color: transparent;
+ border: none;
+
+ &:focus {
+ box-shadow: none;
+ }
+
+ &::-webkit-search-cancel-button {
+ width: 0.75rem;
+ height: 0.75rem;
+ }
+ }
+
+ .ss-addable {
+ border: none;
+ margin-right: 0.5rem;
+ }
+ }
+}
+
+div.ss-main {
+ padding-left: 0.8125rem;
+ border-radius: 4px !important;
+
+ .ss-arrow {
+ width: 1rem;
+ height: 1rem;
+ margin-right: 1rem;
+
+ path {
+ stroke-width: 14px;
+ }
+ }
+
+ &[aria-expanded='true'] {
+ border-color: $secondary_1;
+ }
+
+ &.ss-open-below {
+ border-bottom: none;
+ border-radius: 4px 4px 0 0 !important;
+ }
+
+ &.ss-open-above {
+ border-top: none;
+ border-radius: 0 0 4px 4px !important;
+ }
+}
+
+div.ss-main,
+input[type='text'],
+input[type='email'],
+input[type='password'],
+input[type='email'],
+input[type='file'],
+select,
+textarea,
+textarea.form-control {
+ font-size: 0.875rem;
+ font-weight: 600;
+ height: 2.5rem;
+ border: 2px solid $border;
+ border-radius: 4px;
+ width: 330px;
+ background-color: $light-bg;
+ transition: border-color 0.3s ease, background-color 0.3s ease;
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ max-width: 330px;
+ }
+}
+
+textarea,
+textarea.form-control {
+ height: unset;
+}
+
+input[type='text'],
+input[type='password'],
+input[type='email'],
+select,
+select.form-select,
+textarea,
+textarea.form-control {
+ padding: 0 0.75rem 0 1rem;
+
+ &::placeholder {
+ font-weight: 400;
+ color: $border-hover;
+ }
+
+ &:hover:not(:disabled):not(:focus):not([readonly='readonly']) {
+ border-color: $border-hover;
+ }
+
+ &:focus,
+ &:focus-visible {
+ outline: none;
+ box-shadow: none;
+
+ &:not(:disabled):not([readonly='readonly']) {
+ border-color: $secondary_1;
+ font-weight: 400;
+ letter-spacing: 0.07px;
+ }
+ }
+
+ &:disabled,
+ &[readonly='readonly']:not([data-provide='datetimepicker']):not(.flatpickr-input) {
+ border-color: $light-bg;
+ background-color: $light-bg;
+ color: $border-hover;
+ }
+}
+
+textarea,
+textarea.form-control {
+ padding: 0.625rem 0.75rem 0.625rem 1rem;
+ height: unset;
+}
+
+select {
+ @extend %icon-chevron;
+ background-position: right 12px center;
+ appearance: none;
+ padding-right: 2.75rem;
+
+ &,
+ &.form-select {
+ font-size: 0.875rem;
+ font-weight: 600;
+ }
+}
+
+input[type='checkbox'] {
+ &,
+ &.form-check-input {
+ position: relative;
+ height: 1.25rem;
+ width: 1.25rem;
+ cursor: pointer;
+ appearance: none;
+ background-color: $white;
+ margin: 0;
+
+ border-radius: 4px;
+ border: 2px solid $border;
+ transition: background-color 0.2s ease, border-color 0.2s ease;
+
+ &:not(:checked):not(:indeterminate):hover {
+ background-color: $light-bg;
+ }
+
+ &:checked:not(:indeterminate) {
+ background-color: $secondary_4;
+ border-color: $secondary_4;
+ background-image: none;
+
+ &::after {
+ content: '';
+ @extend %icon-check;
+ position: absolute;
+ inset: 0;
+ }
+ }
+
+ &:indeterminate {
+ background-color: $secondary_4;
+ border-color: $secondary_4;
+ background-image: none;
+
+ &::after {
+ content: '—';
+ color: $white;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+ position: absolute;
+ inset: 0;
+ }
+ }
+ }
+}
+
+input[type='file'] {
+ font-weight: 400;
+
+ &::file-selector-button {
+ background-color: $white;
+ color: $secondary_1;
+ border: none;
+ border-radius: 4px;
+ border: 2px solid $border;
+ padding: 0.125rem 0.375rem;
+ margin: 0.25rem;
+ height: 1.75rem;
+ cursor: pointer;
+ transition: background-color 0.3s ease, border-color 0.3s ease;
+ font-weight: 600;
+ }
+
+ &:hover {
+ &::file-selector-button {
+ background-color: rgba($secondary_1, 0.12);
+ border-color: $border-hover;
+ }
+ }
+}
+
+input[type='radio'],
+input[type='radio'].form-check-input {
+ border-width: 2px;
+ width: 1.25rem;
+ height: 1.25rem;
+ @extend .form-check-input;
+
+ &:checked {
+ background-image: none;
+ position: relative;
+ background-color: transparent;
+ border-color: $secondary_4;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 0.625rem;
+ height: 0.625rem;
+ border-radius: 500px;
+ background-color: $secondary_4;
+ }
+ }
+}
+
+div.cke,
+div.cke.cke_chrome {
+ border: none;
+ box-sizing: border-box;
+ width: calc(65% - 1rem);
+ border-radius: 4px;
+ border: 2px solid $border;
+ transition: border-color 0.3s ease;
+
+ &:hover {
+ border-color: $border-hover;
+ }
+
+ &.cke_focus {
+ border-color: $black;
+ }
+
+ .cke_inner {
+ flex: 1;
+
+ .cke_top {
+ border-bottom: none;
+ }
+
+ .cke_bottom {
+ border-top: none;
+ }
+
+ .cke_contents {
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.01);
+ pointer-events: none;
+ }
+ }
+ }
+}
+
+ol li a {
+ color: $secondary_4;
+}
+
+.ntf-view-text a {
+ color: $secondary_4;
+ font-weight: 600;
+ border-bottom: 2px solid $border;
+}
+
+.gl-star-rating--stars[class*=' s'] > span {
+ background-image: none !important;
+
+ &::before {
+ content: '';
+ @extend %icon-star;
+ position: absolute;
+ width: 1.5rem;
+ height: 1.5rem;
+ }
+
+ &:hover {
+ &::before {
+ @include icon-color(secondary_4);
+ }
+ }
+}
+
+.gl-star-rating--stars[class*=' s'] > span.gl-active {
+ background-image: none !important;
+
+ &::before {
+ @extend %icon-star-filled;
+ }
+}
+
+input[type='range'] {
+ width: 330px;
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ max-width: 330px;
+ }
+
+ + output {
+ width: fit-content;
+ }
+}
+
+.custom-image-upload {
+ .file-wrapper {
+ display: inline-block;
+ border: 2px solid $border;
+ border-radius: 4px;
+ padding: 0.25rem;
+ width: 330px;
+ max-width: 100%;
+ background-color: $light-bg;
+ height: 2.5rem;
+ display: inline-block;
+
+ @include media-breakpoint-down(sm) {
+ width: 100%;
+ max-width: unset;
+ }
+
+ &.hidden {
+ display: none;
+ }
+
+ .file-container {
+ width: fit-content;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.875rem;
+ font-weight: 600;
+ background-color: rgba($secondary_1, 0.12);
+
+ .file-name {
+ max-width: 274px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @include media-breakpoint-down(sm) {
+ max-width: calc(100vw - 6.5rem);
+ }
+ }
+
+ .remove-image-btn {
+ background-color: transparent;
+ border: none;
+
+ &::after {
+ content: '';
+ @extend %icon-close;
+ position: absolute;
+ inset: 0;
+ }
+ }
+ }
+ }
+
+ .image-upload {
+ position: relative;
+
+ &.hidden {
+ display: none;
+ }
+ }
+
+ .btn-replace-image {
+ text-transform: uppercase;
+ height: fit-content;
+ align-self: center;
+ margin-left: 16px;
+
+ &.hidden {
+ display: none;
+ }
+ }
+}
+
+.alert.alert-margin {
+ margin: 1rem;
+}
+
+.ajax-message-wrapper {
+ max-width: 40%;
+ margin: 7rem auto 2rem;
+
+ i {
+ font-size: 4rem;
+ color: $success-green;
+ margin-bottom: 2rem;
+ }
+
+ &-title {
+ font-size: 1.5rem;
+ margin-bottom: 1.25rem;
+ }
+
+ h4 {
+ font-weight: 400;
+ font-size: 1.5rem;
+ }
+
+ @include media-breakpoint-down(md) {
+ max-width: 100%;
+
+ i {
+ font-size: 3rem;
+ }
+
+ h4 {
+ font-size: 1.25rem;
+ }
+ }
+}
+
+.sf-helper {
+ @include media-breakpoint-up(xl) {
+ .ntf-create-actions,
+ .create-actions,
+ .event-actions {
+ background-color: $white;
+ width: calc(100% - 248px) !important;
+ }
+ }
+}
+
+.ntf-create {
+ input[type=checkbox] {
+ height: auto;
+ width: revert;
+ }
+}
+
+.admin-form-action-buttons-wrapper {
+ display: none;
+ background-color: $light-bg;
+ width: 100%;
+ &.visible {
+ display: inline-flex;
+ }
+
+ .btn-action {
+ background: none;
+ border: none;
+ display: flex;
+ align-items: center;
+ font-size: .875rem;
+ color: $secondary_4;
+ margin-right: 0.875rem;
+
+ &.icon-user-cancel {
+ &::before {
+ content: '';
+ @extend %icon-user-cancel;
+ width: 1.5rem;
+ height: 1.5rem;
+ display: block;
+ margin-right: 0.5rem;
+ }
+ }
+
+ &.icon-user-check {
+ &::before {
+ content: '';
+ @extend %icon-user-check;
+ width: 1.5rem;
+ height: 1.5rem;
+ display: block;
+ margin-right: 0.5rem;
+ }
+ }
+
+ &.icon-delete {
+ &::before {
+ content: '';
+ @extend %icon-trash;
+ width: 1.5rem;
+ height: 1.5rem;
+ display: block;
+ margin-right: 0.5rem;
+ }
+ }
+ }
+}
diff --git a/assets/sass/backoffice/_global_styles.scss b/assets/sass/backoffice/_global_styles.scss
new file mode 100644
index 000000000..98656c404
--- /dev/null
+++ b/assets/sass/backoffice/_global_styles.scss
@@ -0,0 +1,179 @@
+// Section styles
+
+.backoffice-container {
+ flex: 1;
+
+ &.page-actions-fixed {
+ margin-bottom: calc(4.5rem - 0rem);
+ }
+
+ .impersonation-active & {
+ min-height: calc(100vh - 5rem - 3.25rem - 4.625rem);
+ }
+
+ .backoffice-container-title {
+ color: $secondary_1;
+ padding: 1rem;
+
+ font-size: 1.25rem;
+ }
+}
+
+.backoffice-section {
+ padding: 1rem;
+
+ + .backoffice-section,
+ .backoffice-container-title + &,
+ + * > .backoffice-section {
+ border-top: 2px solid $secondary_2;
+ }
+
+ .backoffice-section-title {
+ color: $secondary_1;
+
+ font-size: 1.125rem;
+ line-height: 1.2;
+ font-weight: 600;
+
+ + .backoffice-section-description {
+ margin-top: 0.25rem;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ color: rgba($secondary_1, 0.66);
+ }
+
+ ~ .backoffice-section-content {
+ margin-top: 1rem;
+ }
+ }
+
+ .backoffice-section-content:first-child {
+ .backoffice-table-view {
+ margin-top: -1rem;
+ }
+ }
+
+ .backoffice-section-actions {
+ display: flex;
+ justify-content: space-between;
+
+ .backoffice-section-actions-right {
+ margin-left: auto;
+ }
+
+ .backoffice-section-actions-left {
+ + .backoffice-section-actions-right {
+ margin-left: unset;
+ }
+ }
+
+ a,
+ button {
+ margin-top: 1rem;
+ }
+ }
+}
+
+// Page actions styles (e.g. next page, discard, save)
+
+.backoffice-page-actions {
+ display: flex;
+ justify-content: space-between;
+ padding: 0 1rem;
+ position: relative;
+ background-color: $white;
+ z-index: 1;
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0px 0 calc(100% - 2px) 0;
+ background-color: $secondary_2;
+ }
+
+ .backoffice-page-actions-right {
+ margin-left: auto;
+ }
+
+ .backoffice-page-actions-left {
+ + .backoffice-page-actions-right {
+ margin-left: unset;
+ }
+ }
+
+ .backoffice-page-actions-left,
+ .backoffice-page-actions-right {
+ display: flex;
+ gap: 1rem;
+
+ button,
+ a {
+ margin: 1rem 0;
+ }
+ }
+
+ &.page-actions-fixed {
+ position: fixed;
+ inset: auto 0 0 15.5rem;
+ }
+}
+
+// Table styles
+
+.backoffice-table-view {
+ margin: 0 -1rem -1rem;
+
+ .backoffice-table-head {
+ tr {
+ border-bottom: 2px solid $secondary_2;
+
+ th {
+ font-size: 0.875rem;
+ line-height: 1.4;
+ font-weight: 400;
+ color: rgba($secondary_1, 0.54);
+ padding: 0.875rem 0;
+
+ &:first-child {
+ padding-left: 1rem;
+ }
+ }
+ }
+ }
+
+ .backoffice-table-body {
+ font-size: 0.875rem;
+ line-height: 1.4;
+
+ tr {
+
+ &:not(:last-child) {
+ border-bottom: 2px solid $secondary_2;
+ }
+
+ td {
+ padding: 0.75rem 0;
+
+ &:first-child {
+ padding-left: 1rem;
+ }
+
+ &.backoffice-table-row-title {
+ font-weight: 600;
+ }
+ }
+ }
+
+ .backoffice-table-row-highlight {
+ background-color: rgba($secondary_4, 0.05);
+ }
+ }
+
+ + .backoffice-section-actions {
+ border-top: 2px solid $secondary_2;
+ margin-left: -1rem;
+ margin-right: -1rem;
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+}
diff --git a/assets/sass/backoffice/_header.scss b/assets/sass/backoffice/_header.scss
new file mode 100644
index 000000000..e3cdf7929
--- /dev/null
+++ b/assets/sass/backoffice/_header.scss
@@ -0,0 +1,380 @@
+.app-header {
+ height: $chat-header-height;
+ display: flex;
+ align-items: center;
+ position: fixed;
+ border-bottom: 2px solid $secondary_2;
+ width: calc(100% - 248px);
+ right: 0;
+ top: 0;
+ z-index: 100;
+ background: $white;
+ transition: all .2s ease-in-out;
+
+ @include media-breakpoint-down(md) {
+ height: 4.25rem;
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ &__content {
+ position: relative;
+ padding: 0 1rem;
+ width: 100%;
+
+ @include media-breakpoint-down(md) {
+ width: auto;
+ }
+
+ .header-action-button {
+ display: flex;
+ position: absolute;
+ right: 1rem;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ h5 {
+ display: flex;
+ align-items: center;
+ span {
+ width: 3rem;
+ height: 3rem;
+ background: $secondary_2;
+ border-radius: 100px;
+ margin-right: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 500;
+ font-size: 1.1875rem;
+ }
+ }
+
+ .header-icon {
+ margin-right: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: $secondary_2;
+ border-radius: 50%;
+
+ @media (max-width: 768px) {
+ width: 2.5rem;
+ height: 2.5rem;
+ margin-right: 0;
+ }
+
+ &::before {
+ content: "";
+ display: block;
+ width: 3rem;
+ height: 3rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+ }
+
+ &__content-wrap {
+ display: flex;
+ align-items: center;
+ position: relative;
+ color: $secondary_1;
+ }
+
+ &__content-initials {
+ background: $secondary_2;
+ width: 3rem;
+ height: 3rem;
+ border-radius: 50%;
+ font-size: 0.75rem;
+ font-weight: 700;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-right: 1rem;
+
+ @include media-breakpoint-down(md) {
+ width: 2.5rem;
+ height: 2.5rem;
+ margin-right: 0;
+ }
+ }
+
+ &__content-name {
+ font-size: 1.375rem;
+ font-weight: 600;
+
+ @include media-breakpoint-down(md) {
+ display: none;
+ }
+ }
+
+ &__content-location {
+ font-size: 0.75rem;
+ color: rgba($secondary_1, .66);
+ display: block;
+ margin-top: 0.25rem;
+
+ @include media-breakpoint-down(md) {
+ display: none;
+ }
+ }
+
+ &-logo {
+ @include media-breakpoint-up(md) {
+ display: none;
+ }
+ }
+
+ .header-menu-icon-wrap {
+ display: flex;
+ align-items: center;
+ }
+
+ .btn-primary {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ margin-left: auto;
+ }
+}
+
+.info-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+
+ &-wrap {
+ display: flex;
+ align-items: center;
+ }
+
+ &-name {
+ font-size: 1.375rem;
+ font-weight: 600;
+
+ @media (max-width: 768px) {
+ display: none;
+ }
+ }
+
+ &.info-header-modified {
+
+ @include media-breakpoint-down(lg) {
+ h4 span {
+ display: none;
+ }
+ }
+ .btn-link-plain.country-profile-view {
+
+ @include media-breakpoint-up(lg) {
+ position: absolute;
+ right: 1rem;
+ top: 4.6rem;
+ }
+ }
+ }
+
+ h4 {
+ font-size: 1.375rem;
+ display: flex;
+ align-items: center;
+
+ @include media-breakpoint-down(sm) {
+ font-size: 0.875rem;
+ }
+ }
+
+ h4 i {
+ display: block;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: $secondary-1;
+ margin-right: 16px;
+
+ &::before {
+ content: "";
+ display: block;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ &.icon-arrow {
+ background: none;
+ cursor: pointer;
+ &::before {
+ @extend %icon-arrow;
+ }
+ }
+ }
+
+ .content-download-btns {
+ padding: 1rem;
+
+ .btn-icon-pdf {
+ margin-right: 0.5rem;
+ }
+ }
+}
+
+.app-header-actions {
+ display: flex;
+ gap: 1.5rem;
+ margin-right: 1rem;
+ &.app-header-download-actions {
+ margin: 1rem;
+ }
+}
+
+.app-header-tabs {
+ display: flex;
+ text-align: center;
+
+ .app-tabs-mobile-wrapper & {
+ justify-content: center;
+ margin: 6.25rem auto 0;
+ border-bottom: 2px solid $border;
+ padding-bottom: 1.125rem;
+
+ @include media-breakpoint-up(xl) {
+ display: none;
+ }
+
+ @include media-breakpoint-down(md) {
+ margin: 5.25rem auto 0;
+ }
+ }
+
+ .app-header__content-wrap & {
+ position: absolute;
+ left: 0;
+ right: 0;
+ margin: auto;
+ max-width: 300px;
+
+ @include media-breakpoint-down(xl) {
+ display: none;
+ }
+ }
+
+ a {
+ font-size: 0.875rem;
+ font-weight: 600;
+ padding: 0.625rem 2rem 0.375rem;
+ display: flex;
+ align-items: center;
+ position: relative;
+
+ &::before {
+ content: "";
+ display: inline-block;
+ margin-right: 0.5rem;
+ margin-bottom: 0.1875rem;
+ width: 1.5rem;
+ height: 1.5rem;
+ }
+
+ &::after {
+ content: "";
+ width: 100%;
+ height: 2px;
+ background: $secondary_1;
+ position: absolute;
+ left: 0;
+ bottom: -1.25rem;
+ }
+
+ &.icon-overview {
+ &::before {
+ @extend %icon-overview;
+ }
+ }
+
+ &.icon-docs {
+ &::before {
+ @extend %icon-docs;
+ }
+ }
+
+ &:not(.tab-active) {
+ opacity: .66;
+
+ &::after {
+ background: transparent;
+ }
+ }
+ }
+}
+
+.closed-sidebar .app-header {
+ width: calc(100% - 75px);
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ }
+}
+
+.mainnav-toggle {
+ display: none;
+ position: relative;
+ right: $gutter;
+ width: 3.75rem;
+ height: 3.75rem;
+
+ @include media-breakpoint-down(md) {
+ display: block;
+ right: 0;
+ width: 3rem;
+ }
+
+ .hamburger {
+ display: block;
+ width: 1.25rem;
+ height: 0.125rem;
+ position: absolute;
+ background: $black;
+ left: 50%;
+ top: 50%;
+ margin: -0.0625rem 0 0 -0.625rem;
+ transition: background 100ms ease-out;
+
+ &::before,
+ &::after {
+ content: '';
+ display: block;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ background: $black;
+ transition: all 400ms;
+ }
+
+ &::before {
+ top: -0.375rem;
+ }
+ &::after {
+ bottom: -0.375rem;
+ }
+ }
+
+ .closed-sidebar & {
+ .hamburger {
+ background: transparent;
+ transition: background 100ms ease-out;
+ &::before {
+ transform: translate(0, 0.375rem) rotate(135deg);
+ }
+ &::after {
+ transform: translate(0, -0.375rem) rotate(-135deg);
+ }
+ }
+ }
+}
diff --git a/assets/sass/backoffice/_icons.scss b/assets/sass/backoffice/_icons.scss
new file mode 100644
index 000000000..9c3138f02
--- /dev/null
+++ b/assets/sass/backoffice/_icons.scss
@@ -0,0 +1,156 @@
+%icon-dashboard {
+ background: url("../assets/icons/icon_dashboard.svg") no-repeat center center;
+}
+
+%icon-bookmarks {
+ background: url("../assets/icons/icon_bookmarks.svg") no-repeat center center;
+}
+
+%icon-conversations {
+ background: url("../assets/icons/icon_conversations.svg") no-repeat center center;
+}
+
+%icon-info {
+ background: url("../assets/icons/icon_info.svg") no-repeat center center;
+}
+
+%icon-close {
+ background: url("../assets/icons/icon_close.svg") no-repeat center center;
+}
+
+%icon-date {
+ background: url("../assets/icons/icon_date.svg") no-repeat center center;
+}
+
+%icon-plus {
+ background: url("../assets/icons/icon_plus.svg") no-repeat center center;
+}
+
+%icon-download {
+ background: url("../assets/icons/icon_download.svg") no-repeat center center;
+}
+
+%icon-logout {
+ background: url("../assets/icons/icon_logout.svg") no-repeat center center;
+}
+
+%icon-arrow {
+ background: url("../assets/icons/icon_arrow_left.svg") no-repeat center center;
+}
+
+%icon-arrow-white {
+ background: url("../assets/icons/icon_arrow_white.svg") no-repeat center center;
+}
+
+%icon-search {
+ background: url("../assets/icons/icon_search.svg") no-repeat center center;
+}
+
+%icon-docs {
+ background: url("../assets/icons/icon_docs.svg") no-repeat center center;
+}
+
+%icon-overview {
+ background: url("../assets/icons/icon_overview.svg") no-repeat center center;
+}
+
+%icon-delete {
+ background: url("../assets/icons/icon_delete.svg") no-repeat center center;
+}
+
+%icon-warning {
+ background: url("../assets/icons/icon_warning.svg") no-repeat center center;
+}
+
+%icon-external {
+ background: url("../assets/icons/icon_external.svg") no-repeat center center;
+}
+
+%icon-external-dark {
+ background: url("../assets/icons/icon_exteral_dark.svg") no-repeat center center;
+}
+
+%icon-dots {
+ background: url("../assets/icons/icon_dots.svg") no-repeat center center;
+}
+
+%icon-filter {
+ background: url("../assets/icons/icon_filter_red.svg") no-repeat center center;
+}
+
+%icon-check {
+ background: url("../assets/icons/icon_check.svg") no-repeat center center;
+}
+
+%icon-check-white {
+ background: url("../assets/icons/icon_check_white.svg") no-repeat center center;
+}
+
+%icon-x {
+ background: url("../assets/icons/icon_close_small.svg") no-repeat center center;
+}
+
+%icon-view {
+ background: url("../assets/icons/icon_view.svg") no-repeat center center;
+}
+
+%icon-edit {
+ background: url("../assets/icons/icon_edit.svg") no-repeat center center;
+}
+
+%icon-chevron {
+ background: url("../assets/icons/icon_chevron.svg") no-repeat center center;
+}
+
+%icon-check {
+ background: url("../assets/icons/icon_check.svg") no-repeat center center;
+}
+
+%icon-alert {
+ background: url("../assets/icons/icon_alert.svg") no-repeat center center;
+}
+
+%icon-star {
+ background: url("../assets/icons/icon_star.svg") no-repeat center center;
+}
+
+%icon-star-filled {
+ background: url("../assets/icons/icon_star_filled.svg") no-repeat center center;
+}
+
+%icon-copy {
+ background: url("../assets/icons/icon_copy.svg") no-repeat center center;
+}
+
+%icon-user-cancel {
+ background: url("../assets/icons/icon_user_cancel.svg") no-repeat center center;
+}
+
+%icon-user-check {
+ background: url("../assets/icons/icon_user_check.svg") no-repeat center center;
+}
+
+%icon-trash {
+ background: url("../assets/icons/icon_delete.svg") no-repeat center center;
+}
+
+%icon-rename {
+ background: url("../assets/icons/icon_edit.svg") no-repeat center center;
+}
+
+%icon-save {
+ background: url("../assets/icons/icon_save.svg") no-repeat center center;
+}
+
+%icon-list {
+ background: url("../assets/icons/icon_list.svg") no-repeat center center;
+}
+
+%icon-user-group {
+ background: url("../assets/icons/icon_user_group.svg") no-repeat center center;
+}
+
+%icon-notifications {
+ background: url("../assets/icons/icon_notifications_dark.svg") no-repeat center center;
+}
+
diff --git a/assets/sass/backoffice/_listing.scss b/assets/sass/backoffice/_listing.scss
new file mode 100644
index 000000000..46272b766
--- /dev/null
+++ b/assets/sass/backoffice/_listing.scss
@@ -0,0 +1,285 @@
+.list-table {
+ thead {
+ tr {
+ border-bottom: 2px solid $secondary_2;
+ height: 3rem;
+ vertical-align: middle;
+
+ th {
+ font-size: 0.875rem;
+ padding: 0 1rem;
+ color: rgba($secondary_1, .54);
+ font-weight: 400;
+ }
+ }
+ }
+}
+
+.list-body {
+ vertical-align: middle;
+
+ @include media-breakpoint-down(md) {
+ min-width: 900px;
+ }
+
+ tr {
+ border-bottom: 2px solid $secondary_2;
+ height: 3rem;
+
+ td {
+ font-size: 0.875rem;
+ padding: 0 1rem;
+
+ &.list-title {
+ font-weight: 600;
+ }
+
+ &.cell-wide {
+ padding: 1rem;
+ }
+
+ &.cell-baseline {
+ vertical-align: top;
+ padding-top: 0.75rem;
+ }
+ }
+
+ &.selected {
+ background: rgba(163, 31, 52, 0.1);
+ }
+ }
+
+ &-title {
+ color: $secondary_1;
+
+ @include media-breakpoint-down(sm) {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ text-overflow: ellipsis;
+ }
+ }
+
+ &-type {
+ span {
+ border-radius: 100px;
+ background: $secondary_2;
+ padding: 0.125rem 0.75rem;
+ color: inherit;
+ }
+ }
+
+ .dropdown {
+ cursor: pointer;
+ position: relative;
+ width: fit-content;
+
+ .dropdown-toggle {
+ &::after {
+ content: unset;
+ }
+
+ i {
+ &::after {
+ content: '';
+ display: block;
+ width: 1.5rem;
+ height: 1.5rem;
+ }
+
+ &.icon-dots::after {
+ @extend %icon-dots;
+ }
+ }
+ }
+
+ .dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ flex-direction: column;
+ gap: 0.5rem;
+ background-color: $white;
+ border: 2px solid $secondary_2;
+ border-radius: 4px;
+ z-index: 1;
+ padding: 0.5rem;
+ box-shadow: 0px 4px 12px 0px rgba(37, 45, 64, 0.1);
+
+ .dropdown-item {
+ display: flex;
+ align-items: center;
+ position: relative;
+ padding: 0;
+
+ &:hover {
+ background-color: $darkgray;
+ color: $secondary_1;
+ }
+
+ &.with-icon {
+
+ a, button {
+ padding-right: 2.5rem;
+ }
+
+ i {
+ position: absolute;
+ right: 0;
+ left: auto;
+ }
+ }
+
+ &.with-icon-left {
+
+ a, button {
+ padding-right: 0;
+ padding-left: 2.5rem;
+ }
+
+ i {
+ right: auto;
+ left: 0;
+ }
+ }
+ }
+
+ i {
+ left: 0;
+ display: flex;
+ padding: 0.5rem;
+ pointer-events: none;
+
+ &::before {
+ content: '';
+ width: 1.5rem;
+ height: 1.5rem;
+ display: inline-block;
+ }
+ }
+
+ i.icon-docs::before {
+ @extend %icon-docs;
+ }
+ i.icon-delete::before {
+ @extend %icon-delete;
+ }
+ i.icon-view::before {
+ @extend %icon-view;
+ }
+ i.icon-plus::before {
+ @extend %icon-plus;
+ }
+ i.icon-edit::before {
+ @extend %icon-edit;
+ }
+ i.icon-external-dark::before {
+ @extend %icon-external-dark;
+ }
+
+ a,
+ button {
+ display: block;
+ font-size: 0.875rem;
+ background: none;
+ white-space: nowrap;
+ border: none;
+ padding: 0.5rem 0;
+ width: 100%;
+ text-align: start;
+ }
+ }
+ }
+}
+
+nav.page-navigation {
+ ul {
+ display: flex;
+ margin: 2rem 0;
+ justify-content: center;
+
+ .page-item {
+ list-style: none;
+ a {
+ width: 2.5rem;
+ height: 2.5rem;
+ margin: 0.75rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 0.875rem;
+ font-weight: 600;
+ border-radius: 2px;
+ }
+
+ &.current {
+ .page-link {
+ width: 2.5rem;
+ height: 2.5rem;
+ margin: 0.75rem 0.75rem 0.75rem 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 0.875rem;
+ font-weight: 600;
+ border-radius: 2px;
+ background-color: $secondary_2;
+ color: $border-hover;
+ }
+ }
+
+ &.disabled {
+ opacity: .05;
+ }
+ }
+
+ span.disabled, a[rel=next], a[rel=prev] {
+ text-indent: -9999px;
+
+ &::after {
+ @extend %icon-arrow;
+ content: "";
+ display: block;
+ width: 1.5rem;
+ height: 1.5rem;
+ }
+ }
+
+ span.disabled, a[rel=next] {
+ &::after {
+ transform: rotate(180deg);
+ }
+ }
+
+ span.disabled:first-child {
+ &::after {
+ transform: rotate(0);
+ }
+ }
+ }
+}
+
+.nothing-to-show {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ border: 2px dashed $border;
+ border-radius: .5rem;
+ padding: 1.5rem;
+
+ &.nothing-to-show-with-margin {
+ margin: 1rem;
+ }
+
+ .icon.icon-list {
+ @extend %icon-list;
+ width: 1.5rem;
+ height: 1.5rem;
+ }
+
+ p {
+ font-size: .875rem;
+ margin-bottom: .5rem;
+ }
+}
\ No newline at end of file
diff --git a/assets/sass/backoffice/_main.scss b/assets/sass/backoffice/_main.scss
new file mode 100644
index 000000000..e2be1675f
--- /dev/null
+++ b/assets/sass/backoffice/_main.scss
@@ -0,0 +1,121 @@
+html, body {
+ background-color: $white;
+}
+
+.app-container .app-main__outer {
+ transition: all .2s ease-in-out;
+
+ @include media-breakpoint-down(md) {
+ padding-top: 5rem;
+ }
+}
+
+.impersonation-active {
+ .app-header {
+ top: 3.25rem;
+
+ @media (max-width: 600px) {
+ top: 4.8125rem;
+ }
+ }
+
+ .app-container {
+ margin-top: 3.25rem;
+
+ @media (max-width: 600px) {
+ margin-top: 4.8125rem;
+ }
+ }
+
+ .app-sidebar {
+ top: 3.25rem;
+ height: calc(100vh - 3.25rem);
+
+ @media (max-width: 600px) {
+ top: 9rem;
+ height: calc(100vh - 9rem);
+ }
+ }
+}
+
+.fixed-sidebar.app-container .app-main__outer {
+ z-index: 9;
+ padding-left: 15.5rem;
+ padding-top: 5rem;
+
+ @include media-breakpoint-down(md) {
+ padding-left: 0;
+ padding-top: 4.25rem;
+ }
+}
+
+.closed-sidebar.fixed-sidebar .app-main__outer {
+ padding-left: 4.6875rem;
+
+ @include media-breakpoint-down(md) {
+ padding-left: 0;
+ }
+}
+
+.chat-container {
+ height: calc(100vh - #{$chat-header-height});
+}
+
+input[type="search"]::-webkit-search-cancel-button {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 1rem;
+ height: 1rem;
+ margin-right: 1rem;
+ background-size: cover;
+ cursor: pointer;
+ @extend %icon-close;
+}
+
+.impersonation-active-panel {
+ background: $primary_0;
+ width: 100%;
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1000;
+ font-size: 0.875rem;
+ padding: 0.5rem 1rem;
+
+ @media (max-width: 600px) {
+ flex-direction: column;
+ }
+
+ p {
+ display: flex;
+ align-items: center;
+
+ &::before {
+ content: "";
+ width: 1rem;
+ height: 1rem;
+ display: block;
+ border-radius: 50%;
+ margin-right: 0.5rem;
+ @extend %icon-info;
+ }
+ }
+
+ span {
+ font-weight: 600;
+ margin-left: 0.5rem;
+ }
+
+ .btn-user-switch-back {
+ text-transform: uppercase;
+ color: $secondary_4;
+ border: 2px solid rgba($black, .12);
+ border-radius: 20px;
+ font-size: 0.75rem;
+ height: 2.25rem;
+
+ @media (max-width: 600px) {
+ margin-top: 0.25rem;
+ }
+ }
+}
diff --git a/assets/sass/backoffice/_modal.scss b/assets/sass/backoffice/_modal.scss
new file mode 100644
index 000000000..9abd8fa0b
--- /dev/null
+++ b/assets/sass/backoffice/_modal.scss
@@ -0,0 +1,125 @@
+.modal {
+ &.show-spinner {
+ .spinner#spinner {
+ display: block;
+ }
+
+ .modal-content > * {
+ display: none;
+ }
+ }
+
+ @include media-breakpoint-up(sm) {
+ .modal-wide.modal-dialog {
+ --bs-modal-width: 630px;
+ }
+ }
+
+ .modal-content {
+ border-radius: 4px;
+ padding: 32px;
+ min-height: 200px;
+ gap: 8px;
+
+ p {
+ margin: 0;
+ }
+ .modal-header {
+ padding: 0;
+ border: none;
+ justify-content: center;
+
+ .modal-title {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ &.centered {
+ align-items: center;
+ text-align: center;
+ }
+
+ i {
+ display: flex;
+
+ &::before {
+ content: '';
+ width: 56px;
+ height: 56px;
+ display: inline-block;
+ }
+
+ &.icon-error::before {
+ @extend %icon-warning;
+ }
+
+ &.icon-close::before {
+ transform: scale(1.25);
+ width: 12px;
+ height: 12px;
+ @extend %icon-close;
+ }
+ }
+
+ .close-modal {
+ all: unset;
+ cursor: pointer;
+ position: absolute;
+ right: 20px;
+ top: 20px;
+ }
+
+ p {
+ font-size: 22px;
+ font-weight: 600;
+ line-height: 1.2;
+ }
+ }
+ }
+
+ .modal-body {
+ font-size: 14px;
+ padding: 0;
+
+ &.centered {
+ text-align: center;
+ }
+
+ .modal-form-input {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ align-items: flex-start;
+
+ div.cke,
+ div.cke.cke_chrome {
+ width: 100%;
+ }
+ }
+ }
+
+ .modal-footer {
+ padding: 0;
+ border-top: none;
+ margin-top: 32px;
+
+ &.centered {
+ justify-content: center;
+ }
+
+ > * {
+ margin: 0;
+ }
+
+ .btn-primary {
+ &::after {
+ content: unset;
+ }
+ }
+
+ .btn-secondary {
+ border: none;
+ }
+ }
+ }
+}
diff --git a/assets/sass/backoffice/_my_account.scss b/assets/sass/backoffice/_my_account.scss
new file mode 100644
index 000000000..8e53b18e3
--- /dev/null
+++ b/assets/sass/backoffice/_my_account.scss
@@ -0,0 +1,255 @@
+.my-account {
+ form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin: 1.5rem 1rem;
+
+ > div {
+ display: grid;
+ grid-template-columns: 35% 1fr;
+ column-gap: 1rem;
+
+ row-gap: 0.5rem;
+
+ @include media-breakpoint-down(sm) {
+ grid-template-columns: 1fr;
+ }
+
+ label {
+ flex: 35%;
+ color: $secondary_1;
+ margin-right: 0;
+ font-size: 0.875rem;
+ line-height: 1.4;
+ }
+
+ label[for='preferences_email_consent'] {
+ width: auto;
+ }
+
+ > input,
+ > .ss-main,
+ > .cke,
+ .custom-image-upload,
+ select {
+ width: 100%;
+
+ @include media-breakpoint-down(sm) {
+ max-width: unset;
+ }
+ }
+
+ > input[type='checkbox'] {
+ min-width: unset;
+ max-width: 20px;
+ }
+
+ .errors {
+ flex: 100%;
+ margin-left: calc(35% + 1rem);
+
+ @include media-breakpoint-down(sm) {
+ margin-left: 0;
+ }
+ }
+ }
+
+ .my-account-image-container {
+ display: flex;
+ gap: 1rem;
+
+ @include media-breakpoint-down(sm) {
+ flex-direction: column;
+
+ img {
+ align-self: flex-start;
+ }
+ }
+
+ img {
+ border-radius: 4px;
+ max-width: 200px;
+ max-height: 200px;
+
+ &[src=''] {
+ display: none;
+ }
+
+ + .custom-image-upload {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+
+ .file-wrapper {
+ width: unset;
+ }
+
+ .btn-replace-image {
+ align-self: unset;
+ margin-left: 0.5rem;
+ }
+ }
+ }
+ }
+
+ > .checkbox-row {
+
+ @include media-breakpoint-down(sm) {
+ grid-template-columns: 1fr minmax(auto, 50%);
+ }
+
+ label {
+ grid-row: 1;
+ grid-column: 1;
+ }
+
+ input {
+ grid-row: 1;
+ grid-column: 2;
+ }
+
+ .extra-label {
+ grid-row: 1;
+ grid-column: 2;
+ margin-left: 1.625rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ }
+ }
+
+ &[name='change_password'] {
+
+ @include media-breakpoint-down(lg) {
+ input:not([type='checkbox']) {
+ min-width: 100%;
+ }
+ }
+
+ div:nth-child(1),
+ div:nth-child(2),
+ div:nth-child(3) {
+ position: relative;
+ margin-bottom: 1.25rem;
+
+ @include media-breakpoint-down(lg) {
+ margin-bottom: 0.5rem;
+ }
+
+ ul {
+ flex: 100%;
+ margin-left: calc(35% + 1rem);
+ position: absolute;
+ list-style: none;
+ top: 3rem;
+ padding-left: 0;
+
+ @include media-breakpoint-down(lg) {
+ margin-left: 0;
+ margin-bottom: 0;
+ right: 0;
+ position: revert;
+ grid-row: 2;
+ grid-column: 2;
+ }
+
+ @include media-breakpoint-down(sm) {
+ grid-column: 1;
+ grid-row: 3;
+ }
+
+ li {
+ color: $red;
+ font-size: 0.75rem;
+ display: flex;
+ align-items: center;
+
+ &::before {
+ content: '';
+ @extend %icon-alert;
+ margin-right: 0.25rem;
+ width: 1rem;
+ height: 1rem;
+ display: inline-block;
+ }
+ }
+ }
+ }
+
+ div:nth-child(3) {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .change-email-wrapper {
+ .email-domain {
+ display: inline-flex;
+ align-items: center;
+ font-size: 0.875rem;
+ font-weight: 600;
+ height: 2.5rem;
+ border: 2px solid $light-bg;
+ background-color: $light-bg;
+ color: $border-hover;
+ border-radius: 4px;
+ width: 330px;
+ padding: 0 0.75rem 0 1rem;
+ }
+ }
+
+ .form-actions-password-requirements {
+ margin-bottom: 1rem;
+ width: calc(65% - 1rem);
+ margin-left: auto;
+ display: inline-block;
+
+ p {
+ font-size: 0.875rem;
+ color: $gray-54;
+ margin-bottom: 0;
+ }
+
+ @include media-breakpoint-down(lg) {
+ width: 100%;
+ }
+ }
+
+ .form-actions {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ width: 100%;
+ justify-content: space-between;
+
+ @include media-breakpoint-down(lg) {
+ justify-content: center;
+
+ button {
+ margin-bottom: 0.5rem;
+ }
+ }
+ }
+
+ &.my-account-preferences {
+ div.checkbox-row {
+ display: flex;
+ align-items: center;
+ width: max-content;
+ min-width: 530px;
+
+ span.extra-label {
+ margin-left: 0;
+ }
+
+ @include media-breakpoint-down(sm) {
+ min-width: auto;
+ flex-direction: column;
+ }
+ }
+
+ .form-actions {
+ justify-content: flex-start;
+ }
+ }
+}
diff --git a/assets/sass/backoffice/_print.scss b/assets/sass/backoffice/_print.scss
new file mode 100644
index 000000000..85d3b809e
--- /dev/null
+++ b/assets/sass/backoffice/_print.scss
@@ -0,0 +1,42 @@
+@media print {
+ html,
+ body {
+ font-size: 10px !important;
+ width: 210mm !important;
+ margin: 0px;
+ background-color: transparent;
+ }
+
+ @page {
+ size: A4;
+ width: 210mm;
+ height: 297mm;
+ padding: 1cm;
+ margin: 25mm 25mm 25mm 25mm;
+ border: 1px #D3D3D3 solid;
+ border-radius: 0px;
+ background: white;
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
+ }
+ .container {
+ max-width: 100%;
+ }
+ .impersonation-active-panel,
+ .app-sidebar,
+ .btn:not(.btn-download-link),
+ .header-icon.icon-arrow {
+ display: none !important;
+ }
+ .list-group-item {
+ border: none;
+ border-radius: 0;
+ }
+ .alert {
+ color: $black;
+ background-color: transparent;
+ border-color: $black;
+ }
+ .collapse:not(.show) {
+ display: block;
+ }
+}
diff --git a/assets/sass/backoffice/_sidebar.scss b/assets/sass/backoffice/_sidebar.scss
new file mode 100644
index 000000000..f98124c47
--- /dev/null
+++ b/assets/sass/backoffice/_sidebar.scss
@@ -0,0 +1,590 @@
+.app-sidebar {
+ background-color: $white;
+ position: fixed;
+ height: 100vh;
+ width: 248px;
+ min-width: 248px;
+ display: flex;
+ z-index: 11;
+ flex: 0 0 248px;
+ top: 0;
+ transition: all 0.2s;
+ border-right: 2px solid $secondary_2;
+ overflow: hidden;
+ padding: 1.25rem 1.25rem 1.25rem 0;
+
+ @include media-breakpoint-down(md) {
+ width: 280px;
+ top: 4.25rem;
+ filter: drop-shadow(8px 8px 32px rgba(0, 0, 0, 0.1));
+ }
+}
+
+.closed-sidebar .app-sidebar {
+ transition: all 0.2s ease-in-out;
+ width: 75px;
+ min-width: 75px;
+ flex: 0 0 75px;
+ z-index: 13;
+
+ @include media-breakpoint-down(md) {
+ width: 280px;
+ }
+
+ .sidebar-wrapper {
+ width: revert;
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ }
+ }
+}
+
+@include media-breakpoint-down(md) {
+ .app-container:not(.closed-sidebar) {
+ .app-sidebar {
+ transform: translateX(-280px);
+ filter: none;
+ }
+ }
+}
+
+.sidebar-wrapper {
+ width: 100%;
+
+ &__logo {
+
+ @include media-breakpoint-down(md) {
+ display: none;
+ }
+
+ img {
+ width: 166px;
+ height: 40px;
+ object-fit: cover;
+ margin-left: 1.125rem;
+ }
+ }
+}
+
+.sidebar-toggle {
+ position: absolute;
+ right: 0;
+ top: 63px;
+ background-color: $light-bg;
+ width: 1.75rem;
+ height: 2rem;
+ border-radius: 24px 0 0 24px;
+ padding-left: 0.75rem;
+ font-size: 0.875rem;
+ transition: all 0.2s ease-in-out;
+ z-index: 20;
+ border: none;
+
+ @include media-breakpoint-down(md) {
+ display: none;
+ }
+
+ i {
+ color: $secondary_1;
+ opacity: 0.4;
+ transition: all 0.2s ease-in-out;
+
+ .closed-sidebar & {
+ transform: rotate(180deg);
+ }
+ }
+
+ &:hover {
+ width: 2.125rem;
+
+ i {
+ opacity: 1;
+ }
+ }
+}
+
+.sidebar-menu {
+ margin-top: 3.75rem;
+ padding-left: 0.5rem;
+ max-height: calc(100% - 280px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ color: $secondary_1;
+
+ @include media-breakpoint-down(md) {
+ margin-top: 0;
+ max-height: calc(100% - 230px);
+ }
+
+ ul {
+ list-style: none;
+ padding: 0;
+
+ li {
+ font-size: 0.875rem;
+ margin: 0.5rem 0;
+ width: 200px;
+ position: relative;
+ border-radius: 4px;
+
+ a {
+ transition: all 0.2s ease-in-out;
+ padding: 0.8rem 1rem 0.8rem 3rem;
+ z-index: 10;
+ position: relative;
+ display: block;
+ width: 100%;
+
+ .closed-sidebar & {
+ opacity: 0;
+
+ @include media-breakpoint-down(md) {
+ opacity: 1;
+ }
+ }
+ }
+
+ &:hover {
+ background-color: $secondary_2;
+ }
+
+ &.current {
+ background-color: $secondary_2;
+ }
+
+ i {
+ + span.menu-ntf-count:not(:empty) {
+ position: absolute;
+ background: $primary_0;
+ min-width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ font-size: 0.625rem;
+ font-weight: 600;
+ line-height: 1.6;
+ top: 0.5rem;
+ left: 0.625rem;
+ border: 1px solid rgba(37, 45, 64, 0.12);
+ padding-right: 0.0625rem;
+ text-align: center;
+ font-style: normal;
+ }
+ }
+
+ i::before {
+ content: '';
+ width: 1.5rem;
+ height: 1.5rem;
+ background-repeat: no-repeat;
+ position: absolute;
+ top: 0.6875rem;
+ left: 1rem;
+ }
+
+ i.icon-dashboard::before {
+ @extend %icon-dashboard;
+ }
+
+ i.icon-bookmarks::before {
+ @extend %icon-bookmarks;
+ }
+
+ i.icon-conversations::before {
+ @extend %icon-conversations;
+ }
+
+ i.icon-notifications::before {
+ @extend %icon-notifications;
+ }
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ }
+ }
+
+ li.has-submenu {
+ > div {
+ padding: 0.625rem 0 0.625rem 2.875rem;
+ cursor: pointer;
+
+ span:not(.menu-ntf-count) {
+ transition: all 0.2s ease-in-out;
+ pointer-events: none;
+
+ .closed-sidebar & {
+ opacity: 0;
+
+ @include media-breakpoint-down(md) {
+ opacity: 1;
+ }
+ }
+ }
+
+ span.submenu-toggle-icn {
+ background: transparent;
+ display: block;
+ width: 1.875rem;
+ height: 1.875rem;
+ position: absolute;
+ right: 0;
+ top: 0.375rem;
+ z-index: 10;
+ pointer-events: all;
+ }
+
+ + ul.menu_level_1 {
+ li > div > i {
+ &::before {
+ display: none;
+ }
+ }
+ }
+ }
+
+ li {
+ width: 100%;
+
+ > div {
+ padding-left: 3rem;
+ }
+
+ > ul {
+ padding-left: 3rem;
+ }
+ }
+ > div > i::before {
+ content: '';
+ width: 1.5rem;
+ height: 1.5rem;
+ background-repeat: no-repeat;
+ display: inline-block;
+ position: absolute;
+ top: 0.5625rem;
+ left: 1rem;
+ }
+
+ ul.menu_level_1 {
+ padding: 0 0 0.3125rem 0;
+ background-color: $secondary_2;
+ width: 100%;
+ display: none;
+
+ a {
+ width: 90%;
+ border-radius: 4px;
+ padding: 0.125rem 0.25rem;
+ margin: 0.125rem;
+
+ &:hover {
+ background: $light-bg;
+ }
+ }
+
+ &.submenu-open {
+ display: block;
+ }
+
+ .closed-sidebar & {
+ opacity: 0;
+
+ @include media-breakpoint-down(md) {
+ opacity: 1;
+ }
+ }
+
+ &.active {
+ display: block;
+ }
+ }
+
+ &::after {
+ font-family: 'Font Awesome 5 Free';
+ content: '\f078';
+ display: inline-block;
+ width: 1.5rem;
+ height: 1.5rem;
+ color: inherit;
+ position: absolute;
+ font-weight: 600;
+ right: 0.125rem;
+ top: 0.75rem;
+ text-align: center;
+ transition: all 0.2s ease-in-out;
+ transform-origin: 50% 40%;
+ cursor: pointer;
+ }
+
+ &.active {
+ background-color: $secondary_2;
+
+ &::after {
+ transform: rotateX(180deg);
+ }
+
+ .closed-sidebar & {
+ background-color: transparent;
+
+ @include media-breakpoint-down(md) {
+ background-color: $secondary_2;
+ }
+ }
+ }
+ }
+
+ li:not(.has-submenu) {
+ > div > i:before {
+ top: 50%;
+ transform: translateY(-50%);
+ }
+ }
+
+ li.current_ancestor.has-submenu {
+ background-color: $secondary_2;
+ > div,
+ div > span {
+ pointer-events: none;
+ }
+
+ .menu_level_1 {
+ display: block;
+
+ .current a {
+ background: $gray;
+ font-weight: 600;
+ }
+ }
+ }
+ }
+}
+
+.sidebar-bottom {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ border-top: 2px solid $secondary_2;
+ padding: 1.625rem 1.625rem 1.625rem 0.5rem;
+
+ @include media-breakpoint-down(md) {
+ bottom: 4.25rem;
+ }
+
+ .avatar {
+ height: 40px;
+ min-height: 40px;
+ width: 40px;
+ min-width: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+ margin-right: 8px;
+ }
+
+ ul {
+ list-style: none;
+ padding: 0;
+
+ li {
+ font-size: 0.875rem;
+ margin: 0.5rem 0;
+ width: 200px;
+ position: relative;
+ border-radius: 4px;
+
+ a {
+ transition: all 0.2s ease-in-out;
+ padding: 0.8rem 1rem 0.8rem 3.5rem;
+ z-index: 10;
+ position: relative;
+ display: block;
+ width: 100%;
+
+ .closed-sidebar & {
+ opacity: 0;
+
+ @include media-breakpoint-down(md) {
+ opacity: 1;
+ }
+ }
+ }
+
+ &:hover {
+ background-color: $secondary_2;
+ }
+
+ i {
+ font-style: normal;
+
+ &:before {
+ content: '';
+ width: 1.5rem;
+ height: 1.5rem;
+ background-repeat: no-repeat;
+ position: absolute;
+ top: 0.6875rem;
+ left: 1rem;
+
+ }
+ }
+ }
+ }
+
+ .user-settings {
+ display: flex;
+ align-items: center;
+ margin-left: 0.5rem;
+ user-select: none;
+ cursor: pointer;
+
+ i span {
+ margin-right: 0.5rem;
+ background: $secondary_2;
+ border-radius: 100%;
+ width: 2.5rem;
+ height: 2.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.625rem;
+ font-weight: 800;
+ }
+
+ a {
+ padding: 0;
+ }
+ }
+
+ .user-settings-menu-wrap {
+ position: relative;
+
+ .user-settings {
+ > div {
+ width: 100%;
+ transition: all 0.2s ease-in-out;
+ padding-right: 4px;
+
+ @include media-breakpoint-up(md) {
+ .closed-sidebar & {
+ opacity: 0;
+ }
+ }
+ }
+
+ &::after {
+ font-family: 'Font Awesome 5 Free';
+ content: '\f078';
+ display: inline-block;
+ width: 1rem;
+ height: 1rem;
+ color: inherit;
+ font-weight: 600;
+ transform: rotate(-90deg);
+ margin-right: 0.5rem;
+ }
+ }
+ }
+
+ .user-settings-menu {
+ display: none;
+ position: absolute;
+ box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.1);
+ border-radius: 2px;
+ bottom: 7.5rem;
+ z-index: 9999;
+ background-color: $white;
+ color: $secondary_1;
+ left: 0.25rem;
+ max-width: 240px;
+
+ &__header {
+ padding: 2rem 1.5rem;
+ text-align: center;
+ img, .user-icon {
+ height: 3.5rem;
+ width: 3.5rem;
+ border-radius: 50%;
+ object-fit: cover;
+ margin: 0 auto 1.5rem;
+ }
+
+ .user-icon {
+ background-color: $primary_2;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ &::before {
+ content: url('../assets/icons/icon_user.svg');
+ }
+ }
+
+ h6 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ }
+ }
+
+ &__body {
+ border-top: 2px solid $secondary_2;
+ border-bottom: 2px solid $secondary_2;
+
+ ul {
+ padding: 1rem 1.5rem;
+ margin-bottom: 0;
+
+ li {
+ position: relative;
+ margin: 0.6rem 0;
+ a {
+ padding: 0;
+
+ &::after {
+ font-family: 'Font Awesome 5 Free';
+ content: '\f078';
+ display: inline-block;
+ width: 1.5rem;
+ height: 1.5rem;
+ color: inherit;
+ position: absolute;
+ font-weight: 600;
+ right: 0;
+ top: -2px;
+ text-align: center;
+ cursor: pointer;
+ transform: rotate(-90deg);
+ }
+ }
+
+ &:hover {
+ background: none;
+
+ a {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+ }
+
+ &__footer {
+ padding: 1rem 1.5rem;
+ a {
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.875rem;
+
+ &::after {
+ content: "";
+ @extend %icon-logout;
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+ }
+
+ &--visible {
+ display: block;
+ }
+ }
+}
diff --git a/assets/sass/backoffice/_spinner.scss b/assets/sass/backoffice/_spinner.scss
new file mode 100644
index 000000000..326b3260a
--- /dev/null
+++ b/assets/sass/backoffice/_spinner.scss
@@ -0,0 +1,24 @@
+.spinner {
+ display: none;
+ &.show {
+ display: block;
+ }
+
+ position: absolute;
+ margin: auto;
+ left: 0;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 20px;
+ height: 20px;
+ border: 3px solid $secondary_2;
+ border-top: 3px solid $secondary_0;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
diff --git a/assets/sass/backoffice/_top_navigation.scss b/assets/sass/backoffice/_top_navigation.scss
new file mode 100644
index 000000000..d78328d06
--- /dev/null
+++ b/assets/sass/backoffice/_top_navigation.scss
@@ -0,0 +1,59 @@
+.top-navigation {
+ height: 60px;
+ border-bottom: 2px solid $secondary_2;
+
+ ul {
+ list-style-type: none;
+ padding: 0;
+ display: flex;
+ }
+
+ &-btn {
+ height: 60px;
+
+ @media (max-width: 768px) {
+ flex: 1;
+ }
+
+ & > div {
+ height: 100%;
+ }
+
+ a {
+ display: flex;
+ align-items: center;
+ color: rgba(37, 45, 64, 0.38);
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 140%;
+ padding: 0rem 1rem;
+ height: 100%;
+ border-bottom: 2px solid transparent;
+
+ cursor: pointer;
+
+ @media (max-width: 768px) {
+ text-align: center;
+ padding: 0 0.5rem;
+ justify-content: center;
+ }
+
+ @media (max-width: 360px) {
+ padding: 0 0.125rem;
+ }
+
+ &:hover {
+ background-color: $secondary_4;
+ border-color: $secondary_4;
+ color: $darkgray;
+ }
+ }
+
+ &--active {
+ a {
+ color: $secondary_4;
+ border-color: $secondary_4;
+ }
+ }
+ }
+}
diff --git a/assets/sass/backoffice/_user_impersonation.scss b/assets/sass/backoffice/_user_impersonation.scss
new file mode 100644
index 000000000..f2d3cdc54
--- /dev/null
+++ b/assets/sass/backoffice/_user_impersonation.scss
@@ -0,0 +1,95 @@
+.ng-user-impersonation-app {
+ .icon {
+ margin-right: 0.75rem;
+ width: 2rem;
+ height: 2rem;
+
+ &.icon-small{
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+
+ .btn-primary {
+ svg {
+ fill: $white;
+ }
+ }
+}
+
+.ng-user-impersonation-content {
+ .container {
+ max-width: 100%;
+ }
+
+ .users-filter {
+ h3 {
+ font-size: 1.25rem;
+ margin-bottom: 24px;
+ }
+ }
+
+ form {
+ margin: 16px 0;
+
+ > div.row {
+ flex-direction: column;
+ font-size: 0.875rem;
+
+ label {
+ padding-bottom: 8px;
+ padding-top: 0;
+ width: auto;
+ }
+
+ input.form-control,
+ select.form-select {
+ padding-top: 0.4rem;
+ padding-bottom: 0.4rem;
+ border-radius: 4px;
+ }
+ }
+
+ div.col-xs-12 {
+ display: flex;
+
+ a {
+ margin-right: 16px;
+ }
+
+ @include media-breakpoint-down(md) {
+ flex-direction: column;
+
+ a {
+ margin-bottom: 16px;
+ margin-right: 0;
+ }
+ }
+ }
+ }
+
+ .users-list {
+ font-size: 0.875rem;
+ margin-top: 32px;
+
+ .section-title {
+ .title {
+ font-size: 20px;
+ margin-bottom: 20px;
+ color: $secondary_4;
+ }
+ }
+
+ .table {
+ tr {
+ td {
+ vertical-align: middle;
+ }
+
+ ul {
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+}
diff --git a/assets/sass/backoffice/_user_profile.scss b/assets/sass/backoffice/_user_profile.scss
new file mode 100644
index 000000000..66008f1f1
--- /dev/null
+++ b/assets/sass/backoffice/_user_profile.scss
@@ -0,0 +1,35 @@
+.user-profile {
+ padding: 1rem;
+ .user-profile-person-image {
+ width: 8.375rem;
+ height: 8.375rem;
+ margin-bottom: 1.25rem;
+ overflow: hidden;
+ position: relative;
+ img {
+ position: absolute;
+ object-fit: cover;
+ object-position: center;
+ width: 100%;
+ height: 100%;
+ }
+ }
+ .user-profile-person-details {
+ h2 {
+ font-size: 1.125rem;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+ }
+ dl {
+ dt {
+ font-weight: 400;
+ font-size: 0.875rem;
+ color: rgba(37, 45, 64, 0.66);
+ }
+ dd {
+ font-weight: 600;
+ font-size: 0.875rem;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/assets/sass/backoffice/_variables.scss b/assets/sass/backoffice/_variables.scss
new file mode 100644
index 000000000..c0d2e7962
--- /dev/null
+++ b/assets/sass/backoffice/_variables.scss
@@ -0,0 +1,58 @@
+// dimensions
+$impersonation-bar-height: 3rem;
+$chat-header-height: 5rem;
+$search-filter-max-width: 31.625rem;
+
+$gutter: .9375rem;
+
+$badge-green-bg: #219653;
+$badge-green-text: #0E6C36;
+$badge-red-bg: #F2994A;
+$badge-red-text: #98561B;
+
+// colours
+$primary_0: #FFCD00;
+$primary_1: #346094;
+$primary_2: #BBC6D6;
+$primary: #FED82F;
+$secondary: #F8F9FC;
+$secondary_0: #00396F;
+$secondary_1: #252D40;
+$secondary_2: #F3F3F3;
+$secondary_3: #F9F1DE;
+$secondary_4: #A31F34;
+$border: #E8E8E8;
+$border-hover: #ACAFB6;
+$light-bg: #FAFAFA;
+$border_2: #F1F2F3;
+$gray: #E5E5E5;
+$gray-54: hsl(0, 0, 46);
+$gray-64: hsl(0, 0, 36);
+$message: #2970EF;
+$success-green: #4BC17C;
+$darkgray: rgba(37, 45, 64, 0.66);
+$bordeaux: #8B1A2C;
+$olive: #256540;
+$olive-dark: #663939;
+$accept: #BDF4D4;
+$decline: #FFC6C6;
+$placeholder: #757575;
+$event: #98561B;
+$event-bg: #F2994A;
+$flash-ntf: #DBF3E5;
+$flash-ntf-text: #187B42;
+$red: #D93B3B;
+$highlight: #FBF1C8;
+$black: hsl(0, 0, 0);
+$orange: #ffa500;
+$white: #fffffF;
+
+$footer-bg: $black;
+
+//filter colours
+$filtered-colours: (
+ primary: brightness(0) saturate(100%) invert(87%) sepia(98%) saturate(2775%) hue-rotate(328deg) brightness(102%) contrast(99%),
+ secondary: brightness(0) saturate(100%) invert(100%) sepia(26%) saturate(567%) hue-rotate(178deg) brightness(100%) contrast(98%),
+ white: brightness(0) saturate(100%) invert(92%) sepia(93%) saturate(32%) hue-rotate(251deg) brightness(107%) contrast(100%),
+ black: brightness(0) saturate(100%) invert(0%) sepia(9%) saturate(7500%) hue-rotate(154deg) brightness(91%) contrast(108%),
+);
diff --git a/assets/sass/backoffice/style.scss b/assets/sass/backoffice/style.scss
new file mode 100644
index 000000000..c443ab81e
--- /dev/null
+++ b/assets/sass/backoffice/style.scss
@@ -0,0 +1,39 @@
+@charset "UTF-8";
+// IMPORTANT: only add imports to this file
+
+// External libraries
+@import '@fortawesome/fontawesome-free/css/all';
+
+@import 'variables';
+
+// Bootstrap setup
+@import 'bootstrap/scss/functions';
+@import '../bootstrap_import/bootstrap_variables';
+@import '../bootstrap_import/bootstrap';
+
+@import "flatpickr";
+
+// Mixins
+@import '../mixins/mixins';
+@import '../typography';
+
+@import '../bootstrap_import/bootstrap_override';
+
+@import 'icons';
+@import 'buttons';
+@import 'global';
+@import 'header';
+@import 'dashboard';
+@import 'sidebar';
+@import 'main';
+@import 'modal';
+@import 'top_navigation';
+@import 'spinner';
+@import 'my_account';
+@import 'user_profile';
+@import 'global_styles';
+@import 'filter_form';
+@import 'listing';
+@import 'user_impersonation';
+@import 'print';
+
diff --git a/assets/sass/layout/_navigation.scss b/assets/sass/layout/_navigation.scss
index cd73948a3..c068f095b 100644
--- a/assets/sass/layout/_navigation.scss
+++ b/assets/sass/layout/_navigation.scss
@@ -1,43 +1,109 @@
.main-navigation {
flex: 1;
+ position: relative;
+ margin-left: 2rem;
+
+ @include media-breakpoint-down(xl) {
+ margin-left: 0;
+ }
+
.navbar-nav {
display: flex;
flex-direction: row;
- justify-content: center;
list-style-type: none;
margin: 0;
padding: 0 1rem;
- text-align: center;
> li {
+ display: flex;
+ align-items: center;
+ padding: 0 1.3333333333em;
position: relative;
+ cursor: pointer;
+ @extend %hover-underline;
+
+ @media (max-width: 1300px) {
+ padding: 0 1rem;
+ }
+
+ @include media-breakpoint-down(xl) {
+ padding: 0;
+ }
+
> a,
> span {
- @extend %hover-underline;
- @include text-link;
+ @include text-1_125;
+ font-weight: 600;
display: inline-block;
- padding: 0 1.3333333333em;
+
+ @include media-breakpoint-down(sm) {
+ font-size: 1rem;
+ }
+ }
+
+ &.submenu-active {
+ .menu_level_1 {
+ display: block;
+ }
+ .submenu-trigger {
+ transform: rotate(180deg);
+ }
}
}
}
/* main submenu */
.menu_level_1 {
list-style-type: none;
- padding: 1rem 0;
+ padding: 0.875rem 0;
margin: 0;
display: none;
+ cursor: auto;
+
+ li {
+ width: 100%;
+
+ &:not([id*="menu-item"]) {
+ display: flex;
+ flex-direction: column;
+
+ > span {
+ @include text-0_875;
+ border-top: 2px solid rgba($black, 0.1);
+ margin-top: 0.75rem;
+ color: $darkgray;
+ }
+ }
+ }
+
a {
display: block;
- padding: .5em 1.5em;
+ padding: 0.625rem 2rem;
+ width: max-content;
+ min-width: 100%;
+ }
+
+ span {
+ &:not(.user-icon) {
+ display: inline-block;
+ padding: 1.25rem 0 0.75rem;
+ margin: 0 2rem;
+ }
}
}
+
+ .menu_level_2 {
+ list-style-type: none;
+ padding: 0;
+ }
+
/* large screen sizes */
- @include media-breakpoint-up(lg) {
+ @include media-breakpoint-up(xl) {
+ display: flex;
+
.navbar-nav {
> li:hover,
.active {
> a,
> span {
- color: $black;
&::after {
transform: scaleY(1);
}
@@ -63,7 +129,6 @@
position: absolute;
top: 100%;
left: 0;
- width: 16rem;
z-index: 20;
background: $primary;
box-shadow: 0 .5rem 1rem hsla(0, 0, 0, .12);
@@ -75,25 +140,60 @@
}
}
}
+
.submenu-trigger {
- // NOTE: Removed fontawesome extend rule since the nav will we rewritten
- display: none;
+ margin-left: 1.375rem;
+ line-height: 0;
+
+ @media (max-width: 1300px) {
+ margin-left: 0.75rem;
+ }
+
+ &::before {
+ content: url('../assets/icons/icon_arrow_down.svg');
+
+ .inverted & {
+ @include media-breakpoint-up(xl) {
+ @include icon-color(secondary_1);
+ }
+ }
+ }
}
/* small screen sizes */
- @include media-breakpoint-down(lg) {
- display: none;
+ @include media-breakpoint-down(xl) {
+ transform: translateX(100%);
+ pointer-events: none;
+ transition: transform 300ms;
position: absolute;
- left: calc(var(--bs-gutter-x) * .5);
- right: calc(var(--bs-gutter-x) * .5);
- top: $header-height-sm;
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - #{$header-height-sm});
+ overflow-y: scroll;
+ left: 0;
+ right: 0;
+ top: calc(#{$header-height-sm} + 2px);
z-index: 1000;
- background: $primary;
+ background: $secondary_0;
box-shadow: 0 .5rem 1rem hsla(0, 0, 0, .12);
+ padding-bottom: 1.5rem;
.navbar-nav {
display: block;
margin: 0;
- padding: 1rem 0;
+ padding: 0;
> li {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ margin: 0.75rem 1.5rem;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
> a,
> span {
padding: .5em 1em;
@@ -107,20 +207,15 @@
margin: 0 1.875rem;
}
}
- &.submenu-active {
- .menu_level_1 {
- display: block;
- }
- .submenu-trigger {
- &::before {
- transform: rotate(180deg);
- }
- }
+
+ &::after {
+ display: none;
}
}
}
.mainnav-active & {
- display: block;
+ transform: translateX(0);
+ pointer-events: all;
}
.submenu-trigger {
display: inline-block;
@@ -128,31 +223,41 @@
right: 0;
top: 0;
cursor: pointer;
- width: 1.875rem;
- height: 1.875rem;
line-height: 1.875rem;
@include text-0_875;
&::before {
display: block;
- content: '\f078';
}
}
.menu_level_1 {
- padding: 0 0 1rem;
+ width: 100%;
+ padding: 0;
+ background-color: $primary_0;
+ margin-top: 0.5rem;
+ border-radius: 2px;
+
a {
- color: $gray-54;
- @include text-0_875;
+ @include text-1_125;
+ font-weight: 600;
+ padding-inline: 1.5rem;
+
+ @include media-breakpoint-down(sm) {
+ font-size: 1rem;
+ line-height: 1.15;
+ }
+ }
+
+ span {
+ &:not(.user-icon) {
+ margin: 0 1.5rem;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ padding: 0.5rem 0;
}
}
}
- @include media-breakpoint-down(lg) {
- overflow: scroll;
- position: absolute;
- z-index: 1000;
- left: 0;
- right: 0;
- height: calc(100vh - #{$header-height-sm});
- }
@include media-breakpoint-down(xs) {
left: 0;
right: 0;
@@ -160,7 +265,6 @@
}
.mainnav-toggle {
- @extend %hover-underline;
display: none;
position: relative;
width: $header-height-sm;
@@ -196,7 +300,6 @@
color: inherit;
}
.mainnav-active & {
- background: $primary;
.hamburger {
background: transparent;
transition: background 100ms ease-out;
@@ -208,21 +311,218 @@
}
}
}
- @include media-breakpoint-down(lg) {
+
+ .inverted & {
+ @include media-breakpoint-up(xl) {
+ .hamburger {
+ color: $secondary_1;
+ }
+ }
+ }
+
+ @include media-breakpoint-down(xl) {
display: block;
+ margin-right: -1.5rem;
}
}
+.user-menu {
+ display: flex;
+ border-left: 2px solid rgba(249, 241, 222, 0.08);
+ flex-direction: row;
+ position: relative;
-.mainnav-active {
- @include media-breakpoint-down(lg) {
- overflow-y: hidden;
- position: fixed;
- width: 100%;
- body {
- overflow-y: hidden;
- position: fixed;
- width: 100%;
+ @include media-breakpoint-down(xl) {
+ flex-direction: column;
+ border-left: none;
+ border-top: 2px solid rgba(0, 0, 0, 0.08);
+ margin: 1.5rem;
+ padding: 1.5rem 0;
+ }
+
+ &.submenu-active {
+ .user-dropdown-menu {
+ display: flex;
+ }
+ }
+
+ .submenu-trigger {
+ display: none;
+ }
+
+ .user-menu-cta {
+ display: flex;
+ align-items: center;
+ position: relative;
+ //color: $secondary_3;
+ @include text-base;
+ font-weight: 600;
+ padding-inline: 2rem;
+ @extend %hover-underline;
+
+ @include media-breakpoint-down(xl) {
+ padding-inline: 0;
+ }
+
+ &.prevent-user-menu-cta {
+ @include media-breakpoint-down(xl) {
+ display: none;
+ }
+ }
+
+ .arrow-icon {
+ &::after {
+ content: url('../assets/icons/icon_arrow_right_small.svg');
+ @include icon-color(secondary_3);
+ margin-left: 0.625rem;
+
+ .inverted & {
+ @include media-breakpoint-up(xl) {
+ @include icon-color(secondary_1);
+ }
+ }
+ }
+
+ }
+
+ .inverted & {
+ @include media-breakpoint-up(xl) {
+ color: $secondary_1;
+ }
+ }
+
+ .user-icon {
+ margin: 0 0.5rem 0 0;
+ width: 1.875rem;
+ height: 1.875rem;
+ background-color: $secondary_3;
+ font-size: 0;
+ }
+ }
+
+ .user-dropdown-menu {
+ display: none;
+ position: absolute;
+ top: 100%;
+ right: 0;
+ left: revert;
+ flex-direction: column;
+ align-items: center;
+ padding: 2rem 1.5rem 2.5rem;
+ border: none;
+ box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.1);
+ width: max-content;
+
+ @include media-breakpoint-down(xl) {
+ display: flex;
+ position: relative;
+ top: revert;
+ right: revert;
+ box-shadow: none;
+ align-items: flex-start;
+ padding: 0;
+ }
+
+ &.menu_level_1 {
+ @include media-breakpoint-down(xl) {
+ background-color: revert;
+ margin-top: 0;
+ }
+ }
+ }
+
+ .user-icon {
+ width: 3.5rem;
+ height: 3.5rem;
+ border-radius: 50%;
+ background-color: $primary_2;
+ display: flex;
+ justify-content: center;
+ object-fit: cover;
+ align-items: center;
+ margin-bottom: 24px;
+
+ &--main {
+ @include media-breakpoint-down(xl) {
+ display: none;
+ }
+ }
+
+ &::before {
+ content: url('../assets/icons/icon_user.svg');
+ }
+ }
+
+ .user-menu-title {
+ @include text-1_25;
+ font-weight: 600;
+ margin-bottom: 1rem;
+
+ @include media-breakpoint-down(xl) {
+ display: none;
+ }
+ }
+
+ a.btn-user {
+ @include text-1_125;
+ font-weight: 600;
+ color: $secondary_4;
+ display: flex;
+ align-items: center;
+
+ @include media-breakpoint-down(xl) {
+ justify-content: flex-start;
+ font-size: 1rem;
+ font-weight: 600;
+ color: $secondary_3;
+ }
+
+ &::after {
+ content: url('../assets/icons/icon_caret_right.svg');
+ margin-left: 0.625rem;
+ width: 1.5625rem;
+ height: 1.5625rem;
+ @include icon-color(secondary_4);
+
+ @include media-breakpoint-down(xl) {
+ @include icon-color(secondary_3);
+ }
+ }
+
+ & + .btn-user {
+ margin-top: 24px;
+ }
+
+ &:not(.btn.btn-link) {
+ @include media-breakpoint-down(xl) {
+ width: fit-content;
+ padding: 0;
+ }
+ }
+
+ .user-icon {
+ display: none;
+
+ @include media-breakpoint-down(xl) {
+ display: inline-flex;
+ width: 2rem;
+ height: 2rem;
+ margin: 0 0.5rem 0 0;
+ background-color: $white;
+ }
+
+ &::before {
+ @include media-breakpoint-down(xl) {
+ transform: scale(0.9);
+ }
+ }
+ }
+
+ &-register {
+ @include media-breakpoint-down(xl) {
+ display: none;
+ pointer-events: none;
+ }
}
}
}
diff --git a/assets/sass/layout/_print.scss b/assets/sass/layout/_print.scss
new file mode 100644
index 000000000..d58c8957c
--- /dev/null
+++ b/assets/sass/layout/_print.scss
@@ -0,0 +1,247 @@
+.print-icon {
+ // position: fixed;
+ // bottom: 40%;
+ // right: 0;
+ width: 2.5rem;
+ height: 2.5rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: $secondary_0;
+ color: $white;
+ cursor: pointer;
+ transform: translateX(0);
+ transition: transform 300ms ease-in;
+ &:hover, &:focus {
+ opacity: 0.8;
+ color: $white;
+ }
+ &.hide {
+ transform: translateX(100%);
+ }
+
+ @include media-breakpoint-down(sm) {
+ display: none !important;
+ }
+}
+
+@media print {
+ .site-header {
+ display: none !important;
+ }
+ .mainnav-toggle,
+ .nav.navbar-nav {
+ display: none !important;
+ }
+ .breadcrumb-wrapper {
+ display: none !important;
+ }
+ footer {
+ display: none !important;
+ }
+
+ .print-icon {
+ display: none !important;
+ }
+
+ div[class^="video"] {
+ display: none !important;
+ }
+
+ html,
+ body {
+ font-size: 10px !important;
+ width: 210mm !important;
+ margin: 0px;
+ background-color: transparent;
+ }
+
+ #page {
+ padding-top: 0;
+ }
+
+ .full-page-image {
+ transform: translate(0) !important;
+ }
+
+ .btn:not(.btn-download-link) {
+ display: none;
+ }
+
+ .list-group-item{
+ border: none;
+ border-radius: 0;
+ }
+
+ .full-page-related-grid {
+ break-inside: avoid;
+ }
+
+ .ngl-block[class*="bg-color-gray"],
+ .ngl-block.bg-color-primary,
+ .ngl-block.bg-color-secondary {
+ background-color: transparent;
+ color: revert;
+ &:before,
+ &:after {
+ content: none;
+ }
+ }
+
+ .zone.zone-main > .ngl-block:not(.ngl-full_view):not(.ngl-vt-slider),
+ .zone.zone-post_header > .ngl-block:not(.ngl-full_view):not(.ngl-vt-slider),
+ .scroll-animation-trigger .full-page-body > .ezxmltext-field > *,
+ .scroll-animation-trigger .animate-full-page-header {
+ opacity: 1 !important;
+ transform: translate(0px, 0px) !important;
+ background-color: $white !important;
+ }
+
+
+ .view-type-standard.ng-article.vl1 {
+ opacity: 1 !important;
+ transform: translate(0px, 0px) !important;
+ }
+
+
+ .ngl-list {
+ &:not(&.ngl-vt-grid_uneven) {
+ .col-sm-6 {
+ flex: 0 0 33.33333% !important;
+ max-width: 33.33333% !important;
+ }
+
+ }
+ }
+
+ .ng-video .article-icon {
+ background: transparent !important;
+ }
+
+ figure, .image {
+ break-inside: avoid;
+ }
+
+ .break-before {
+ padding-top: 10px;
+ break-before: page;
+ }
+
+ .break-after {
+ break-after: page;
+ padding-bottom: 10px;
+ }
+
+ .break-inside-avoid {
+ break-inside: avoid;
+ }
+
+ .print-hidden {
+ display: none;
+ }
+
+ .scroll-animation-trigger {
+ position: static !important;
+ }
+
+ .container-wide,
+ .container,
+ .container-narrow {
+ max-width: 100%;
+ padding-left: var(--header-gutter) !important;
+ padding-right: var(--header-gutter) !important;
+ }
+
+ .table-bordered th, .table-bordered td {
+ border: none !important;
+ }
+
+ .vl9 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 4rem;
+ margin-bottom: 1rem;
+
+ &.even {
+ .article-content {
+ order: -1;
+ }
+ }
+ }
+
+ .vl10 {
+ background-color: transparent;
+ padding: 0;
+ }
+
+ .vl11 {
+ display: grid;
+
+ &.view-type-zigzag_blocks {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .article-content {
+ background-color: transparent;
+ }
+ }
+
+ .ngl-block.bg-color-gray_with_logo_left {
+ &::before {
+ display: none;
+ }
+ }
+
+ .page-floating-widgets {
+ display: none;
+ }
+
+ .social-share-wrapper {
+ display: none;
+ }
+
+ .layouts-filter-block-form {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ max-width: 50%;
+ }
+
+ .ngl-vt-sushi_bar {
+ .swiper-slide {
+ opacity: 1;
+ }
+
+ .swiper-wrapper {
+ flex-direction: column;
+ }
+ }
+
+ .collapse:not(.show) {
+ display: block;
+ }
+
+ .tab-content > .tab-pane {
+ display: block;
+ opacity: 1;
+ }
+
+ .full-page-accordions {
+ background: transparent;
+ }
+
+ .row {
+ --bs-gutter-x: 0;
+ }
+
+ @page {
+ size: A4;
+ width: 210mm;
+ height: 297mm;
+ padding: 1cm;
+ margin: 25mm 25mm 25mm 25mm;
+ border: 1px #D3D3D3 solid;
+ border-radius: 0px;
+ background: white;
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
+ }
+}
diff --git a/assets/sass/style.scss b/assets/sass/style.scss
index 6f526b014..b885dfe82 100644
--- a/assets/sass/style.scss
+++ b/assets/sass/style.scss
@@ -98,3 +98,15 @@
// Accessibility
@import 'accessibility';
+
+// Page floating widgets
+@import 'page_floating_widgets';
+
+// print
+@import 'layout/print';
+
+// Subscription widget
+@import 'subscription_widget';
+
+// User impersonation
+@import 'user_impersonation';
diff --git a/captainhook.json b/captainhook.json
new file mode 120000
index 000000000..90b3edb11
--- /dev/null
+++ b/captainhook.json
@@ -0,0 +1 @@
+captainhook.template.json
\ No newline at end of file
diff --git a/captainhook.template.json b/captainhook.template.json
index 3794c1c75..30f5cfe6e 100644
--- a/captainhook.template.json
+++ b/captainhook.template.json
@@ -87,6 +87,6 @@
"actions": []
},
"config": {
- "php-path": "/usr/bin/env php8.1"
+ "php-path": "/usr/bin/env php8.2"
}
}
diff --git a/composer.json b/composer.json
index 3c21538cf..50e452a4f 100644
--- a/composer.json
+++ b/composer.json
@@ -54,7 +54,10 @@
"netgen/layouts-ibexa": "~1.4.0",
"netgen/layouts-ibexa-site-api": "~1.4.0",
"netgen/layouts-ibexa-relation-list-query": "^1.4",
- "netgen/layouts-ibexa-tags-query": "^1.4"
+ "netgen/layouts-ibexa-tags-query": "^1.4",
+ "netgen/conversations": "^1.0",
+ "netgen/notifications": "^1.0",
+ "netgen/ibexa-user-impersonation": "dev-NGSTACK-853"
},
"require-dev": {
"symfony/debug-bundle": "5.4.*",
@@ -74,6 +77,9 @@
"symfony/css-selector": "5.4.*",
"symfony/phpunit-bridge": "5.4.*"
},
+ "repositories": [
+ { "type": "composer", "url": "https://packagist.netgen.biz", "canonical": false }
+ ],
"config": {
"allow-plugins": {
"composer/package-versions-deprecated": true,
@@ -144,5 +150,7 @@
"branch-alias": {
"dev-master": "3.2.x-dev"
}
- }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
}
diff --git a/config/app/app.yaml b/config/app/app.yaml
index 28c57bf4c..3178babce 100644
--- a/config/app/app.yaml
+++ b/config/app/app.yaml
@@ -13,4 +13,6 @@ parameters:
ngsite.default.lazy_loading.enabled: true
+ ngsite.default.token_validity.email_change: 900
+
app.testing.site_domain: '%env(TEST_DOMAIN)%'
diff --git a/config/app/doctrine/backoffice/Bookmark.orm.xml b/config/app/doctrine/backoffice/Bookmark.orm.xml
new file mode 100644
index 000000000..1d99bf0a8
--- /dev/null
+++ b/config/app/doctrine/backoffice/Bookmark.orm.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/app/doctrine/backoffice/SecurityToken.orm.xml b/config/app/doctrine/backoffice/SecurityToken.orm.xml
new file mode 100644
index 000000000..bd3a34256
--- /dev/null
+++ b/config/app/doctrine/backoffice/SecurityToken.orm.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/app/packages/ibexa_siteaccess.yaml b/config/app/packages/ibexa_siteaccess.yaml
index 4792ab925..b76613f43 100644
--- a/config/app/packages/ibexa_siteaccess.yaml
+++ b/config/app/packages/ibexa_siteaccess.yaml
@@ -8,6 +8,7 @@ ibexa:
- fh_eng
- bold_eng
- bold_ger
+ - peak_eng
- '%ngsite.admin_siteaccess_name%'
groups:
admin_group:
@@ -16,11 +17,17 @@ ibexa:
- fh_eng
- bold_eng
- bold_ger
+ - peak_eng
fh_group:
- fh_eng
bold_group:
- bold_eng
- bold_ger
+ peak_group:
+ - peak_eng
+ impersonation_group:
+ - peak_eng
+ - '%ngsite.admin_siteaccess_name%'
system:
admin_group:
design: ngadmin
@@ -48,6 +55,15 @@ ibexa:
location_id: '%ngsite.bold_group.locations.tree_root.id%'
session:
name: eZSESSID
+ peak_group:
+ design: peak
+ translation_siteaccesses:
+ - peak_eng
+ content:
+ tree_root:
+ location_id: '%ngsite.peak_group.locations.tree_root.id%'
+ session:
+ name: eZSESSID
fh_eng:
languages:
- eng-GB
@@ -57,6 +73,9 @@ ibexa:
bold_ger:
languages:
- ger-DE
+ peak_eng:
+ languages:
+ - eng-GB
netgen_layouts:
design_list:
@@ -68,6 +87,11 @@ netgen_layouts:
ibexa_design_engine:
design_list:
+ peak:
+ - peak
+ - app
+ - common
+ - standard
bold:
- bold
- app
diff --git a/config/app/packages/netgen_ibexa_user_impersonation.yaml b/config/app/packages/netgen_ibexa_user_impersonation.yaml
new file mode 100644
index 000000000..53bb7c7e6
--- /dev/null
+++ b/config/app/packages/netgen_ibexa_user_impersonation.yaml
@@ -0,0 +1,8 @@
+parameters:
+ netgen_user_impersonation.default.content_type_identifier.user_groups: ['user_group']
+ netgen_user_impersonation.default.content_type_identifier.users: ['user']
+ netgen_user_impersonation.default.users.root_location_ids: [5]
+ netgen_user_impersonation.default.users.name_search_fields: ['first_name', 'last_name']
+ netgen_user_impersonation.admin.siteaccess_name: '%ngsite.admin_siteaccess_name%'
+ netgen_user_impersonation.default.admin.pagelayout: 'backoffice/user_impersonation/layout.html.twig'
+ netgen_user_impersonation.peak_eng.impersonate.default_target.route: 'backoffice_dashboard_index'
diff --git a/config/app/packages/novactive_ez_seo.yaml b/config/app/packages/novactive_ez_seo.yaml
index 0b7e07318..a8c1c2bdf 100644
--- a/config/app/packages/novactive_ez_seo.yaml
+++ b/config/app/packages/novactive_ez_seo.yaml
@@ -1,5 +1,38 @@
nova_ezseo:
system:
+ peak_group:
+ default_metas:
+ author: "Netgen"
+ copyright: ~
+ generator: "Ibexa DXP"
+ MSSmartTagsPreventParsing: "TRUE"
+ fieldtype_metas_identifier: "metas"
+ fieldtype_metas:
+ title:
+ label: 'Title'
+ default_pattern: ""
+ description:
+ label: 'Description'
+ default_pattern: ""
+ maxLength: 150
+ keywords:
+ label: 'Keywords'
+ default_pattern: ~
+ robots:
+ sitemap:
+ - url: "https://%ngsite.peak_group.site_domain%/sitemap.xml"
+ sitemap_includes:
+ subtrees:
+ - "%ngsite.bold_group.locations.tree_root.id%"
+ contentTypeIdentifiers:
+ - ng_article
+ - ng audio
+ - ng_blog_post
+ - ng_category
+ - ng_frontpage
+ - ng_gallery
+ - ng_job_position
+ - ng_news
bold_group:
default_metas:
author: "Netgen"
diff --git a/config/app/routes.yaml b/config/app/routes.yaml
index e69de29bb..d69a0590a 100644
--- a/config/app/routes.yaml
+++ b/config/app/routes.yaml
@@ -0,0 +1,13 @@
+ngsite_api_bookmarks_create:
+ path: /api/bookmarks/create/{locationId}
+ controller: App\Controller\API\Bookmarks\Create
+ methods: [POST]
+ requirements:
+ locationId: \d+
+
+ngsite_api_bookmarks_delete:
+ path: /api/bookmarks/delete/{locationId}
+ controller: App\Controller\API\Bookmarks\Delete
+ methods: [POST]
+ requirements:
+ locationId: \d+
diff --git a/config/app/routes/backoffice.yaml b/config/app/routes/backoffice.yaml
new file mode 100644
index 000000000..c87232689
--- /dev/null
+++ b/config/app/routes/backoffice.yaml
@@ -0,0 +1,6 @@
+backoffice:
+ resource: backoffice/*
+ prefix: /backoffice
+ trailing_slash_on_root: false
+ defaults:
+ allowed_siteaccess: peak_eng
diff --git a/config/app/routes/backoffice/bookmarks.yaml b/config/app/routes/backoffice/bookmarks.yaml
new file mode 100644
index 000000000..59b3fc270
--- /dev/null
+++ b/config/app/routes/backoffice/bookmarks.yaml
@@ -0,0 +1,11 @@
+backoffice_bookmarks_index:
+ path: /bookmarks
+ controller: App\Backoffice\Controller\Bookmarks\Index
+ methods: [GET]
+
+backoffice_bookmarks_delete:
+ path: /bookmarks/delete/{id}
+ controller: App\Backoffice\Controller\Bookmarks\Delete
+ methods: [GET, POST]
+ requirements:
+ id: \d+
diff --git a/config/app/routes/backoffice/index.yaml b/config/app/routes/backoffice/index.yaml
new file mode 100644
index 000000000..e5bcd77e8
--- /dev/null
+++ b/config/app/routes/backoffice/index.yaml
@@ -0,0 +1,12 @@
+backoffice_index:
+ path: /
+ controller: 'Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction'
+ methods: [GET]
+ defaults:
+ route: backoffice_dashboard_index
+ permanent: true
+
+backoffice_dashboard_index:
+ path: /dashboard
+ controller: App\Backoffice\Controller\Dashboard\Index
+ methods: [GET]
diff --git a/config/app/routes/backoffice/my_account.yaml b/config/app/routes/backoffice/my_account.yaml
new file mode 100644
index 000000000..9cd9e1876
--- /dev/null
+++ b/config/app/routes/backoffice/my_account.yaml
@@ -0,0 +1,22 @@
+backoffice_my_account_index:
+ path: /backoffice/my_account
+ controller: 'Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction'
+ methods: [GET]
+ defaults:
+ route: backoffice_my_account_personal_details
+ permanent: true
+
+backoffice_my_account_personal_details:
+ path: /backoffice/my_account/personal_details
+ controller: App\Backoffice\Controller\MyAccount\PersonalDetails
+ methods: [GET, POST]
+
+backoffice_my_account_user_credentials:
+ path: /backoffice/my_account/user_credentials
+ controller: App\Backoffice\Controller\MyAccount\UserCredentials
+ methods: [GET, POST]
+
+backoffice_my_account_change_email_confirmation:
+ path: /backoffice/my_account/change_email_confirmation/{token}
+ controller: App\Backoffice\Controller\MyAccount\ChangeEmailConfirmation
+ methods: [GET]
diff --git a/config/app/server/dev/app.yaml b/config/app/server/dev/app.yaml
index a428577a1..ee768fa46 100644
--- a/config/app/server/dev/app.yaml
+++ b/config/app/server/dev/app.yaml
@@ -1,12 +1,15 @@
parameters:
ngsite.fh_group.site_domain: localhost
ngsite.bold_group.site_domain: localhost
+ ngsite.peak_group.site_domain: localhost
ngsite.fh_group.locations.site_info.id: 65
ngsite.bold_group.locations.site_info.id: 442
+ ngsite.peak_group.locations.site_info.id: 561
ngsite.fh_group.locations.tree_root.id: 385
ngsite.bold_group.locations.tree_root.id: 386
+ ngsite.peak_group.locations.tree_root.id: 555
ngsite.default.locations.ng_component_hero.id: 407
ngsite.default.locations.ng_component_quote.id: 445
@@ -21,3 +24,16 @@ parameters:
# This is Netgen Stack Demo GTM code: GTM-WMG2VT2
# Replace it with project specific GTM code before going live
ngsite.default.site_settings.google_tag_manager_code: 'GTM-WMG2VT2'
+
+ ngsite.default.pager_limit: 25
+
+ ngsite.default.bookmarks.content_types:
+ - ng_article
+ - ng_category
+ - ng_job_position
+ - ng_landing_page
+ - ng_news
+
+ netgen_user_impersonation.default.excluded_user_groups:
+ - 13 # administrator users
+ - 44 # anonymous users
diff --git a/config/bundles.php b/config/bundles.php
index 7dcc19363..c274408d3 100644
--- a/config/bundles.php
+++ b/config/bundles.php
@@ -82,4 +82,9 @@
Netgen\IbexaFieldTypeEnhancedLinkBundle\NetgenIbexaFieldTypeEnhancedLinkBundle::class => ['all' => true],
Netgen\Bundle\BetterIbexaAdminUIBundle\NetgenBetterIbexaAdminUIBundle::class => ['all' => true],
Netgen\Bundle\ToolbarBundle\NetgenToolbarBundle::class => ['all' => true],
+ Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
+ ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
+ Netgen\Bundle\ConversationsBundle\NetgenConversationsBundle::class => ['all' => true],
+ Netgen\Bundle\NotificationsBundle\NetgenNotificationsBundle::class => ['all' => true],
+ Netgen\IbexaUserImpersonationBundle\NetgenIbexaUserImpersonationBundle::class => ['all' => true],
];
diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml
new file mode 100644
index 000000000..2eb7b4c58
--- /dev/null
+++ b/config/packages/api_platform.yaml
@@ -0,0 +1,7 @@
+api_platform:
+ mapping:
+ paths: ['%kernel.project_dir%/src/Entity']
+ patch_formats:
+ json: ['application/merge-patch+json']
+ swagger:
+ versions: [3]
diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml
index 6f62bef29..342b1a1de 100644
--- a/config/packages/doctrine.yaml
+++ b/config/packages/doctrine.yaml
@@ -16,6 +16,12 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
+ App\Backoffice:
+ is_bundle: false
+ type: xml
+ dir: '%kernel.project_dir%/config/app/doctrine/backoffice'
+ prefix: 'App\Backoffice\Doctrine\Entity'
+ alias: Backoffice
when@test:
doctrine:
diff --git a/config/packages/netgen_notifications.yaml b/config/packages/netgen_notifications.yaml
new file mode 100644
index 000000000..e1d214f54
--- /dev/null
+++ b/config/packages/netgen_notifications.yaml
@@ -0,0 +1,23 @@
+netgen_notifications:
+ categories:
+ default:
+ subscription:
+ enabled: true
+ resolver: netgen_notifications.ibexa.subtree_update_resolver
+ digest_actions:
+ email:
+ type: digest_email
+ parameters:
+ from: '%env(MAIL_SENDER)%'
+ subject: 'Here is what you missed'
+ template: backoffice/notifications/digest.html.twig
+ actions:
+ email:
+ type: email
+ parameters:
+ from: '%env(MAIL_SENDER)%'
+ template: backoffice/notifications/notification.html.twig
+ category_mappings:
+ ibexa:
+ subtree:
+ default: [167]
diff --git a/config/packages/ramsey_uuid_doctrine.yaml b/config/packages/ramsey_uuid_doctrine.yaml
new file mode 100644
index 000000000..cfc3036f9
--- /dev/null
+++ b/config/packages/ramsey_uuid_doctrine.yaml
@@ -0,0 +1,4 @@
+doctrine:
+ dbal:
+ types:
+ uuid: 'Ramsey\Uuid\Doctrine\UuidType'
diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml
new file mode 100644
index 000000000..32c852e05
--- /dev/null
+++ b/config/packages/vich_uploader.yaml
@@ -0,0 +1,8 @@
+vich_uploader:
+ db_driver: orm
+
+ #mappings:
+ # products:
+ # uri_prefix: /images/products
+ # upload_destination: '%kernel.project_dir%/public/images/products'
+ # namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
diff --git a/config/routes/api_platform.yaml b/config/routes/api_platform.yaml
new file mode 100644
index 000000000..38f11cba8
--- /dev/null
+++ b/config/routes/api_platform.yaml
@@ -0,0 +1,4 @@
+api_platform:
+ resource: .
+ type: api_platform
+ prefix: /api
diff --git a/config/routes/netgen_conversations.yaml b/config/routes/netgen_conversations.yaml
new file mode 100644
index 000000000..8ef2c4def
--- /dev/null
+++ b/config/routes/netgen_conversations.yaml
@@ -0,0 +1,4 @@
+netgen_conversations:
+ resource: '@NetgenConversationsBundle/Resources/config/routing.yaml'
+ defaults:
+ ngconversations_pagelayout: 'backoffice/conversations/layout.html.twig'
diff --git a/config/routes/netgen_ibexa_user_impersonation.yaml b/config/routes/netgen_ibexa_user_impersonation.yaml
new file mode 100644
index 000000000..3670e7485
--- /dev/null
+++ b/config/routes/netgen_ibexa_user_impersonation.yaml
@@ -0,0 +1,3 @@
+netgen_ibexa_user_impersonation:
+ resource: "@NetgenIbexaUserImpersonationBundle/Resources/config/routing.yaml"
+ prefix: /user-impersonation
diff --git a/config/routes/netgen_notifications.yaml b/config/routes/netgen_notifications.yaml
new file mode 100644
index 000000000..6de1f186d
--- /dev/null
+++ b/config/routes/netgen_notifications.yaml
@@ -0,0 +1,5 @@
+netgen_notifications:
+ resource: '@NetgenNotificationsBundle/Resources/config/routing.yaml'
+ prefix: '%netgen_notifications.route_prefix%'
+ defaults:
+ ngnotifications_pagelayout: 'backoffice/notifications/layout.html.twig'
diff --git a/package.json b/package.json
index bf4a0ab5b..95d478749 100644
--- a/package.json
+++ b/package.json
@@ -70,9 +70,11 @@
"webpack-notifier": "^1.6.0"
},
"dependencies": {
+ "@fortawesome/fontawesome-free": "^6.5.2",
"@netgen/javascript-cookie-control": "^0.0.8",
"@popperjs/core": "^2.11.6",
- "bootstrap": "~5.2.1",
+ "bootstrap": "^5.3.3",
+ "flatpickr": "^4.6.13",
"js-cookie": "^3.0.5",
"photoswipe": "^5.3.4",
"popper.js": "^1.15.0",
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 8daa2f2e3..104b19f70 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -3,3 +3,5 @@ parameters:
- '#Access to an undefined property Ibexa\\Contracts\\Core\\FieldType\\Value::\$\w+#'
- '#Cannot access property \$\w+ on Ibexa\\Contracts\\Core\\FieldType\\Value\|null#'
- '#Cannot access property \$value on Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Field\|null#'
+ # Disable errors for various "only read/written" properties
+ - '#\$\w+ is never (written|read), only (read|written).#'
diff --git a/src/Attribute/AsMenuBuilder.php b/src/Attribute/AsMenuBuilder.php
new file mode 100644
index 000000000..4c4f80e16
--- /dev/null
+++ b/src/Attribute/AsMenuBuilder.php
@@ -0,0 +1,19 @@
+denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
+
+ $form = $this->createFormBuilder()->getForm();
+
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->repository->remove($bookmark, true);
+
+ return $this->redirectToRoute('backoffice_bookmarks_index');
+ }
+
+ return $this->render(
+ 'backoffice/bookmarks/delete.html.twig',
+ [
+ 'form' => $form->createView(),
+ 'bookmark' => $bookmark,
+ ],
+ );
+ }
+}
diff --git a/src/Backoffice/Controller/Bookmarks/Index.php b/src/Backoffice/Controller/Bookmarks/Index.php
new file mode 100644
index 000000000..ea0d83b31
--- /dev/null
+++ b/src/Backoffice/Controller/Bookmarks/Index.php
@@ -0,0 +1,79 @@
+denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
+
+ $filterForm = $this->createForm(
+ FilterType::class,
+ null,
+ [
+ 'translation_prefix' => 'bookmarks',
+ 'show_date' => true,
+ 'show_search' => true,
+ 'selections' => [
+ 'type' => [
+ 'options' => new ContentType($this->contentTypeService, $this->configResolver),
+ ],
+ ],
+ ],
+ );
+
+ $filterForm->handleRequest($request);
+
+ /** @var \Ibexa\Core\MVC\Symfony\Security\User $sfUser */
+ $sfUser = $this->getUser();
+
+ $adapter = new BookmarksAdapter(
+ $this->repository,
+ $sfUser->getAPIUser()->id,
+ $filterForm->get('selections')->get('type')->getData(),
+ $filterForm->get('searchText')->getData(),
+ $filterForm->get('date')->get('dateFrom')->getData(),
+ $filterForm->get('date')->get('dateTo')->getData(),
+ );
+
+ $bookmarks = new Pagerfanta($adapter);
+ $bookmarks->setNormalizeOutOfRangePages(true);
+ $bookmarks->setMaxPerPage($this->getConfigResolver()->getParameter('pager_limit', 'ngsite'));
+
+ $page = abs($request->query->getInt('page', 1));
+ $bookmarks->setCurrentPage($page > 0 ? $page : 1);
+
+ return $this->render(
+ 'backoffice/bookmarks/index.html.twig',
+ [
+ 'filter_form' => $filterForm->createView(),
+ 'bookmarks' => $bookmarks,
+ ],
+ );
+ }
+}
diff --git a/src/Backoffice/Controller/Dashboard/Index.php b/src/Backoffice/Controller/Dashboard/Index.php
new file mode 100644
index 000000000..be5528751
--- /dev/null
+++ b/src/Backoffice/Controller/Dashboard/Index.php
@@ -0,0 +1,18 @@
+denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
+
+ return $this->render('backoffice/dashboard/index.html.twig');
+ }
+}
diff --git a/src/Backoffice/Controller/MyAccount/ChangeEmailConfirmation.php b/src/Backoffice/Controller/MyAccount/ChangeEmailConfirmation.php
new file mode 100644
index 000000000..23820111a
--- /dev/null
+++ b/src/Backoffice/Controller/MyAccount/ChangeEmailConfirmation.php
@@ -0,0 +1,69 @@
+denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
+
+ /** @var \Ibexa\Core\MVC\Symfony\Security\User $sfUser */
+ $sfUser = $this->getUser();
+ $user = $sfUser->getAPIUser();
+
+ $changeEmailToken = $this->tokenRepository->findOneBy(
+ [
+ 'userId' => $user->id,
+ 'token' => $token,
+ ],
+ );
+
+ if ($changeEmailToken === null) {
+ throw new BadRequestHttpException('Request for e-mail change with given token does not exist.');
+ }
+
+ if ($changeEmailToken->isUsed()) {
+ $this->addFlash('notice', 'my_account.change_email.already_activated');
+
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ }
+
+ if ($changeEmailToken->getExpiryDate() < new DateTimeImmutable()) {
+ $this->addFlash('notice', 'my_account.change_email.token_expired');
+
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ }
+
+ $userUpdateStruct = $this->userService->newUserUpdateStruct();
+ $userUpdateStruct->email = $changeEmailToken->getData();
+ $this->repository->sudo(
+ fn (): User => $this->userService->updateUser($user, $userUpdateStruct),
+ );
+
+ $changeEmailToken->setIsUsed(true);
+ $this->tokenRepository->save($changeEmailToken, true);
+
+ $this->addFlash('success', 'my_account.change_email.successfully_changed');
+
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ }
+}
diff --git a/src/Backoffice/Controller/MyAccount/PersonalDetails.php b/src/Backoffice/Controller/MyAccount/PersonalDetails.php
new file mode 100644
index 000000000..d9458b0cf
--- /dev/null
+++ b/src/Backoffice/Controller/MyAccount/PersonalDetails.php
@@ -0,0 +1,105 @@
+denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
+
+ /** @var \Ibexa\Core\MVC\Symfony\Security\User $sfUser */
+ $sfUser = $this->getUser();
+ $user = $sfUser->getAPIUser();
+
+ $form = $this->createForm(
+ PersonalDetailsType::class,
+ $user,
+ );
+
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var \App\Backoffice\Form\MyAccount\PersonalDetailsData $formData */
+ $formData = $form->getData();
+
+ $updateStruct = $this->userService->newUserUpdateStruct();
+
+ $updateStruct->contentUpdateStruct = $this->contentService->newContentUpdateStruct();
+
+ if ($formData->removeImage) {
+ $updateStruct->contentUpdateStruct->setField('image', null);
+ } elseif ($formData->image !== null) {
+ $updateStruct->contentUpdateStruct->setField('image', $formData->image);
+ }
+
+ $updateStruct->contentUpdateStruct->setField('first_name', $formData->firstName);
+ $updateStruct->contentUpdateStruct->setField('last_name', $formData->lastName);
+
+ $errors = [];
+
+ try {
+ $this->repository->sudo(
+ fn (): User => $this->userService->updateUser(
+ $user,
+ $updateStruct,
+ ),
+ );
+ } catch (ContentFieldValidationException $e) {
+ foreach ($e->getFieldErrors() as $validationErrors) {
+ foreach ($validationErrors as $validationError) {
+ if (is_array($validationError)) {
+ foreach ($validationError as $item) {
+ $errors[] = $item->getTranslatableMessage();
+ }
+ } else {
+ $errors[] = $validationError->getTranslatableMessage();
+ }
+ }
+ }
+
+ return $this->render(
+ 'backoffice/my_account/personal_details.html.twig',
+ [
+ 'form' => $form->createView(),
+ 'errors' => $errors,
+ ],
+ );
+ }
+
+ $this->addFlash(
+ 'success',
+ 'my_account.user_successfully_updated',
+ );
+
+ return $this->redirectToRoute('backoffice_my_account_personal_details');
+ }
+
+ return $this->render(
+ 'backoffice/my_account/personal_details.html.twig',
+ [
+ 'form' => $form->createView(),
+ ],
+ );
+ }
+}
diff --git a/src/Backoffice/Controller/MyAccount/UserCredentials.php b/src/Backoffice/Controller/MyAccount/UserCredentials.php
new file mode 100644
index 000000000..36966955d
--- /dev/null
+++ b/src/Backoffice/Controller/MyAccount/UserCredentials.php
@@ -0,0 +1,148 @@
+denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
+
+ /** @var \Ibexa\Core\MVC\Symfony\Security\User $sfUser */
+ $sfUser = $this->getUser();
+ $user = $sfUser->getAPIUser();
+
+ $emailForm = $this->createForm(ChangeEmailType::class, $user);
+ $emailForm->handleRequest($request);
+
+ if ($emailForm->isSubmitted() && $emailForm->isValid()) {
+ if ($this->tokenRepository->findUserToken($user, SecurityTokenType::EmailChangeConfirmation) !== null) {
+ $this->addFlash('notice', 'my_account.change_email.already_requested');
+
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ }
+
+ /** @var \App\Backoffice\Form\MyAccount\ChangeEmailData $data */
+ $data = $emailForm->getData();
+ $email = $data->email;
+
+ if ($email === null) {
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ }
+
+ if ($email === $user->email) {
+ $this->addFlash('notice', 'my_account.change_email.same_email');
+
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ }
+
+ try {
+ $this->userService->loadUserByEmail($email);
+ $this->addFlash('notice', 'my_account.change_email.email_exists');
+
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ } catch (NotFoundException) {
+ // Do nothing
+ }
+
+ $token = new SecurityToken(
+ $user->id,
+ SecurityTokenType::EmailChangeConfirmation,
+ $this->configResolver->getParameter('ngsite.default.token_validity.email_change', 'ngsite'),
+ $email,
+ );
+
+ try {
+ $this->mailHelper->sendMail(
+ $email,
+ 'mail',
+ 'backoffice/email/change_email_confirmation.html.twig',
+ [
+ 'user' => $user,
+ 'token' => $token->getToken(),
+ ],
+ );
+ } catch (RfcComplianceException) {
+ $this->addFlash('notice', 'my_account.change_email.invalid_email');
+
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ }
+
+ $this->tokenRepository->save($token, true);
+
+ $this->addFlash('success', 'my_account.change_email.activation_mail_sent');
+
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ }
+
+ $passwordForm = $this->createForm(ChangePasswordType::class);
+ $passwordForm->handleRequest($request);
+
+ if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
+ $data = $passwordForm->getData();
+
+ if ($data['password'] !== null) {
+ $userUpdateStruct = $this->userService->newUserUpdateStruct();
+ $userUpdateStruct->password = $data['password'];
+
+ $this->repository->sudo(
+ fn (): User => $this->userService->updateUser($user, $userUpdateStruct),
+ );
+
+ $this->addFlash(
+ 'success',
+ 'my_account.user_successfully_updated',
+ );
+ }
+
+ return $this->redirectToRoute('backoffice_my_account_user_credentials');
+ }
+
+ $currentUserEmail = $user->email;
+ $emailParts = explode('@', $currentUserEmail);
+
+ if (count($emailParts) !== 2) {
+ throw new BadRequestHttpException("User's email address is not valid.");
+ }
+
+ return $this->render(
+ 'backoffice/my_account/user_credentials.html.twig',
+ [
+ 'email_domain' => $emailParts[1],
+ 'email_form' => $emailForm->createView(),
+ 'password_form' => $passwordForm->createView(),
+ 'user' => $user,
+ ],
+ );
+ }
+}
diff --git a/src/Backoffice/Doctrine/Entity/Bookmark.php b/src/Backoffice/Doctrine/Entity/Bookmark.php
new file mode 100644
index 000000000..0971b1d25
--- /dev/null
+++ b/src/Backoffice/Doctrine/Entity/Bookmark.php
@@ -0,0 +1,126 @@
+createdAt = new DateTimeImmutable();
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function getCreatedAt(): DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+
+ public function setCreatedAt(DateTimeImmutable $createdAt): self
+ {
+ $this->createdAt = $createdAt;
+
+ return $this;
+ }
+
+ public function getUserId(): int
+ {
+ return $this->userId;
+ }
+
+ public function setUserId(int $userId): self
+ {
+ $this->userId = $userId;
+
+ return $this;
+ }
+
+ public function getContentId(): int
+ {
+ return $this->contentId;
+ }
+
+ public function setContentId(int $contentId): self
+ {
+ $this->contentId = $contentId;
+
+ return $this;
+ }
+
+ public function getLocationId(): int
+ {
+ return $this->locationId;
+ }
+
+ public function setLocationId(int $locationId): self
+ {
+ $this->locationId = $locationId;
+
+ return $this;
+ }
+
+ public function getContentTypeId(): int
+ {
+ return $this->contentTypeId;
+ }
+
+ public function setContentTypeId(int $contentTypeId): self
+ {
+ $this->contentTypeId = $contentTypeId;
+
+ return $this;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): self
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ public function getLocation(): ?Location
+ {
+ return $this->location;
+ }
+
+ public function setLocation(Location $location): self
+ {
+ $this->location = $location;
+
+ return $this;
+ }
+
+ public static function create(
+ int $userId,
+ int $contentId,
+ int $locationId,
+ int $contentTypeId,
+ string $title,
+ ): self {
+ return new self($userId, $contentId, $locationId, $contentTypeId, $title);
+ }
+}
diff --git a/src/Backoffice/Doctrine/Entity/SecurityToken.php b/src/Backoffice/Doctrine/Entity/SecurityToken.php
new file mode 100644
index 000000000..9545d4a02
--- /dev/null
+++ b/src/Backoffice/Doctrine/Entity/SecurityToken.php
@@ -0,0 +1,82 @@
+token = hash('sha256', random_bytes(256));
+ $this->creationDate = new DateTimeImmutable();
+ $this->expiryDate = $this->creationDate->modify(sprintf('+%d seconds', $validity));
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function getCreationDate(): DateTimeImmutable
+ {
+ return $this->creationDate;
+ }
+
+ public function getExpiryDate(): DateTimeImmutable
+ {
+ return $this->expiryDate;
+ }
+
+ public function isUsed(): bool
+ {
+ return $this->isUsed;
+ }
+
+ public function getUserId(): int
+ {
+ return $this->userId;
+ }
+
+ public function getToken(): string
+ {
+ return $this->token;
+ }
+
+ public function getTokenType(): SecurityTokenType
+ {
+ return $this->tokenType;
+ }
+
+ public function getValidity(): int
+ {
+ return $this->validity;
+ }
+
+ public function getData(): ?string
+ {
+ return $this->data;
+ }
+
+ public function setIsUsed(bool $isUsed): void
+ {
+ $this->isUsed = $isUsed;
+ }
+}
diff --git a/src/Backoffice/Doctrine/EventListener/BookmarkPostLoadListener.php b/src/Backoffice/Doctrine/EventListener/BookmarkPostLoadListener.php
new file mode 100644
index 000000000..585d4829f
--- /dev/null
+++ b/src/Backoffice/Doctrine/EventListener/BookmarkPostLoadListener.php
@@ -0,0 +1,40 @@
+enrichEntity($bookmark);
+ }
+
+ public function postPersist(Bookmark $bookmark, PostPersistEventArgs $args): void
+ {
+ $this->enrichEntity($bookmark);
+ }
+
+ private function enrichEntity(Bookmark $bookmark): void
+ {
+ try {
+ $bookmark->setLocation(
+ $this->loadService->loadLocation($bookmark->getLocationId()),
+ );
+ } catch (NotFoundException) {
+ // Do nothing
+ }
+ }
+}
diff --git a/src/Backoffice/Doctrine/Repository/BookmarkRepository.php b/src/Backoffice/Doctrine/Repository/BookmarkRepository.php
new file mode 100644
index 000000000..8d1c7d2a5
--- /dev/null
+++ b/src/Backoffice/Doctrine/Repository/BookmarkRepository.php
@@ -0,0 +1,116 @@
+createQueryBuilder('b');
+
+ $query->select('COUNT(b)');
+
+ $this->addFiltersToQuery($query, $userId, $contentTypeId, $searchText, $dateFrom, $dateTo);
+
+ return (int) $query->getQuery()->getSingleScalarResult();
+ }
+
+ /**
+ * @return iterable
+ */
+ public function filterUserBookmarks(
+ int $userId,
+ ?int $contentTypeId = null,
+ ?string $searchText = null,
+ ?DateTimeImmutable $dateFrom = null,
+ ?DateTimeImmutable $dateTo = null,
+ int $offset = 0,
+ ?int $limit = null,
+ ): iterable {
+ $query = $this->createQueryBuilder('b');
+
+ $this->addFiltersToQuery($query, $userId, $contentTypeId, $searchText, $dateFrom, $dateTo);
+
+ $query
+ ->orderBy('b.createdAt', 'DESC')
+ ->setFirstResult($offset)
+ ->setMaxResults($limit);
+
+ return $query->getQuery()->getResult();
+ }
+
+ public function updateBookmarkNames(int $contentId, string $title): void
+ {
+ $query = $this->createQueryBuilder('b');
+
+ $query->update()
+ ->set('b.title', ':title')
+ ->where($query->expr()->eq('b.contentId', ':contentId'))
+ ->setParameter('title', $title)
+ ->setParameter('contentId', $contentId)
+ ->getQuery()->execute();
+ }
+
+ private function addFiltersToQuery(
+ QueryBuilder $query,
+ ?int $userId = null,
+ ?int $contentTypeId = null,
+ ?string $searchText = null,
+ ?DateTimeImmutable $dateFrom = null,
+ ?DateTimeImmutable $dateTo = null,
+ ): void {
+ if ($userId !== null) {
+ $query->andWhere(
+ $query->expr()->eq('b.userId', ':userId'),
+ )->setParameter('userId', $userId);
+ }
+
+ if ($contentTypeId !== 0 && $contentTypeId !== null) {
+ $query->andWhere(
+ $query->expr()->eq('b.contentTypeId', ':contentTypeId'),
+ )->setParameter('contentTypeId', $contentTypeId);
+ }
+
+ if ($searchText !== '' && $searchText !== null) {
+ $query->andWhere(
+ $query->expr()->like('b.title', ':searchText'),
+ )->setParameter('searchText', '%' . $searchText . '%');
+ }
+
+ if ($dateFrom !== null) {
+ $query->andWhere(
+ $query->expr()->gte('b.createdAt', ':dateFrom'),
+ )->setParameter('dateFrom', $dateFrom);
+ }
+
+ if ($dateTo !== null) {
+ $query->andWhere(
+ $query->expr()->lt('b.createdAt', ':dateTo'),
+ )->setParameter('dateTo', $dateTo);
+ }
+ }
+}
diff --git a/src/Backoffice/Doctrine/Repository/SecurityTokenRepository.php b/src/Backoffice/Doctrine/Repository/SecurityTokenRepository.php
new file mode 100644
index 000000000..62e890160
--- /dev/null
+++ b/src/Backoffice/Doctrine/Repository/SecurityTokenRepository.php
@@ -0,0 +1,46 @@
+createQueryBuilder('t');
+
+ $query
+ ->where(
+ $query->expr()->andX(
+ $query->expr()->eq('t.userId', ':userId'),
+ $query->expr()->eq('t.tokenType', ':tokenType'),
+ $query->expr()->eq('t.isUsed', 0),
+ $query->expr()->gt('t.expiryDate', ':expiryDate'),
+ ),
+ )->setParameter('userId', $user->id)
+ ->setParameter('tokenType', $tokenType->value)
+ ->setParameter('expiryDate', new DateTimeImmutable());
+
+ return $query->getQuery()->getOneOrNullResult();
+ }
+}
diff --git a/src/Backoffice/Enums/SecurityTokenType.php b/src/Backoffice/Enums/SecurityTokenType.php
new file mode 100644
index 000000000..e1b8734da
--- /dev/null
+++ b/src/Backoffice/Enums/SecurityTokenType.php
@@ -0,0 +1,10 @@
+
+ */
+ public function __invoke(): iterable
+ {
+ $allowedContentTypes = $this->configResolver->getParameter('bookmarks.content_types', 'ngsite');
+
+ /** @var \Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType[] $contentTypes */
+ $contentTypes = (function (): Generator {
+ $contentTypeGroups = $this->contentTypeService->loadContentTypeGroups();
+ foreach ($contentTypeGroups as $contentTypeGroup) {
+ yield from $this->contentTypeService->loadContentTypes($contentTypeGroup);
+ }
+ })();
+
+ $typeOptions = [];
+
+ foreach ($contentTypes as $contentType) {
+ if (in_array($contentType->identifier, $allowedContentTypes, true)) {
+ $contentTypeName = $contentType->getName();
+ $typeOptions[$contentTypeName] = $contentType->id;
+ }
+ }
+
+ ksort($typeOptions);
+
+ return $typeOptions;
+ }
+}
diff --git a/src/Backoffice/Form/Filter/DateRangeType.php b/src/Backoffice/Form/Filter/DateRangeType.php
new file mode 100644
index 000000000..d19af2e62
--- /dev/null
+++ b/src/Backoffice/Form/Filter/DateRangeType.php
@@ -0,0 +1,66 @@
+setRequired('translation_prefix');
+ $resolver->setAllowedTypes('translation_prefix', 'string');
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->add(
+ 'date',
+ Type\TextType::class,
+ [
+ 'required' => false,
+ 'label' => sprintf('%s.filter.date.label', $options['translation_prefix']),
+ ],
+ );
+
+ $builder->add(
+ 'dateFrom',
+ Type\DateTimeType::class,
+ [
+ 'html5' => false,
+ 'input' => 'string',
+ 'widget' => 'single_text',
+ 'required' => false,
+ ],
+ );
+
+ $builder->get('dateFrom')->addModelTransformer(new DateTimeTransformer());
+
+ $builder->add(
+ 'dateTo',
+ Type\DateTimeType::class,
+ [
+ 'html5' => false,
+ 'input' => 'string',
+ 'widget' => 'single_text',
+ 'required' => false,
+ ],
+ );
+
+ $builder->get('dateTo')->addModelTransformer(new DateTimeTransformer());
+ }
+
+ public function buildView(FormView $view, FormInterface $form, array $options): void
+ {
+ $view->vars['translation_prefix'] = $options['translation_prefix'];
+ }
+}
diff --git a/src/Backoffice/Form/Filter/DateTimeTransformer.php b/src/Backoffice/Form/Filter/DateTimeTransformer.php
new file mode 100644
index 000000000..09e8de317
--- /dev/null
+++ b/src/Backoffice/Form/Filter/DateTimeTransformer.php
@@ -0,0 +1,35 @@
+
+ */
+final class DateTimeTransformer implements DataTransformerInterface
+{
+ public function transform($value): ?string
+ {
+ return $value?->format('Y-m-d H:i:s');
+ }
+
+ public function reverseTransform($value): ?DateTimeImmutable
+ {
+ try {
+ return $value !== '' && $value !== null ?
+ new DateTimeImmutable($value, new DateTimeZone('UTC')) :
+ null;
+ } catch (Throwable) {
+ return null;
+ }
+ }
+}
diff --git a/src/Backoffice/Form/Filter/FilterType.php b/src/Backoffice/Form/Filter/FilterType.php
new file mode 100644
index 000000000..50af981e2
--- /dev/null
+++ b/src/Backoffice/Form/Filter/FilterType.php
@@ -0,0 +1,75 @@
+setDefault('translation_domain', 'backoffice');
+ $resolver->setDefault('method', Request::METHOD_GET);
+ $resolver->setDefault('csrf_protection', false);
+
+ $resolver->setRequired('translation_prefix');
+ $resolver->setAllowedTypes('translation_prefix', 'string');
+
+ $resolver->setRequired('show_date');
+ $resolver->setAllowedTypes('show_date', 'bool');
+ $resolver->setDefault('show_date', false);
+
+ $resolver->setRequired('show_search');
+ $resolver->setAllowedTypes('show_search', 'bool');
+ $resolver->setDefault('show_search', false);
+
+ $resolver->setRequired('selections');
+ $resolver->setAllowedTypes('selections', 'array');
+ $resolver->setDefault('selections', []);
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->add(
+ 'selections',
+ SelectionsType::class,
+ [
+ 'label' => false,
+ 'translation_prefix' => $options['translation_prefix'],
+ 'selections' => $options['selections'],
+ ],
+ );
+
+ $builder->add(
+ 'date',
+ DateRangeType::class,
+ [
+ 'label' => false,
+ 'translation_prefix' => $options['translation_prefix'],
+ ],
+ );
+
+ $builder->add(
+ 'searchText',
+ Type\SearchType::class,
+ [
+ 'required' => false,
+ ],
+ );
+ }
+
+ public function buildView(FormView $view, FormInterface $form, array $options): void
+ {
+ $view->vars['translation_prefix'] = $options['translation_prefix'];
+ $view->vars['show_date'] = $options['show_date'];
+ $view->vars['show_search'] = $options['show_search'];
+ }
+}
diff --git a/src/Backoffice/Form/Filter/SelectionsType.php b/src/Backoffice/Form/Filter/SelectionsType.php
new file mode 100644
index 000000000..c349c7591
--- /dev/null
+++ b/src/Backoffice/Form/Filter/SelectionsType.php
@@ -0,0 +1,119 @@
+setDefault('translation_domain', 'backoffice');
+
+ $resolver->setRequired('translation_prefix');
+ $resolver->setAllowedTypes('translation_prefix', 'string');
+
+ $resolver->setRequired('selections');
+ $resolver->setAllowedTypes('selections', 'array');
+ $resolver->setDefault('selections', []);
+
+ $resolver->setAllowedValues(
+ 'selections',
+ static function (array $selections): bool {
+ foreach ($selections as $identifier => $selection) {
+ if (
+ is_callable($selection['options'])
+ || is_array($selection['options'])
+ || (is_string($selection['options']) && is_a($selection['options'], BackedEnum::class, true))
+ ) {
+ continue;
+ }
+
+ throw new InvalidOptionsException(
+ sprintf(
+ 'Choices for "%s" identifier must be a callable or an array or a class string for a backed enum, %s given.',
+ $identifier,
+ get_debug_type($selection['options']),
+ ),
+ );
+ }
+
+ return true;
+ },
+ );
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ foreach ($options['selections'] as $identifier => $choices) {
+ if (is_callable($choices['options']) || (is_array($choices['options']) && is_callable($choices['options'][0] ?? null))) {
+ $callable = $choices['options'];
+ $params = [];
+
+ if (!is_callable($callable)) {
+ $callable = $choices['options'][0] ?? static fn (): array => [];
+ $params = $choices['options'][1] ?? [];
+ }
+
+ $builder->add(
+ $identifier,
+ Type\ChoiceType::class,
+ [
+ 'required' => $choices['required'] ?? false,
+ 'choices' => $callable(...$params),
+ 'label' => sprintf('%s.filter.%s.label', $options['translation_prefix'], $identifier),
+ 'multiple' => $choices['multiple'] ?? false,
+ 'data' => $choices['default_value'] ?? null,
+ ],
+ );
+ } elseif (is_array($choices['options'])) {
+ $builder->add(
+ $identifier,
+ Type\ChoiceType::class,
+ [
+ 'required' => $choices['required'] ?? false,
+ 'choices' => $choices['options'],
+ 'choice_label' => static fn (string $choice): string => sprintf('%s.%s.%s', $options['translation_prefix'], $identifier, $choice),
+ 'label' => sprintf('%s.filter.%s.label', $options['translation_prefix'], $identifier),
+ 'multiple' => $choices['multiple'] ?? false,
+ 'data' => $choices['default_value'] ?? null,
+ ],
+ );
+ } elseif (is_string($choices['options'])) {
+ $builder->add(
+ $identifier,
+ Type\EnumType::class,
+ [
+ 'required' => $choices['required'] ?? false,
+ 'class' => $choices['options'],
+ 'choice_label' => static fn (BackedEnum $choice): string => sprintf('%s.%s.%s', $options['translation_prefix'], $identifier, $choice->value),
+ 'label' => sprintf('%s.filter.%s.label', $options['translation_prefix'], $identifier),
+ 'multiple' => $choices['multiple'] ?? false,
+ 'data' => $choices['default_value'] ?? null,
+ ],
+ );
+ }
+ }
+ }
+
+ public function buildView(FormView $view, FormInterface $form, array $options): void
+ {
+ $view->vars['translation_prefix'] = $options['translation_prefix'];
+ }
+}
diff --git a/src/Backoffice/Form/MyAccount/ChangeEmailData.php b/src/Backoffice/Form/MyAccount/ChangeEmailData.php
new file mode 100644
index 000000000..a9a6ab753
--- /dev/null
+++ b/src/Backoffice/Form/MyAccount/ChangeEmailData.php
@@ -0,0 +1,12 @@
+email);
+
+ if (count($emailParts) !== 2) {
+ throw new BadRequestHttpException("User's email address is not valid.");
+ }
+
+ $emailUsername = $emailParts[0];
+
+ $forms = iterator_to_array($forms);
+
+ $forms['email_username']->setData($emailUsername);
+ }
+
+ public function mapFormsToData(Traversable $forms, &$viewData): void
+ {
+ /** @var \Ibexa\Core\MVC\Symfony\Security\User $symfonyUser */
+ $symfonyUser = $this->security->getUser();
+ $user = $symfonyUser->getAPIUser();
+
+ $emailParts = explode('@', $user->email);
+
+ if (count($emailParts) !== 2) {
+ throw new BadRequestHttpException("User's email address is not valid.");
+ }
+
+ $forms = iterator_to_array($forms);
+ $emailDomain = '@' . $emailParts[1];
+
+ $viewData = new ChangeEmailData(
+ $forms['email_username']->getData() . $emailDomain,
+ );
+ }
+}
diff --git a/src/Backoffice/Form/MyAccount/ChangeEmailType.php b/src/Backoffice/Form/MyAccount/ChangeEmailType.php
new file mode 100644
index 000000000..2962cd249
--- /dev/null
+++ b/src/Backoffice/Form/MyAccount/ChangeEmailType.php
@@ -0,0 +1,42 @@
+setDefault('translation_domain', 'backoffice');
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->add(
+ 'email_username',
+ TextType::class,
+ [
+ 'required' => true,
+ 'label' => 'form.my_account.user_credentials.email',
+ 'constraints' => [
+ new Constraints\NotBlank(),
+ new Constraints\Regex(['pattern' => '/^[^@]+$/', 'message' => 'backoffice.change_email.username.invalid']),
+ ],
+ ],
+ );
+
+ $builder->setDataMapper(new ChangeEmailDataMapper($this->security));
+ }
+}
diff --git a/src/Backoffice/Form/MyAccount/ChangePasswordType.php b/src/Backoffice/Form/MyAccount/ChangePasswordType.php
new file mode 100644
index 000000000..3d91edfea
--- /dev/null
+++ b/src/Backoffice/Form/MyAccount/ChangePasswordType.php
@@ -0,0 +1,42 @@
+setDefault('translation_domain', 'backoffice');
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->add(
+ 'current_password',
+ PasswordType::class,
+ [
+ 'required' => true,
+ 'constraints' => [
+ new UserPassword(['message' => 'backoffice.change_password.current_password.invalid']),
+ ],
+ ],
+ );
+
+ $builder->add(
+ 'password',
+ UserPasswordType::class,
+ [
+ 'required' => true,
+ ],
+ );
+ }
+}
diff --git a/src/Backoffice/Form/MyAccount/PersonalDetailsData.php b/src/Backoffice/Form/MyAccount/PersonalDetailsData.php
new file mode 100644
index 000000000..b26be384a
--- /dev/null
+++ b/src/Backoffice/Form/MyAccount/PersonalDetailsData.php
@@ -0,0 +1,18 @@
+setData(
+ $viewData->getField('first_name')->value->text,
+ );
+
+ $forms['last_name']->setData(
+ $viewData->getField('last_name')->value->text,
+ );
+ }
+
+ public function mapFormsToData(Traversable $forms, &$viewData): void
+ {
+ $forms = iterator_to_array($forms);
+
+ /** @var \Symfony\Component\HttpFoundation\File\UploadedFile|null $image */
+ $image = $forms['image']->getData();
+
+ if ($image !== null) {
+ $originalFilename = pathinfo($image->getClientOriginalName(), PATHINFO_FILENAME);
+ $extension = $image->guessExtension();
+
+ try {
+ $imageValue = Value::fromString($forms['image']->getData()->getRealPath());
+ $imageValue->fileName = $originalFilename . '.' . $extension;
+ } catch (InvalidArgumentException) {
+ $imageValue = null;
+ }
+ }
+
+ $viewData = new PersonalDetailsData(
+ $imageValue ?? null,
+ $forms['remove_image']->getData(),
+ $forms['first_name']->getData(),
+ $forms['last_name']->getData(),
+ );
+ }
+}
diff --git a/src/Backoffice/Form/MyAccount/PersonalDetailsType.php b/src/Backoffice/Form/MyAccount/PersonalDetailsType.php
new file mode 100644
index 000000000..95f86d90c
--- /dev/null
+++ b/src/Backoffice/Form/MyAccount/PersonalDetailsType.php
@@ -0,0 +1,88 @@
+setDefault('translation_domain', 'backoffice');
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $contentType = $this->contentTypeService->loadContentTypeByIdentifier('user');
+
+ $builder->add(
+ 'image',
+ FileType::class,
+ [
+ 'required' => false,
+ 'label' => 'form.my_account.personal_details.image',
+ 'constraints' => [
+ new Constraints\Image(),
+ new IbexaFieldValueValid([
+ 'field' => 'image',
+ 'contentType' => 'user',
+ ]),
+ ],
+ ],
+ );
+
+ $builder->add(
+ 'remove_image',
+ CheckboxType::class,
+ [
+ 'required' => false,
+ ],
+ );
+
+ $builder->add(
+ 'first_name',
+ TextType::class,
+ [
+ 'required' => true,
+ 'label' => 'form.my_account.personal_details.first_name',
+ 'constraints' => [
+ new Constraints\NotBlank(),
+ new IbexaFieldValueValid([
+ 'field' => 'first_name',
+ 'contentType' => 'user',
+ ]),
+ ],
+ ],
+ );
+
+ $builder->add(
+ 'last_name',
+ TextType::class,
+ [
+ 'required' => true,
+ 'label' => 'form.my_account.personal_details.last_name',
+ 'constraints' => [
+ new Constraints\NotBlank(),
+ new IbexaFieldValueValid([
+ 'field' => 'last_name',
+ 'contentType' => 'user',
+ ]),
+ ],
+ ],
+ );
+
+ $builder->setDataMapper(new PersonalDetailsDataMapper());
+ }
+}
diff --git a/src/Backoffice/Form/User/UserPasswordType.php b/src/Backoffice/Form/User/UserPasswordType.php
new file mode 100644
index 000000000..eec1bd57f
--- /dev/null
+++ b/src/Backoffice/Form/User/UserPasswordType.php
@@ -0,0 +1,106 @@
+contentTypeService->loadContentTypeByIdentifier('user');
+
+ /** @var \Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinition $userAccountFieldDefinition */
+ $userAccountFieldDefinition = $contentType->getFieldDefinition('user_account');
+
+ $resolver->setDefaults(
+ [
+ 'type' => Type\PasswordType::class,
+ 'translation_domain' => 'backoffice',
+ 'invalid_message' => 'backoffice.user_password.invalid',
+ 'first_options' => [
+ 'label' => 'form.user_password.password.label',
+ ],
+ 'second_options' => [
+ 'label' => 'form.user_password.repeat_password.label',
+ ],
+ 'constraints' => fn (Options $options) => $this->getPasswordConstraints($userAccountFieldDefinition, $options),
+ ],
+ );
+ }
+
+ public function getParent(): string
+ {
+ return Type\RepeatedType::class;
+ }
+
+ /**
+ * @return \Symfony\Component\Validator\Constraint[]
+ */
+ private function getPasswordConstraints(FieldDefinition $definition, Options $options): array
+ {
+ $config = $definition->validatorConfiguration['PasswordValueValidator'];
+
+ $constraints = $options['required'] ?
+ [new Constraints\NotBlank()] :
+ [];
+
+ if ($config['requireAtLeastOneUpperCaseCharacter'] ?? false) {
+ $constraints[] = new Constraints\Regex(
+ [
+ 'pattern' => '/\p{Lu}/u',
+ 'message' => 'backoffice.register_user.password.at_least_one_uppercase_character',
+ ],
+ );
+ }
+
+ if ($config['requireAtLeastOneLowerCaseCharacter'] ?? false) {
+ $constraints[] = new Constraints\Regex(
+ [
+ 'pattern' => '/\p{Ll}/u',
+ 'message' => 'backoffice.register_user.password.at_least_one_lowercase_character',
+ ],
+ );
+ }
+
+ if ($config['requireAtLeastOneNumericCharacter'] ?? false) {
+ $constraints[] = new Constraints\Regex(
+ [
+ 'pattern' => '/\pN/u',
+ 'message' => 'backoffice.register_user.password.at_least_one_numeric_character',
+ ],
+ );
+ }
+
+ if ($config['requireAtLeastOneNonAlphanumericCharacter'] ?? false) {
+ $constraints[] = new Constraints\Regex(
+ [
+ 'pattern' => '/[^\p{Ll}\p{Lu}\pL\pN]/u',
+ 'message' => 'backoffice.register_user.password.at_least_one_non_alphanumeric_character',
+ ],
+ );
+ }
+
+ if ($config['requireNotCompromisedPassword'] ?? false) {
+ $constraints[] = new Constraints\NotCompromisedPassword();
+ }
+
+ if (($config['minLength'] ?? 0) > 0) {
+ $constraints[] = new Constraints\Length(['min' => $config['minLength']]);
+ }
+
+ return $constraints;
+ }
+}
diff --git a/src/Backoffice/Menu/MainMenuBuilder.php b/src/Backoffice/Menu/MainMenuBuilder.php
new file mode 100644
index 000000000..48b23c17e
--- /dev/null
+++ b/src/Backoffice/Menu/MainMenuBuilder.php
@@ -0,0 +1,93 @@
+factory->createItem('root');
+
+ $menu
+ ->addChild('dashboard', ['route' => 'backoffice_dashboard_index'])
+ ->setLabel('menu.main_menu.dashboard')
+ ->setExtra('translation_domain', 'backoffice')
+ ->setExtra('icon_class', 'icon-dashboard');
+
+ $menu
+ ->addChild('bookmarks', ['route' => 'backoffice_bookmarks_index'])
+ ->setLabel('menu.main_menu.bookmarks')
+ ->setExtra('translation_domain', 'backoffice')
+ ->setExtra('icon_class', 'icon-bookmarks');
+
+ $menu
+ ->addChild('conversations', ['route' => 'ngconversations_app'])
+ ->setLabel('menu.main_menu.conversations')
+ ->setExtra('translation_domain', 'backoffice')
+ ->setExtra('icon_class', 'icon-conversations')
+ ->setExtra(
+ 'icon_count',
+ $this->conversationRepository->findUnreadParticipantConversationsCount(
+ $this->participantProvider->provideParticipant(),
+ ),
+ );
+
+ $menu
+ ->addChild('notifications')
+ ->setLabel('menu.main_menu.notifications')
+ ->setExtra('translation_domain', 'backoffice')
+ ->setExtra('icon_class', 'icon-notifications')
+ ->setExtra(
+ 'icon_count',
+ $this->notificationRepository->getUserNotificationsCount(
+ $this->userProvider->provideUser(),
+ false,
+ ),
+ );
+
+ /** @var \Knp\Menu\ItemInterface $notificationsMenu */
+ $notificationsMenu = $menu->getChild('notifications');
+
+ $notificationsMenu
+ ->addChild('notifications_inbox', ['route' => 'ngnotifications_admin_inbox'])
+ ->setLabel('menu.main_menu.notifications.inbox')
+ ->setExtra('translation_domain', 'backoffice');
+
+ $notificationsMenu
+ ->addChild('notifications_archive', ['route' => 'ngnotifications_admin_archive'])
+ ->setLabel('menu.main_menu.notifications.archive')
+ ->setExtra('translation_domain', 'backoffice');
+
+ $notificationsMenu
+ ->addChild('notifications_subscriptions', ['route' => 'ngnotifications_admin_subscriptions'])
+ ->setLabel('menu.main_menu.notifications.subscriptions')
+ ->setExtra('translation_domain', 'backoffice');
+
+ return $menu;
+ }
+}
diff --git a/src/Backoffice/Menu/MyAccountMenuBuilder.php b/src/Backoffice/Menu/MyAccountMenuBuilder.php
new file mode 100644
index 000000000..6174db72d
--- /dev/null
+++ b/src/Backoffice/Menu/MyAccountMenuBuilder.php
@@ -0,0 +1,34 @@
+factory->createItem('root');
+
+ $menu
+ ->addChild('personal_details', ['route' => 'backoffice_my_account_personal_details'])
+ ->setLabel('menu.main_menu.personal_details')
+ ->setExtra('translation_domain', 'backoffice');
+
+ $menu
+ ->addChild('user_credentials', ['route' => 'backoffice_my_account_user_credentials'])
+ ->setLabel('menu.main_menu.user_credentials')
+ ->setExtra('translation_domain', 'backoffice');
+
+ return $menu;
+ }
+}
diff --git a/src/Backoffice/Pagerfanta/BookmarksAdapter.php b/src/Backoffice/Pagerfanta/BookmarksAdapter.php
new file mode 100644
index 000000000..e6eba083a
--- /dev/null
+++ b/src/Backoffice/Pagerfanta/BookmarksAdapter.php
@@ -0,0 +1,51 @@
+repository->filterUserBookmarksCount(
+ $this->userId,
+ $this->contentTypeId,
+ $this->searchText,
+ $this->dateFrom,
+ $this->dateTo,
+ );
+ }
+
+ /**
+ * @param int $offset
+ * @param int $length
+ *
+ * @return iterable
+ */
+ public function getSlice($offset, $length): iterable
+ {
+ return $this->repository->filterUserBookmarks(
+ $this->userId,
+ $this->contentTypeId,
+ $this->searchText,
+ $this->dateFrom,
+ $this->dateTo,
+ $offset,
+ $length,
+ );
+ }
+}
diff --git a/src/Controller/API/Bookmarks/Create.php b/src/Controller/API/Bookmarks/Create.php
new file mode 100644
index 000000000..008b735d5
--- /dev/null
+++ b/src/Controller/API/Bookmarks/Create.php
@@ -0,0 +1,60 @@
+denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
+
+ $allowedContentTypes = $this->configResolver->getParameter('bookmarks.content_types', 'ngsite');
+ if (!in_array($location->contentInfo->contentTypeIdentifier, $allowedContentTypes, true)) {
+ throw new BadRequestHttpException('This location cannot be bookmarked!');
+ }
+
+ /** @var \Ibexa\Core\MVC\Symfony\Security\User $sfUser */
+ $sfUser = $this->getUser();
+
+ $existingBookmark = $this->repository->findOneBy(
+ [
+ 'userId' => $sfUser->getAPIUser()->id,
+ 'locationId' => $location->id,
+ ],
+ );
+
+ if ($existingBookmark instanceof Bookmark) {
+ throw new BadRequestHttpException('Bookmark already exists!');
+ }
+
+ $bookmark = Bookmark::create(
+ $sfUser->getAPIUser()->id,
+ $location->contentInfo->id,
+ $location->id,
+ $location->contentInfo->contentTypeId,
+ $location->contentInfo->name ?? '',
+ );
+
+ $this->repository->save($bookmark, true);
+
+ return new Response();
+ }
+}
diff --git a/src/Controller/API/Bookmarks/Delete.php b/src/Controller/API/Bookmarks/Delete.php
new file mode 100644
index 000000000..efae18cf4
--- /dev/null
+++ b/src/Controller/API/Bookmarks/Delete.php
@@ -0,0 +1,37 @@
+denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
+
+ /** @var \Ibexa\Core\MVC\Symfony\Security\User $sfUser */
+ $sfUser = $this->getUser();
+
+ $bookmark = $this->repository->findOneBy(
+ [
+ 'userId' => $sfUser->getAPIUser()->id,
+ 'locationId' => $location->id,
+ ],
+ ) ?? throw $this->createNotFoundException();
+
+ $this->repository->remove($bookmark, true);
+
+ return new Response();
+ }
+}
diff --git a/src/Controller/PageLayout/UserMenu.php b/src/Controller/PageLayout/UserMenu.php
new file mode 100644
index 000000000..abe9e39d8
--- /dev/null
+++ b/src/Controller/PageLayout/UserMenu.php
@@ -0,0 +1,30 @@
+setMaxAge(0);
+ $response->setSharedMaxAge(0);
+ $response->setPrivate();
+ $response->headers->addCacheControlDirective('no-cache');
+ $response->headers->addCacheControlDirective('no-store');
+ $response->headers->addCacheControlDirective('must-revalidate');
+
+ return $this->render(
+ '@ibexadesign/pagelayout/header/login.html.twig',
+ [],
+ $response,
+ );
+ }
+}
diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php
index 1324bf603..6364e6b0b 100644
--- a/src/DependencyInjection/AppExtension.php
+++ b/src/DependencyInjection/AppExtension.php
@@ -4,7 +4,9 @@
namespace App\DependencyInjection;
+use App\Attribute;
use Symfony\Component\Config\Resource\FileResource;
+use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\Finder\Finder;
@@ -19,6 +21,8 @@ public function load(array $configs, ContainerBuilder $container): void
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
+
+ $this->registerAttributeAutoConfiguration($container);
}
public function prepend(ContainerBuilder $container): void
@@ -32,4 +36,14 @@ public function prepend(ContainerBuilder $container): void
}
}
}
+
+ private function registerAttributeAutoConfiguration(ContainerBuilder $container): void
+ {
+ $container->registerAttributeForAutoconfiguration(
+ Attribute\AsMenuBuilder::class,
+ static function (ChildDefinition $definition, Attribute\AsMenuBuilder $attribute): void {
+ $definition->addTag('knp_menu.menu_builder', ['method' => $attribute->method, 'alias' => $attribute->alias]);
+ },
+ );
+ }
}
diff --git a/src/EventListener/Ibexa/Bookmarks/UpdateTitleListener.php b/src/EventListener/Ibexa/Bookmarks/UpdateTitleListener.php
new file mode 100644
index 000000000..5a4f8e9d7
--- /dev/null
+++ b/src/EventListener/Ibexa/Bookmarks/UpdateTitleListener.php
@@ -0,0 +1,28 @@
+ 'onUpdateContent',
+ ];
+ }
+
+ public function onUpdateContent(UpdateContentEvent $event): void
+ {
+ $content = $event->getContent();
+
+ $this->bookmarkRepository->updateBookmarkNames($content->id, (string) $content->getName());
+ }
+}
diff --git a/src/EventListener/Ibexa/Notifications/PublishVersionListener.php b/src/EventListener/Ibexa/Notifications/PublishVersionListener.php
new file mode 100644
index 000000000..ad63b8e50
--- /dev/null
+++ b/src/EventListener/Ibexa/Notifications/PublishVersionListener.php
@@ -0,0 +1,122 @@
+ $categoryResolver
+ */
+ public function __construct(
+ private CategoryResolverInterface $categoryResolver,
+ private NotificationCategoryRegistry $categoryRegistry,
+ private NotificationManagerInterface $notificationManager,
+ ) {}
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ PublishVersionEvent::class => 'onPublishVersion',
+ ];
+ }
+
+ public function onPublishVersion(PublishVersionEvent $event): void
+ {
+ $mainLocation = $event->getContent()->contentInfo->getMainLocation();
+ if ($mainLocation === null) {
+ return;
+ }
+
+ $locationCategory = $this->getLocationCategory($mainLocation);
+ if ($locationCategory === null) {
+ return;
+ }
+
+ if ($event->getContent()->versionInfo->versionNo === 1) {
+ $this->sendNewContentNotification($event->getContent(), $mainLocation, $locationCategory);
+
+ return;
+ }
+
+ $this->sendUpdatedContentNotification($event->getContent(), $mainLocation, $locationCategory);
+ }
+
+ private function getLocationCategory(Location $location): ?NotificationCategory
+ {
+ $categoryIdentifier = $this->categoryResolver->resolveCategory($location);
+
+ if ($categoryIdentifier !== null) {
+ $category = $this->categoryRegistry->getCategory($categoryIdentifier);
+
+ if ($category->subscription->enabled) {
+ return $category;
+ }
+ }
+
+ return null;
+ }
+
+ private function sendNewContentNotification(Content $content, Location $location, NotificationCategory $category): void
+ {
+ $this->notificationManager->sendNotification(
+ $category->identifier,
+ [],
+ new NotificationData(
+ sprintf('New content "%s" has been published', $content->getName()),
+ new TwigTemplate(
+ 'backoffice/notifications/new_content.html.twig',
+ [
+ 'content_id' => $content->id,
+ 'content_name' => $content->getName(),
+ ],
+ ),
+ ),
+ new Context(
+ [
+ 'contentId' => $content->id,
+ 'locationId' => $location->id,
+ ],
+ ),
+ );
+ }
+
+ private function sendUpdatedContentNotification(Content $content, Location $location, NotificationCategory $category): void
+ {
+ $this->notificationManager->sendNotification(
+ $category->identifier,
+ [],
+ new NotificationData(
+ sprintf('Content "%s" has been updated', $content->getName()),
+ new TwigTemplate(
+ 'backoffice/notifications/updated_content.html.twig',
+ [
+ 'content_id' => $content->id,
+ 'content_name' => $content->getName(),
+ ],
+ ),
+ ),
+ new Context(
+ [
+ 'contentId' => $content->id,
+ 'locationId' => $location->id,
+ ],
+ ),
+ );
+ }
+}
diff --git a/src/Repository/Repository.php b/src/Repository/Repository.php
new file mode 100644
index 000000000..be773f601
--- /dev/null
+++ b/src/Repository/Repository.php
@@ -0,0 +1,40 @@
+
+ */
+abstract class Repository extends ServiceEntityRepository
+{
+ public function save(object $entity, bool $flush = false, bool $refresh = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->flush();
+
+ if ($refresh) {
+ $this->getEntityManager()->refresh($entity);
+ }
+ }
+ }
+
+ public function remove(object $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->flush();
+ }
+ }
+
+ public function flush(): void
+ {
+ $this->getEntityManager()->flush();
+ }
+}
diff --git a/src/Twig/BookmarkExtension.php b/src/Twig/BookmarkExtension.php
new file mode 100644
index 000000000..13496b9b0
--- /dev/null
+++ b/src/Twig/BookmarkExtension.php
@@ -0,0 +1,18 @@
+security->getUser();
+
+ return $this->bookmarkRepository->findOneBy(
+ [
+ 'userId' => $symfonyUser->getAPIUser()->id ?? 0,
+ 'locationId' => $location->id,
+ ],
+ );
+ }
+}
diff --git a/src/Validator/IbexaFieldValueValid.php b/src/Validator/IbexaFieldValueValid.php
new file mode 100644
index 000000000..e47ed3f20
--- /dev/null
+++ b/src/Validator/IbexaFieldValueValid.php
@@ -0,0 +1,14 @@
+field;
+
+ $contentType = $this->contentTypeService->loadContentTypeByIdentifier(
+ $constraint->contentType,
+ );
+
+ /** @var \Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinition $fieldDefinition */
+ $fieldDefinition = $contentType->getFieldDefinition($fieldIdentifier);
+ $fieldType = $this->fieldTypeRegistry->getFieldType($fieldDefinition->fieldTypeIdentifier);
+
+ try {
+ if ($value instanceof UploadedFile) {
+ $value = $value->getRealPath();
+ }
+
+ $fieldValue = $fieldType->acceptValue($value);
+ $fieldErrors = $fieldType->validate($fieldDefinition, $fieldValue);
+
+ if (count($fieldErrors) > 0) {
+ foreach ($fieldErrors as $fieldError) {
+ /** @var \Ibexa\Contracts\Core\Repository\Values\Translation\Plural $errorMessage */
+ $errorMessage = $fieldError->getTranslatableMessage();
+ $this->context->buildViolation((string) $errorMessage)
+ ->atPath($fieldIdentifier)
+ ->addViolation();
+ }
+ }
+ } catch (InvalidArgumentException $e) {
+ $this->context->buildViolation($e->getMessage())
+ ->atPath($fieldIdentifier)
+ ->addViolation();
+ }
+ }
+}
diff --git a/templates/backoffice/bookmarks/delete.html.twig b/templates/backoffice/bookmarks/delete.html.twig
new file mode 100644
index 000000000..d1922c5f9
--- /dev/null
+++ b/templates/backoffice/bookmarks/delete.html.twig
@@ -0,0 +1,26 @@
+{% trans_default_domain 'backoffice' %}
+
+
+
+
+
{{ 'bookmarks.delete.confirmation'|trans({'%content%': bookmark.location.content.name}) }}
+
+
+
diff --git a/templates/backoffice/bookmarks/index.html.twig b/templates/backoffice/bookmarks/index.html.twig
new file mode 100644
index 000000000..d61341517
--- /dev/null
+++ b/templates/backoffice/bookmarks/index.html.twig
@@ -0,0 +1,78 @@
+{% extends 'backoffice/layout.html.twig' %}
+
+{% trans_default_domain 'backoffice' %}
+
+{% form_theme filter_form 'backoffice/forms/theme.html.twig' %}
+
+{% block content %}
+
+
+ {% if bookmarks|length > 0 %}
+
+
+
+ {{ 'bookmarks.title'|trans }}
+ {{ 'bookmarks.type'|trans }}
+ {{ 'bookmarks.saved'|trans }}
+ {{ 'bookmarks.options'|trans }}
+
+
+
+ {% for bookmark in bookmarks %}
+
+
+
+ {{ bookmark.location.content.name }}
+
+
+
+ {% if bookmark.location is not null %}
+ {{ bookmark.location.contentInfo.contentType.name }}
+ {% endif %}
+
+ {{ bookmark.createdAt|format_datetime }}
+
+
+
+
+ {% endfor %}
+
+
+
+ {% include 'backoffice/parts/confirm_modal.html.twig' %}
+
+ {% if bookmarks.haveToPaginate() %}
+ {{ pagerfanta(bookmarks, 'ngsite') }}
+ {% endif %}
+ {% else %}
+
+
{{ 'bookmarks.no_bookmarks'|trans }}
+
+ {% endif %}
+{% endblock %}
diff --git a/templates/backoffice/conversations/layout.html.twig b/templates/backoffice/conversations/layout.html.twig
new file mode 100644
index 000000000..0699d9d0f
--- /dev/null
+++ b/templates/backoffice/conversations/layout.html.twig
@@ -0,0 +1,15 @@
+{% extends 'backoffice/layout.html.twig' %}
+
+{% block title %}{{ title|default('Conversations') }}{% endblock %}
+
+{% block meta %}
+ {% include '@NetgenConversations/app/meta.html.twig' %}
+{% endblock %}
+
+{% block javascripts %}
+ {% include '@NetgenConversations/app/javascripts.html.twig' %}
+{% endblock %}
+
+{% block stylesheets %}
+ {% include '@NetgenConversations/app/stylesheets.html.twig' %}
+{% endblock %}
diff --git a/templates/backoffice/dashboard/index.html.twig b/templates/backoffice/dashboard/index.html.twig
new file mode 100644
index 000000000..743bbd90b
--- /dev/null
+++ b/templates/backoffice/dashboard/index.html.twig
@@ -0,0 +1 @@
+{% extends 'backoffice/layout.html.twig' %}
diff --git a/templates/backoffice/email/change_email_confirmation.html.twig b/templates/backoffice/email/change_email_confirmation.html.twig
new file mode 100644
index 000000000..36bd45940
--- /dev/null
+++ b/templates/backoffice/email/change_email_confirmation.html.twig
@@ -0,0 +1,5 @@
+Dear {{ user.name }},
+
+
You requested a change of your email. To confirm, click here.
+
+This link will be active for 15 minutes.
diff --git a/templates/backoffice/forms/theme.html.twig b/templates/backoffice/forms/theme.html.twig
new file mode 100644
index 000000000..2e485bd20
--- /dev/null
+++ b/templates/backoffice/forms/theme.html.twig
@@ -0,0 +1,97 @@
+{% extends 'backoffice/layout.html.twig' %}
+
+{% trans_default_domain 'backoffice' %}
+
+{%- block form_errors -%}
+ {%- if errors|length > 0 -%}
+
+ {%- for error in errors -%}
+ {{ error.message }}
+ {%- endfor -%}
+
+ {%- endif -%}
+{%- endblock form_errors -%}
+
+{%- block form_row -%}
+ {% set row_attr = row_attr|default([]) %}
+ {% set row_attr = row_attr|merge({class: row_attr.class|default('') ~ ' row-input'}) %}
+
+ {% if errors|length > 0 %}
+ {% set row_attr = row_attr|merge({class: row_attr.class|default('') ~ ' error-input'}) %}
+ {% endif %}
+
+
+ {{- form_label(form) -}}
+ {{- form_widget(form) -}}
+ {{- form_errors(form) -}}
+
+{%- endblock form_row -%}
+
+{% block filter_widget %}
+
+
+
+ {{ form_widget(form.selections) }}
+
+
+
+ {% if form.vars.show_date|default(false) %}
+ {{ form_widget(form.date) }}
+ {% endif %}
+
+
+
+
+
+
+ {{ (form.vars.translation_prefix ~ '.filter.button.more')|trans }}
+
+
+
+
+
+ {% if form.vars.show_search|default(false) %}
+
+
+ {{ form_widget(
+ form.searchText, {
+ attr: {
+ placeholder: form.vars.translation_prefix ~ '.filter.search_text.placeholder'
+ }
+ }
+ ) }}
+
+
+ {% endif %}
+
+
+ {{ (form.vars.translation_prefix ~ '.filter.button.apply')|trans }}
+
+
+
+ {{ (form.vars.translation_prefix ~ '.filter.button.reset')|trans }}
+
+
+
+{% endblock %}
+
+{% block date_range_widget %}
+
+ {{ form_label(form.date, null, {label_attr: {'class': 'form-label'}}) }}
+ {{ form_widget(
+ form.date, {
+ attr: {
+ 'readonly': true,
+ 'placeholder': form.vars.translation_prefix ~ '.filter.date.placeholder',
+ 'class': 'flatpickr flatpickr-input',
+ 'data-input': null
+ }
+ }
+ ) }}
+
+ {{ form_widget(form.dateFrom, {'id': 'dateFromHidden', attr: {class: 'form-hidden'}}) }}
+ {{ form_widget(form.dateTo, {'id': 'dateToHidden', attr: {class: 'form-hidden'}}) }}
+
+
+
+{% endblock %}
diff --git a/templates/backoffice/layout.html.twig b/templates/backoffice/layout.html.twig
new file mode 100644
index 000000000..5cc276007
--- /dev/null
+++ b/templates/backoffice/layout.html.twig
@@ -0,0 +1,101 @@
+{% trans_default_domain 'backoffice' %}
+
+{% set user_initials = ibexa_field_value(app.user.APIUser, 'first_name').text[0:1] ~ ibexa_field_value(app.user.APIUser, 'last_name').text[0:1] %}
+
+
+
+
+
+
+
+
+
+ {% block meta %}{% endblock %}
+
+ {% for href in encore_entry_css_files('backoffice-index', 'app') %}
+
+ {% endfor %}
+
+ {% block stylesheets %}{% endblock %}
+
+
+ {% block title %}
+ {{ 'layout.title'|trans }}
+ {% endblock %}
+
+
+
+{% if is_granted('ROLE_IMPERSONATED_USER') %}
+
+
{{ 'layout.user_settings.you_are_impersonating'|trans }} {{ app.user.APIUser.name }}
+
{{ 'layout.user_settings.end_impersonation'|trans }}
+
+{% endif %}
+
+
+
+{% for src in encore_entry_js_files('backoffice-index', 'app') %}
+
+{% endfor %}
+
+{% block javascripts %}{% endblock %}
+
+
diff --git a/templates/backoffice/main_menu.html.twig b/templates/backoffice/main_menu.html.twig
new file mode 100644
index 000000000..78c9281cf
--- /dev/null
+++ b/templates/backoffice/main_menu.html.twig
@@ -0,0 +1,72 @@
+{% trans_default_domain 'backoffice' %}
+
+{% set user_initials = ibexa_field_value(app.user.APIUser, 'first_name').text[0:1] ~ ibexa_field_value(app.user.APIUser, 'last_name').text[0:1] %}
+
+{% set site_name = ngsite.siteInfoContent.fields.site_name.value.text|trim %}
+
+
diff --git a/templates/backoffice/menu/knp_menu.html.twig b/templates/backoffice/menu/knp_menu.html.twig
new file mode 100644
index 000000000..e40862efa
--- /dev/null
+++ b/templates/backoffice/menu/knp_menu.html.twig
@@ -0,0 +1,75 @@
+{% extends '@KnpMenu/menu.html.twig' %}
+
+{# To override this template, copy and paste relevant blocks from '@KnpMenu/menu.html.twig' and 'knp_menu.html.twig' #}
+
+{% block item %}
+ {% if item.displayed %}
+ {# building the class of the item #}
+ {%- set classes = item.attribute('class') is not empty ? [item.attribute('class')] : [] %}
+
+ {%- if matcher.isCurrent(item) %}
+ {%- set classes = classes|merge([options.currentClass]) %}
+ {%- elseif matcher.isAncestor(item, options.matchingDepth) %}
+ {%- set classes = classes|merge([options.ancestorClass]) %}
+ {%- endif %}
+
+ {%- if item.actsLikeFirst %}
+ {%- set classes = classes|merge([options.firstClass]) %}
+ {%- endif %}
+
+ {%- if item.actsLikeLast %}
+ {%- set classes = classes|merge([options.lastClass]) %}
+ {%- endif %}
+
+ {# Mark item as "leaf" (no children) or as "branch" (has children that are displayed) #}
+ {% if item.hasChildren and options.depth is not same as(0) %}
+ {% if options.branch_class is not empty and item.displayChildren %}
+ {%- set classes = classes|merge([options.branch_class]) %}
+ {% endif %}
+ {% elseif options.leaf_class is not empty %}
+ {%- set classes = classes|merge([options.leaf_class]) %}
+ {%- endif %}
+
+ {%- set attributes = item.attributes %}
+
+ {%- if classes is not empty %}
+ {%- set attributes = attributes|merge({'class': classes|join(' ')}) %}
+ {%- endif %}
+
+ {# displaying the item #}
+ {% import _self as knp_menu %}
+
+
+ {% endif %}
+{% endblock %}
diff --git a/templates/backoffice/my_account/change_email.html.twig b/templates/backoffice/my_account/change_email.html.twig
new file mode 100644
index 000000000..d30972cbc
--- /dev/null
+++ b/templates/backoffice/my_account/change_email.html.twig
@@ -0,0 +1,21 @@
+{% trans_default_domain 'backoffice' %}
+
+
+ {{ form_start(form) }}
+
+ {{ form_label(form.email_username) }}
+
+ {{ form_widget(form.email_username) }}
+ {{ '@' ~ app.user.APIUser.email|split('@')[1] }}
+
+
+
+
+ {{ 'my_account.button.discard'|trans }}
+ {{ 'my_account.button.save_changes'|trans }}
+
+ {{ form_end(form) }}
+
+
diff --git a/templates/backoffice/my_account/change_password.html.twig b/templates/backoffice/my_account/change_password.html.twig
new file mode 100644
index 000000000..c9ea838e1
--- /dev/null
+++ b/templates/backoffice/my_account/change_password.html.twig
@@ -0,0 +1,15 @@
+{% trans_default_domain 'backoffice' %}
+
+
+ {{ form_start(form) }}
+ {{ form_row(form.current_password) }}
+ {{ form_row(form.password) }}
+
+
+ {{ 'my_account.button.discard'|trans }}
+ {{ 'my_account.button.save_changes'|trans }}
+
+ {{ form_end(form) }}
+
diff --git a/templates/backoffice/my_account/menu.html.twig b/templates/backoffice/my_account/menu.html.twig
new file mode 100644
index 000000000..b66cdec09
--- /dev/null
+++ b/templates/backoffice/my_account/menu.html.twig
@@ -0,0 +1,9 @@
+
+ {{ knp_menu_render(
+ knp_menu_get('backoffice.my_account_menu'), {
+ 'leaf_class': 'top-navigation-btn',
+ 'currentClass': 'top-navigation-btn--active',
+ 'template': 'backoffice/menu/knp_menu.html.twig'
+ }
+ ) }}
+
diff --git a/templates/backoffice/my_account/personal_details.html.twig b/templates/backoffice/my_account/personal_details.html.twig
new file mode 100644
index 000000000..3311e197a
--- /dev/null
+++ b/templates/backoffice/my_account/personal_details.html.twig
@@ -0,0 +1,76 @@
+{% extends 'backoffice/layout.html.twig' %}
+
+{% trans_default_domain 'backoffice' %}
+
+{% form_theme form.image _self %}
+{% form_theme form.remove_image _self %}
+
+{% block top_navigation %}
+ {% include 'backoffice/my_account/menu.html.twig' %}
+{% endblock %}
+
+{% block content %}
+
+ {{ form_start(form, {attr: { class: 'js-custom-form' }}) }}
+
+ {% if errors is defined and errors|length > 0 %}
+
+ {% endif %}
+
+ {{ form_label(form.image) }}
+
+ {% if not ibexa_field_is_empty(app.user.APIUser, 'image') %}
+
+ {% else %}
+
+ {% endif %}
+ {{ form_widget(form.image, {remove_image_form: form.remove_image}) }}
+
+
+
+ {{ form_row(form.first_name) }}
+ {{ form_row(form.last_name) }}
+
+
+
+ {{ 'my_account.button.discard'|trans }}
+ {{ 'my_account.button.save_changes'|trans }}
+
+ {{ form_end(form) }}
+
+{% endblock %}
+
+{% block _personal_details_image_widget %}
+ {% set user = app.user.APIUser %}
+
+ {% set image_class = not ibexa_field_is_empty(user, 'image') ? ' hidden' : '' %}
+
+
+ {{ form_widget(remove_image_form) }}
+ {{ form_widget(form, {attr: {accept: 'image/*', class: 'image-upload js-image-upload' ~ image_class }}) }}
+
+
+ {{ 'form.my_account.image.replace_image'|trans }}
+
+
+{% endblock %}
+
+{% block _personal_details_remove_image_widget %}
+ {% set user = app.user.APIUser %}
+
+
+
+
+ {{ user.field('image').value.fileName }}
+
+
+ {{ form_widget(form, {attr: {class: 'remove-image-btn js-remove-image'}}) }}
+
+
+{% endblock %}
diff --git a/templates/backoffice/my_account/user_credentials.html.twig b/templates/backoffice/my_account/user_credentials.html.twig
new file mode 100644
index 000000000..6ae2349b0
--- /dev/null
+++ b/templates/backoffice/my_account/user_credentials.html.twig
@@ -0,0 +1,13 @@
+{% extends 'backoffice/layout.html.twig' %}
+
+{% trans_default_domain 'backoffice' %}
+
+{% block top_navigation %}
+ {% include 'backoffice/my_account/menu.html.twig' %}
+{% endblock %}
+
+{% block content %}
+ {% include 'backoffice/my_account/change_email.html.twig' with {form: email_form} only %}
+
+ {% include 'backoffice/my_account/change_password.html.twig' with {form: password_form} only %}
+{% endblock %}
diff --git a/templates/backoffice/notifications/digest.html.twig b/templates/backoffice/notifications/digest.html.twig
new file mode 100644
index 000000000..4e06ce9d4
--- /dev/null
+++ b/templates/backoffice/notifications/digest.html.twig
@@ -0,0 +1,7 @@
+{% for notification in notifications %}
+ {{ notification.title }}
+
+ {{ notification.sentAt|format_datetime }}
+
+ {{ notification.text|raw }}
+{% endfor %}
diff --git a/templates/backoffice/notifications/layout.html.twig b/templates/backoffice/notifications/layout.html.twig
new file mode 100644
index 000000000..03f878ef3
--- /dev/null
+++ b/templates/backoffice/notifications/layout.html.twig
@@ -0,0 +1,14 @@
+{% extends 'backoffice/layout.html.twig' %}
+
+{% block title %}{{ title|default('Notifications') }}{% endblock %}
+
+{% block javascripts %}
+ {% include '@NetgenNotifications/admin/javascripts.html.twig' %}
+{% endblock %}
+
+{% block stylesheets %}
+ {% include '@NetgenNotifications/admin/stylesheets.html.twig' %}
+{% endblock %}
+
+{% block content %}
+{% endblock %}
diff --git a/templates/backoffice/notifications/new_content.html.twig b/templates/backoffice/notifications/new_content.html.twig
new file mode 100644
index 000000000..7aacb2a99
--- /dev/null
+++ b/templates/backoffice/notifications/new_content.html.twig
@@ -0,0 +1,4 @@
+New content "{{ content_name }}" has been published.
+
+See content details here .
+
diff --git a/templates/backoffice/notifications/notification.html.twig b/templates/backoffice/notifications/notification.html.twig
new file mode 100644
index 000000000..77030c3fa
--- /dev/null
+++ b/templates/backoffice/notifications/notification.html.twig
@@ -0,0 +1,3 @@
+{{ notification.sentAt|format_datetime }}
+
+{{ notification.text|raw }}
diff --git a/templates/backoffice/notifications/updated_content.html.twig b/templates/backoffice/notifications/updated_content.html.twig
new file mode 100644
index 000000000..7aebd68e1
--- /dev/null
+++ b/templates/backoffice/notifications/updated_content.html.twig
@@ -0,0 +1,3 @@
+Content "{{ content_name }}" has been updated.
+
+See content details here .
diff --git a/templates/backoffice/parts/confirm_modal.html.twig b/templates/backoffice/parts/confirm_modal.html.twig
new file mode 100644
index 000000000..b8613f1c1
--- /dev/null
+++ b/templates/backoffice/parts/confirm_modal.html.twig
@@ -0,0 +1,6 @@
+
diff --git a/templates/backoffice/user_impersonation/layout.html.twig b/templates/backoffice/user_impersonation/layout.html.twig
new file mode 100644
index 000000000..a998ebbe1
--- /dev/null
+++ b/templates/backoffice/user_impersonation/layout.html.twig
@@ -0,0 +1,15 @@
+{% extends 'backoffice/layout.html.twig' %}
+
+{% block title %}{{ title|default('User impersonation') }}{% endblock %}
+
+{% block pre_content %}
+
+
+
+{% endblock %}
+
+{% block post_content %}
+
+
+
+{% endblock %}
diff --git a/templates/themes/app/pagelayout.html.twig b/templates/themes/app/pagelayout.html.twig
index 07b714d45..3003513e8 100644
--- a/templates/themes/app/pagelayout.html.twig
+++ b/templates/themes/app/pagelayout.html.twig
@@ -60,6 +60,8 @@
{% endblock %}
{% endblock %}
+ {% include '@ibexadesign/parts/page_floating_widgets.html.twig' %}
+
{{ nglayouts_template_plugin('preview.body') }}
diff --git a/templates/themes/app/pagelayout/header/login.html.twig b/templates/themes/app/pagelayout/header/login.html.twig
new file mode 100644
index 000000000..e3674667d
--- /dev/null
+++ b/templates/themes/app/pagelayout/header/login.html.twig
@@ -0,0 +1,52 @@
+{% trans_default_domain 'messages' %}
+
+{% set menu_url = path('login') %}
+{% set menu_label = 'ngsite.layout.login'|trans %}
+
+{% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
+ {% set menu_url = '#' %}
+ {% set menu_label = '%s %s'|format(
+ ibexa_field_value(app.user.APIUser, 'first_name').text,
+ ibexa_field_value(app.user.APIUser, 'last_name').text
+ ) %}
+{% endif %}
+
+
diff --git a/templates/themes/app/pagelayout/header/main_menu.html.twig b/templates/themes/app/pagelayout/header/main_menu.html.twig
index 9a65a0261..fa8786925 100644
--- a/templates/themes/app/pagelayout/header/main_menu.html.twig
+++ b/templates/themes/app/pagelayout/header/main_menu.html.twig
@@ -7,5 +7,9 @@
}
)
) }}
+
+ {% if app.request.attributes.get('siteaccess').name == 'peak_eng' %}
+ {{ render_esi(controller('App\\Controller\\PageLayout\\UserMenu')) }}
+ {% endif %}
{% endblock %}
diff --git a/templates/themes/app/parts/page_floating_widgets.html.twig b/templates/themes/app/parts/page_floating_widgets.html.twig
new file mode 100644
index 000000000..59b7fcb44
--- /dev/null
+++ b/templates/themes/app/parts/page_floating_widgets.html.twig
@@ -0,0 +1,51 @@
+{% set notification_category = null %}
+
+{% if location is defined %}
+ {% set notification_category = ngnotifications_category(location) %}
+{% endif %}
+
+
+ {% if is_granted('IS_AUTHENTICATED_REMEMBERED') and notification_category is not null %}
+
+ {% endif %}
+
+
+
+
+
+ {% if location is defined and is_granted('IS_AUTHENTICATED_REMEMBERED') %}
+ {% if location.contentInfo.contentType.identifier in ibexa.configResolver.parameter('bookmarks.content_types', 'ngsite') %}
+ {% set is_bookmarked = ngsite_bookmark(location) is not null %}
+
+
+
+
+
+ {% endif %}
+ {% endif %}
+
+
+{% if is_granted('IS_AUTHENTICATED_REMEMBERED') and notification_category is not null %}
+
+
+
+
+ {% include '@NetgenNotifications/subscription/widget/widget.html.twig' with {
+ category: notification_category.identifier,
+ parameters: { contentId: content.id, locationId: location.id }
+ } %}
+
+
+
+
+{% endif %}
diff --git a/templates/themes/peak/.gitignore b/templates/themes/peak/.gitignore
new file mode 100644
index 000000000..e69de29bb
diff --git a/translations/backoffice.en.yaml b/translations/backoffice.en.yaml
new file mode 100644
index 000000000..84d0d10d7
--- /dev/null
+++ b/translations/backoffice.en.yaml
@@ -0,0 +1,68 @@
+layout.title: 'Admin Dashboard'
+layout.hello: 'Hello'
+layout.report_an_issue: 'Report an issue'
+layout.user_settings: 'User settings'
+layout.user_settings.end_impersonation: 'End impersonation'
+layout.user_settings.you_are_impersonating: 'You are impersonating'
+
+menu.main_menu.dashboard: 'Dashboard'
+menu.main_menu.bookmarks: 'Bookmarks'
+menu.main_menu.conversations: 'Conversations'
+menu.main_menu.personal_details: 'Personal details'
+menu.main_menu.user_credentials: 'User credentials'
+menu.main_menu.impersonate: 'Impersonate'
+menu.main_menu.preferences: 'Preferences'
+menu.main_menu.logout: 'Log out'
+menu.main_menu.notifications: 'Notifications'
+menu.main_menu.notifications.inbox: 'Inbox'
+menu.main_menu.notifications.archive: 'Archive'
+menu.main_menu.notifications.subscriptions: 'Subscriptions'
+
+bookmarks.title: 'Title'
+bookmarks.type: 'Type'
+bookmarks.saved: 'Saved'
+bookmarks.options: 'Options'
+bookmarks.no_bookmarks: 'No bookmarks :('
+bookmarks.view_content: 'View content'
+bookmarks.delete: 'Delete'
+bookmarks.delete.title: 'Delete bookmark'
+bookmarks.delete.confirmation: 'Are you sure you want to delete the bookmark for "%content%" page?'
+bookmarks.delete.cancel: 'Cancel'
+bookmarks.filter.type.label: 'Type'
+bookmarks.filter.type.empty_option: 'Select type'
+bookmarks.filter.date.label: 'Date'
+bookmarks.filter.date.placeholder: 'Select date'
+bookmarks.filter.search_text.placeholder: 'Search bookmarks'
+bookmarks.filter.button.more: 'More filters'
+bookmarks.filter.button.apply: 'Apply filter'
+bookmarks.filter.button.reset: 'Reset all'
+
+my_account.preferences.send_to_email: 'Send to email'
+my_account.button.discard: 'Discard'
+my_account.button.save_changes: 'Save changes'
+my_account.password_requirements: 'The password must have at least 10 characters and at least one uppercase and one lowercase letter.'
+my_account.email_requirements: "You will receive a confirmation email with a link to confirm your new email address. The link will be valid for 15 minutes. If you don't confirm, the change won't be accepted."
+
+my_account.change_email.already_requested: 'You already requested change of e-mail address. Check you inbox or try again later.'
+my_account.change_email.same_email: 'You requested the same e-mail address.'
+my_account.change_email.email_exists: 'The e-mail address you entered is used by another user.'
+my_account.change_email.invalid_email: 'You requested invalid e-mail address.'
+my_account.change_email.activation_mail_sent: 'We sent you activation e-mail with confirmation link. Please check your inbox.'
+my_account.change_email.already_activated: 'E-mail address has already been activated.'
+my_account.change_email.token_expired: 'Your request for e-mail address change has expired. Please try again.'
+my_account.change_email.successfully_changed: 'E-mail address successfully changed.'
+
+my_account.user_successfully_updated: 'User successfully updated.'
+
+form.my_account.image.replace_image: 'Replace image'
+form.my_account.placeholder.search: 'Type to search...'
+form.my_account.no_results: 'No results'
+form.my_account.searching: 'Searching...'
+
+form.my_account.user_credentials.email: 'E-mail'
+
+form.my_account.personal_details.image: 'Image'
+form.my_account.personal_details.first_name: 'First name'
+form.my_account.personal_details.last_name: 'Last name'
+form.user_password.password.label: 'Password'
+form.user_password.repeat_password.label: 'Repeat password'
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml
index b0a148e8e..40c2cd3c3 100644
--- a/translations/messages.en.yaml
+++ b/translations/messages.en.yaml
@@ -40,3 +40,10 @@ ngsite.job_application.responsibilities: 'Responsibilities'
ngsite.job_application.deadline: 'Application deadline'
ngsite.skip_to_main_content: 'Skip to main content'
+ngsite.widget.subscriptions: 'Subscriptions'
+
+ngsite.layout.login: 'Log in'
+ngsite.layout.register: 'Register'
+ngsite.layout.logout: 'Logout'
+ngsite.layout.my_dashboard: 'My dashboard'
+ngsite.layout.my_profile: 'My profile'
diff --git a/translations/ngnotifications.en.yaml b/translations/ngnotifications.en.yaml
new file mode 100644
index 000000000..d4043be58
--- /dev/null
+++ b/translations/ngnotifications.en.yaml
@@ -0,0 +1 @@
+category.default: 'Default'
diff --git a/webpack.config.default.js b/webpack.config.default.js
index dbecd7dc0..9308d4117 100644
--- a/webpack.config.default.js
+++ b/webpack.config.default.js
@@ -22,6 +22,7 @@ Encore
// will create public/assets/app/build/index.js and public/assets/app/build/index.css
.addEntry('index', `./${siteConfig.assetsLocation}/js/index.js`)
.addEntry('index-noncritical', `./${siteConfig.assetsLocation}/js/index-noncritical.js`)
+ .addEntry('backoffice-index', `./${siteConfig.assetsLocation}/js/backoffice/index.js`)
// allow sass/scss files to be processed
.enableSassLoader((options) => {