Skip to content
1 change: 0 additions & 1 deletion src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
<router-outlet />
<osf-toast></osf-toast>
<osf-cookie-consent-banner></osf-cookie-consent-banner>
<osf-full-screen-loader></osf-full-screen-loader>
3 changes: 1 addition & 2 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';

import { CookieConsentBannerComponent } from '@core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component';
import { ENVIRONMENT } from '@core/provider/environment.provider';
import { GetCurrentUser } from '@core/store/user';
import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails';
Expand All @@ -22,7 +21,7 @@ import { GoogleTagManagerService } from 'angular-google-tag-manager';

@Component({
selector: 'osf-root',
imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent, CookieConsentBannerComponent],
imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<p-toast key="cookie" position="bottom-center" [styleClass]="'cookie-toast'">
<ng-template let-message pTemplate="message">
<div class="flex flex-column gap-2">
<span>{{ message.detail }}</span>
<div class="flex justify-content-end">
<p-button
type="button"
label="{{ 'toast.cookie-consent.accept' | translate }}"
(click)="acceptCookies()"
></p-button>
@if (this.displayBanner()) {
<div class="cookie-consent-container" @fadeInOut>
<div class="w-full p-message">
<div class="grid flex-row p-message-content">
<div class="col">{{ 'toast.cookie-consent.message' | translate }}</div>
<div class="col-fixed flex justify-content-end">
<p-button
type="button"
label="{{ 'toast.cookie-consent.accept' | translate }}"
(click)="acceptCookies()"
></p-button>
</div>
</div>
</div>
</ng-template>
</p-toast>
</div>
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
:host ::ng-deep .cookie-toast {
width: 900px;
max-width: min(92vw, 960px);
left: 50% !important;
transform: translateX(-50%) !important;
}
@use "styles/mixins" as mix;

:host ::ng-deep .cookie-toast .p-toast-message {
width: 100%;
}
.cookie-consent-container {
padding: 1rem !important;
display: block !important;

.p-message {
background-color: #886d3f;
color: var(--white);
width: 100%;
border-radius: var(--p-message-border-radius, 6px);
outline-width: var(--p-message-border-width, 1px);
outline-style: solid;

:host ::ng-deep .cookie-toast .p-toast-message .p-toast-message-content {
color: #fcf8e3;
width: 100%;
.p-message-content {
display: flex;
align-items: center;
padding: var(--p-message-content-padding, 1.5rem);
gap: var(--p-message-content-gap, 0.5rem);
height: 100%;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,84 +1,57 @@
import { TranslateService } from '@ngx-translate/core';
import { CookieService } from 'ngx-cookie-service';

import { MessageService } from 'primeng/api';

import { of } from 'rxjs';
import { Button } from 'primeng/button';

import { ComponentFixture, TestBed } from '@angular/core/testing';

import { CookieConsentService } from '../../../../shared/services/cookie-consent/cookie-consent.service';

import { CookieConsentBannerComponent } from './cookie-consent-banner.component';

describe('CookieConsentComponent', () => {
let component: CookieConsentBannerComponent;
import { OSFTestingModule } from '@testing/osf.testing.module';

describe('Component: Cookie Consent Banner', () => {
let fixture: ComponentFixture<CookieConsentBannerComponent>;
let mockToastService: jest.Mocked<MessageService>;
let mockConsentService: jest.Mocked<CookieConsentService>;
let mockTranslateService: jest.Mocked<TranslateService>;
let component: CookieConsentBannerComponent;

const cookieServiceMock = {
check: jest.fn(),
set: jest.fn(),
};

beforeEach(async () => {
mockToastService = {
add: jest.fn(),
clear: jest.fn(),
} as unknown as jest.Mocked<MessageService>;
await TestBed.configureTestingModule({
imports: [OSFTestingModule, CookieConsentBannerComponent, Button],

mockConsentService = {
hasConsent: jest.fn(),
grantConsent: jest.fn(),
} as unknown as jest.Mocked<CookieConsentService>;
providers: [{ provide: CookieService, useValue: cookieServiceMock }],
});

mockTranslateService = {
get: jest.fn(),
} as unknown as jest.Mocked<TranslateService>;
jest.clearAllMocks();
});

await TestBed.configureTestingModule({
imports: [CookieConsentBannerComponent],
providers: [
{ provide: MessageService, useValue: mockToastService },
{ provide: CookieConsentService, useValue: mockConsentService },
{ provide: TranslateService, useValue: mockTranslateService },
],
}).compileComponents();
it('should show the banner if cookie is not set', () => {
cookieServiceMock.check.mockReturnValue(false);
fixture = TestBed.createComponent(CookieConsentBannerComponent);
component = fixture.componentInstance;

expect(component.displayBanner()).toBe(true);
});

it('should hide the banner if cookie is set', () => {
cookieServiceMock.check.mockReturnValue(true);
fixture = TestBed.createComponent(CookieConsentBannerComponent);
component = fixture.componentInstance;

expect(component.displayBanner()).toBe(false);
});
describe('ngAfterViewInit', () => {
it('should show toast if no consent', () => {
mockConsentService.hasConsent.mockReturnValue(false);
mockTranslateService.get.mockReturnValue(of('Please accept cookies'));

component.ngAfterViewInit();

// wait for queueMicrotask to execute
return Promise.resolve().then(() => {
expect(mockTranslateService.get).toHaveBeenCalledWith('toast.cookie-consent.message');
expect(mockToastService.add).toHaveBeenCalledWith({
detail: 'Please accept cookies',
key: 'cookie',
sticky: true,
severity: 'warn',
closable: false,
});
});
});

it('should not show toast if consent already given', () => {
mockConsentService.hasConsent.mockReturnValue(true);
it('should set cookie and hide banner on acceptCookies()', () => {
cookieServiceMock.check.mockReturnValue(false);
fixture = TestBed.createComponent(CookieConsentBannerComponent);
component = fixture.componentInstance;

component.ngAfterViewInit();
component.acceptCookies();

expect(mockTranslateService.get).not.toHaveBeenCalled();
expect(mockToastService.add).not.toHaveBeenCalled();
});
});
expect(cookieServiceMock.set).toHaveBeenCalledWith('cookie-consent', 'true', new Date('9999-12-31T23:59:59Z'), '/');

describe('acceptCookies', () => {
it('should grant consent and clear toast', () => {
component.acceptCookies();
expect(mockConsentService.grantConsent).toHaveBeenCalled();
expect(mockToastService.clear).toHaveBeenCalledWith('cookie');
});
expect(component.displayBanner()).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,42 +1,61 @@
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CookieService } from 'ngx-cookie-service';
import { TranslatePipe } from '@ngx-translate/core';

import { MessageService, PrimeTemplate } from 'primeng/api';
import { Button } from 'primeng/button';
import { Toast } from 'primeng/toast';

import { AfterViewInit, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';

import { CookieConsentService } from '../../../../shared/services/cookie-consent/cookie-consent.service';
import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation';

/**
* Displays a cookie consent banner until the user accepts.
*
* - Uses `ngx-cookie-service` to persist acceptance across sessions.
* - Automatically hides the banner if consent is already recorded.
* - Animates in/out using the `fadeInOutAnimation`.
* - Supports translation via `TranslatePipe`.
*/
@Component({
selector: 'osf-cookie-consent-banner',
templateUrl: './cookie-consent-banner.component.html',
styleUrls: ['./cookie-consent-banner.component.scss'],
imports: [Toast, Button, PrimeTemplate, TranslatePipe],
imports: [Button, TranslatePipe],
animations: [fadeInOutAnimation],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CookieConsentBannerComponent implements AfterViewInit {
private readonly toastService = inject(MessageService);
private readonly consentService = inject(CookieConsentService);
private readonly translateService = inject(TranslateService);

ngAfterViewInit() {
if (!this.consentService.hasConsent()) {
this.translateService.get('toast.cookie-consent.message').subscribe((detail) => {
queueMicrotask(() =>
this.toastService.add({
detail,
key: 'cookie',
sticky: true,
severity: 'warn',
closable: false,
})
);
});
}
export class CookieConsentBannerComponent {
/**
* The name of the cookie used to track whether the user accepted cookies.
*/
private readonly cookieName = 'cookie-consent';

/**
* Signal controlling the visibility of the cookie banner.
* Set to `true` if the user has not accepted cookies yet.
*/
readonly displayBanner = signal<boolean>(false);

/**
* Cookie service used to persist dismissal state in the browser.
*/
private readonly cookies = inject(CookieService);

/**
* Initializes the component and sets the banner display
* based on the existence of the cookie.
*/
constructor() {
this.displayBanner.set(!this.cookies.check(this.cookieName));
}

/**
* Called when the user accepts cookies.
* - Sets a persistent cookie with a far-future expiration.
* - Hides the banner immediately.
*/
acceptCookies() {
this.consentService.grantConsent();
this.toastService.clear('cookie');
const expireDate = new Date('9999-12-31T23:59:59Z');
this.cookies.set(this.cookieName, 'true', expireDate, '/');
this.displayBanner.set(false);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<osf-maintenance-banner></osf-maintenance-banner>
<osf-scheduled-banner></osf-scheduled-banner>
<osf-maintenance-banner></osf-maintenance-banner>
<osf-cookie-consent-banner></osf-cookie-consent-banner>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.w-full {
height: 108px !important;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { BannersSelector, GetCurrentScheduledBanner } from '@osf/shared/stores/b
@Component({
selector: 'osf-scheduled-banner',
templateUrl: './scheduled-banner.component.html',
styleUrls: ['./scheduled-banner.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ScheduledBannerComponent implements OnInit {
Expand Down
2 changes: 1 addition & 1 deletion src/app/core/components/root/root.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { HeaderComponent } from '@core/components/header/header.component';
import { TopnavComponent } from '@core/components/topnav/topnav.component';
import { IS_WEB, IS_XSMALL } from '@osf/shared/helpers';

import { OSFBannerComponent } from '../osf-banners/osf.banner.component';
import { OSFBannerComponent } from '../osf-banners/osf-banner.component';
import { SidenavComponent } from '../sidenav/sidenav.component';

import { RootComponent } from './root.component';
Expand Down

This file was deleted.

This file was deleted.

18 changes: 0 additions & 18 deletions src/app/shared/services/cookie-consent/cookie-consent.service.ts

This file was deleted.

Loading